Skip to main content

greentic_deployer/
sorx_routing.rs

1//! Alias-aware routing layer for the bundled single-operator case (S3-deployer,
2//! decision B1 / Option A).
3//!
4//! This module is the **verifiable core** of the deployer-owned thin
5//! orchestrator/router. It consumes SoRX's routing-table
6//! (`GET /v1/sorx/routing-table?tenant=&sor=`) and decides where an inbound
7//! `/{tenant}/{sor}/{alias}/{rest}` request should be forwarded.
8//!
9//! ## What is in scope
10//!
11//! 1. [`AliasResolver`] — a TTL-cached resolver over a [`RoutingTableSource`]
12//!    that maps `(tenant, sor, alias)` to a routable [`RouteRow`]
13//!    (`deployment_id`, `base_path`, …). Stale-on-error: if a refetch fails,
14//!    the last good table is served.
15//! 2. [`route_request`] — a **pure** decision function: given a resolver, an
16//!    injected [`UpstreamRegistry`], and the parts of an inbound HTTP request,
17//!    it returns a [`ProxyOutcome`] (`Forward { upstream, rewritten_path }` or
18//!    `NotFound`). It performs no network I/O, so the full routing logic is
19//!    unit-testable without sockets.
20//!
21//! ## What is OUT of scope (documented boundary — deferred follow-up)
22//!
23//! The actual **N-process spawning** — starting one SoRX instance per
24//! deployment and tracking its live `host:port` — is the infra-coupled part of
25//! Option A and is *not* built here. Instead the live instance address is an
26//! injected parameter: [`UpstreamRegistry::upstream_for`]. The v1
27//! implementation is [`StaticUpstreamRegistry`] (a fixed map). A live
28//! orchestrator registry (process spawner + health tracking) is the documented
29//! follow-up; it slots in behind the same [`UpstreamRegistry`] trait without
30//! touching the routing logic.
31//!
32//! Likewise, the real network forward (binding a listener, copying bytes) is a
33//! thin wrapper around [`route_request`]; see [`describe_request`] for the
34//! CLI-facing dry-run that exercises the decision layer without a listener.
35
36use std::collections::HashMap;
37use std::sync::Mutex;
38use std::time::{Duration, Instant};
39
40use serde::{Deserialize, Serialize};
41
42/// One row of the SoRX routing-table
43/// (`GET /v1/sorx/routing-table` → `{ "schema": ..., "routes": [ … ] }`).
44///
45/// Field set mirrors the SoRX contract exactly; unknown future fields are
46/// ignored on deserialize so a SoRX schema bump does not break the resolver.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct RouteRow {
49    pub tenant_id: String,
50    pub sor_name: String,
51    pub alias: String,
52    pub deployment_id: String,
53    pub pack_name: String,
54    pub pack_version: String,
55    /// Path prefix the upstream instance serves under (e.g. `/sor/customer`).
56    pub base_path: String,
57    pub state_namespace: String,
58    pub visibility: String,
59    /// Whether this row may currently receive traffic. Only `routable == true`
60    /// rows are resolvable.
61    pub routable: bool,
62    /// Traffic weight (0..=100). Carried through for parity with the SoRX
63    /// contract; weighted split across multiple routable rows for the same
64    /// alias is a follow-up (v1 picks the first routable row).
65    pub traffic: u32,
66}
67
68/// Wire envelope for `GET /v1/sorx/routing-table`.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct RoutingTable {
71    pub schema: String,
72    pub routes: Vec<RouteRow>,
73}
74
75/// Source of routing-table rows. Sync by design: the deployer's HTTP stack is
76/// `reqwest::blocking`, and the router runs off the request hot-path behind a
77/// TTL cache, so a blocking fetch is appropriate and keeps the trait
78/// object-safe and trivially fakeable in tests.
79pub trait RoutingTableSource: Send + Sync {
80    /// Fetch the routing-table from SoRX. `tenant` / `sor` are optional server
81    /// side filters; passing `None` fetches the full table. Errors are
82    /// returned as a `String` so the resolver can apply stale-on-error without
83    /// coupling to a concrete error type.
84    fn fetch(
85        &self,
86        sorx_base_url: &str,
87        tenant: Option<&str>,
88        sor: Option<&str>,
89    ) -> Result<Vec<RouteRow>, String>;
90}
91
92/// Maps a resolved deployment to its live instance address. **Supplied by the
93/// orchestrator (process spawner) — NOT resolved here.**
94///
95/// v1: a static map ([`StaticUpstreamRegistry`]). A live orchestrator registry
96/// that tracks spawned SoRX processes is the documented follow-up; it
97/// implements this same trait.
98pub trait UpstreamRegistry: Send + Sync {
99    /// The live `host:port` (e.g. `"127.0.0.1:8088"`) for a `deployment_id`, or
100    /// `None` if no instance is currently registered/healthy.
101    fn upstream_for(&self, deployment_id: &str) -> Option<String>;
102}
103
104/// v1 [`UpstreamRegistry`]: a fixed `deployment_id -> host:port` map injected
105/// at construction (e.g. parsed from `--upstreams <json>`).
106#[derive(Debug, Clone, Default)]
107pub struct StaticUpstreamRegistry {
108    map: HashMap<String, String>,
109}
110
111impl StaticUpstreamRegistry {
112    pub fn new(map: HashMap<String, String>) -> Self {
113        Self { map }
114    }
115}
116
117impl UpstreamRegistry for StaticUpstreamRegistry {
118    fn upstream_for(&self, deployment_id: &str) -> Option<String> {
119        self.map.get(deployment_id).cloned()
120    }
121}
122
123/// Cache key for a resolved table slice. The resolver caches per
124/// `(tenant, sor)` fetch scope so different filter scopes do not clobber each
125/// other.
126#[derive(Debug, Clone, PartialEq, Eq, Hash)]
127struct ScopeKey {
128    tenant: Option<String>,
129    sor: Option<String>,
130}
131
132struct CacheEntry {
133    rows: Vec<RouteRow>,
134    fetched_at: Instant,
135}
136
137/// Resolves `(tenant, sor, alias)` to a routable [`RouteRow`] from a TTL-cached
138/// routing table. Stale-on-error: a failed refetch falls back to the last good
139/// table for that scope.
140pub struct AliasResolver {
141    source: Box<dyn RoutingTableSource>,
142    ttl: Duration,
143    cache: Mutex<HashMap<ScopeKey, CacheEntry>>,
144}
145
146impl AliasResolver {
147    pub fn new(source: Box<dyn RoutingTableSource>, ttl: Duration) -> Self {
148        Self {
149            source,
150            ttl,
151            cache: Mutex::new(HashMap::new()),
152        }
153    }
154
155    /// Return the routable [`RouteRow`] for `(tenant, sor, alias)`, or `None`.
156    ///
157    /// The table is fetched (scoped to `tenant`/`sor`) and cached for `ttl`.
158    /// Within the TTL the cache is reused; after expiry a refetch is attempted.
159    /// If the refetch fails but a previous good table exists, the stale table
160    /// is used (stale-on-error). Only `routable == true` rows match.
161    pub fn resolve(
162        &self,
163        sorx_base_url: &str,
164        tenant: &str,
165        sor: &str,
166        alias: &str,
167    ) -> Option<RouteRow> {
168        let rows = self.rows_for_scope(sorx_base_url, tenant, sor);
169        select_routable(&rows, tenant, sor, alias)
170    }
171
172    /// Fetch-or-cache the rows for the `(tenant, sor)` scope, applying TTL and
173    /// stale-on-error semantics. Always queries SoRX with both filters set so
174    /// the returned slice is already scoped.
175    fn rows_for_scope(&self, sorx_base_url: &str, tenant: &str, sor: &str) -> Vec<RouteRow> {
176        let key = ScopeKey {
177            tenant: Some(tenant.to_string()),
178            sor: Some(sor.to_string()),
179        };
180
181        let mut cache = match self.cache.lock() {
182            Ok(guard) => guard,
183            // A poisoned lock means a previous holder panicked while mutating
184            // the cache. Recover the guard rather than propagating a panic:
185            // the router must keep serving, and the worst case is a re-fetch.
186            Err(poisoned) => poisoned.into_inner(),
187        };
188
189        let fresh = cache
190            .get(&key)
191            .map(|entry| entry.fetched_at.elapsed() < self.ttl)
192            .unwrap_or(false);
193
194        if fresh {
195            // Safe: `fresh` is only true when the entry exists.
196            if let Some(entry) = cache.get(&key) {
197                return entry.rows.clone();
198            }
199        }
200
201        match self.source.fetch(sorx_base_url, Some(tenant), Some(sor)) {
202            Ok(rows) => {
203                cache.insert(
204                    key,
205                    CacheEntry {
206                        rows: rows.clone(),
207                        fetched_at: Instant::now(),
208                    },
209                );
210                rows
211            }
212            Err(err) => {
213                // Stale-on-error: serve the last good table if we have one.
214                if let Some(entry) = cache.get(&key) {
215                    tracing::warn!(
216                        error = %err,
217                        tenant,
218                        sor,
219                        "sorx routing-table refetch failed; serving stale cache"
220                    );
221                    entry.rows.clone()
222                } else {
223                    tracing::warn!(
224                        error = %err,
225                        tenant,
226                        sor,
227                        "sorx routing-table fetch failed and no cache available"
228                    );
229                    Vec::new()
230                }
231            }
232        }
233    }
234}
235
236/// Pure selection: first `routable` row matching `(tenant, sor, alias)`.
237///
238/// v1 picks the first routable match. Weighted traffic split across multiple
239/// routable rows for the same alias (using `RouteRow::traffic`) is a follow-up.
240fn select_routable(rows: &[RouteRow], tenant: &str, sor: &str, alias: &str) -> Option<RouteRow> {
241    rows.iter()
242        .find(|r| r.routable && r.tenant_id == tenant && r.sor_name == sor && r.alias == alias)
243        .cloned()
244}
245
246/// Outcome of the pure routing decision.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum ProxyOutcome {
249    /// Forward the request to `upstream` (`host:port`) at `rewritten_path`.
250    Forward {
251        upstream: String,
252        rewritten_path: String,
253        deployment_id: String,
254    },
255    /// The request cannot be served. `status` is the HTTP status the proxy
256    /// should return (404 alias-unresolved / 503 no-upstream / 400 malformed).
257    NotFound { status: u16, reason: String },
258}
259
260/// Parsed parts of an inbound `/{tenant}/{sor}/{alias}/{rest}` request path.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct RequestPath {
263    pub tenant: String,
264    pub sor: String,
265    pub alias: String,
266    /// Everything after the alias segment, **without** a leading slash. May be
267    /// empty (the alias root was requested).
268    pub rest: String,
269}
270
271/// Parse `/{tenant}/{sor}/{alias}/{rest}` from a request path.
272///
273/// Requires at least the three leading segments (`tenant`, `sor`, `alias`);
274/// `rest` is optional. Any query string is stripped (the caller forwards it
275/// separately). Returns `None` for malformed paths so the caller can answer
276/// 400.
277pub fn parse_request_path(path: &str) -> Option<RequestPath> {
278    // Drop a query string if present; the decision layer routes on the path.
279    let path = path.split('?').next().unwrap_or(path);
280    let trimmed = path.trim_start_matches('/');
281    let mut segments = trimmed.splitn(4, '/');
282
283    let tenant = segments.next().filter(|s| !s.is_empty())?;
284    let sor = segments.next().filter(|s| !s.is_empty())?;
285    let alias = segments.next().filter(|s| !s.is_empty())?;
286    // `rest` may be absent (alias root) — default to empty.
287    let rest = segments.next().unwrap_or("");
288
289    Some(RequestPath {
290        tenant: tenant.to_string(),
291        sor: sor.to_string(),
292        alias: alias.to_string(),
293        rest: rest.to_string(),
294    })
295}
296
297/// Build the upstream forward path for a resolved route.
298///
299/// **Path-rewrite decision (v1):** the inbound `{tenant}/{sor}/{alias}` prefix
300/// is stripped and the remaining `{rest}` is forwarded under the resolved
301/// deployment's `base_path`, i.e. `{base_path}/{rest}`. Rationale: the alias is
302/// a routing handle that does not exist on the upstream instance; the upstream
303/// serves its own surface under `base_path`. Both `base_path` and `rest` are
304/// normalized so the result has exactly one slash between them and a single
305/// leading slash.
306fn rewrite_path(base_path: &str, rest: &str) -> String {
307    let base = base_path.trim_matches('/');
308    let rest = rest.trim_matches('/');
309    match (base.is_empty(), rest.is_empty()) {
310        (true, true) => "/".to_string(),
311        (true, false) => format!("/{rest}"),
312        (false, true) => format!("/{base}"),
313        (false, false) => format!("/{base}/{rest}"),
314    }
315}
316
317/// The pure routing decision: resolve the alias, then look up its upstream.
318///
319/// No network I/O. `method`, `headers`, and `body` are accepted so the live
320/// proxy wrapper can pass them straight through; the decision itself only
321/// depends on the path. They are intentionally unused by the decision and are
322/// forwarded verbatim by the network layer.
323///
324/// Returns:
325/// - `Forward` when the alias resolves to a routable deployment **and** that
326///   deployment has a registered upstream.
327/// - `NotFound { status: 400 }` for a malformed path.
328/// - `NotFound { status: 404 }` when the alias does not resolve to a routable
329///   deployment.
330/// - `NotFound { status: 503 }` when the alias resolves but no upstream is
331///   registered for the deployment (instance not yet spawned/healthy).
332pub fn route_request(
333    resolver: &AliasResolver,
334    upstreams: &dyn UpstreamRegistry,
335    sorx_base_url: &str,
336    _method: &str,
337    path: &str,
338    _headers: &[(String, String)],
339    _body: &[u8],
340) -> ProxyOutcome {
341    let parsed = match parse_request_path(path) {
342        Some(p) => p,
343        None => {
344            return ProxyOutcome::NotFound {
345                status: 400,
346                reason: format!(
347                    "malformed path {path:?}: expected /{{tenant}}/{{sor}}/{{alias}}/{{rest}}"
348                ),
349            };
350        }
351    };
352
353    let row = match resolver.resolve(sorx_base_url, &parsed.tenant, &parsed.sor, &parsed.alias) {
354        Some(row) => row,
355        None => {
356            return ProxyOutcome::NotFound {
357                status: 404,
358                reason: format!(
359                    "no routable deployment for tenant={} sor={} alias={}",
360                    parsed.tenant, parsed.sor, parsed.alias
361                ),
362            };
363        }
364    };
365
366    let upstream = match upstreams.upstream_for(&row.deployment_id) {
367        Some(addr) => addr,
368        None => {
369            return ProxyOutcome::NotFound {
370                status: 503,
371                reason: format!(
372                    "no live upstream registered for deployment_id={} \
373                     (instance not spawned/healthy yet)",
374                    row.deployment_id
375                ),
376            };
377        }
378    };
379
380    ProxyOutcome::Forward {
381        upstream,
382        rewritten_path: rewrite_path(&row.base_path, &parsed.rest),
383        deployment_id: row.deployment_id,
384    }
385}
386
387/// CLI-facing dry-run: run the routing decision for a sample request and return
388/// a JSON-serializable description. Used by the `sorx route --dry-run`
389/// subcommand so the decision layer is exercisable without binding a listener
390/// (the live listener is the documented follow-up).
391#[derive(Debug, Clone, Serialize)]
392pub struct RouteDecision {
393    pub method: String,
394    pub path: String,
395    pub outcome: RouteDecisionOutcome,
396}
397
398#[derive(Debug, Clone, Serialize)]
399#[serde(tag = "kind", rename_all = "snake_case")]
400pub enum RouteDecisionOutcome {
401    Forward {
402        upstream: String,
403        rewritten_path: String,
404        deployment_id: String,
405    },
406    NotFound {
407        status: u16,
408        reason: String,
409    },
410}
411
412/// Produce a [`RouteDecision`] for a sample `(method, path)` against the live
413/// resolver + upstream registry. No request body/headers (dry-run).
414pub fn describe_request(
415    resolver: &AliasResolver,
416    upstreams: &dyn UpstreamRegistry,
417    sorx_base_url: &str,
418    method: &str,
419    path: &str,
420) -> RouteDecision {
421    let outcome = match route_request(resolver, upstreams, sorx_base_url, method, path, &[], &[]) {
422        ProxyOutcome::Forward {
423            upstream,
424            rewritten_path,
425            deployment_id,
426        } => RouteDecisionOutcome::Forward {
427            upstream,
428            rewritten_path,
429            deployment_id,
430        },
431        ProxyOutcome::NotFound { status, reason } => {
432            RouteDecisionOutcome::NotFound { status, reason }
433        }
434    };
435    RouteDecision {
436        method: method.to_string(),
437        path: path.to_string(),
438        outcome,
439    }
440}
441
442/// A [`RoutingTableSource`] backed by SoRX over `reqwest::blocking`.
443///
444/// This is the live source used by the CLI. The pure decision layer above does
445/// not depend on it (tests use a fake source), so the routing logic stays
446/// socket-free and fully unit-testable.
447pub struct HttpRoutingTableSource {
448    client: reqwest::blocking::Client,
449}
450
451impl HttpRoutingTableSource {
452    /// Build with a default blocking client (3s connect timeout to keep the
453    /// router responsive on a dead SoRX; stale-on-error covers the gap).
454    pub fn new() -> Result<Self, String> {
455        let client = reqwest::blocking::Client::builder()
456            .connect_timeout(Duration::from_secs(3))
457            .timeout(Duration::from_secs(10))
458            .build()
459            .map_err(|e| format!("failed to build http client: {e}"))?;
460        Ok(Self { client })
461    }
462}
463
464impl RoutingTableSource for HttpRoutingTableSource {
465    fn fetch(
466        &self,
467        sorx_base_url: &str,
468        tenant: Option<&str>,
469        sor: Option<&str>,
470    ) -> Result<Vec<RouteRow>, String> {
471        let base = sorx_base_url.trim_end_matches('/');
472        let mut url = format!("{base}/v1/sorx/routing-table");
473        let mut params: Vec<(&str, &str)> = Vec::new();
474        if let Some(t) = tenant {
475            params.push(("tenant", t));
476        }
477        if let Some(s) = sor {
478            params.push(("sor", s));
479        }
480
481        let mut request = self.client.get(&url);
482        if !params.is_empty() {
483            request = request.query(&params);
484        }
485        // Keep `url` referenced for error messages even when query is appended
486        // by reqwest internally.
487        url = format!("{url}{}", render_query_suffix(&params));
488
489        let response = request
490            .send()
491            .map_err(|e| format!("GET {url} failed: {e}"))?;
492        let status = response.status();
493        if !status.is_success() {
494            let body = response.text().unwrap_or_default();
495            return Err(format!("GET {url} returned {status}: {body}"));
496        }
497        let table: RoutingTable = response
498            .json()
499            .map_err(|e| format!("GET {url} returned undecodable routing-table: {e}"))?;
500        Ok(table.routes)
501    }
502}
503
504fn render_query_suffix(params: &[(&str, &str)]) -> String {
505    if params.is_empty() {
506        return String::new();
507    }
508    let joined = params
509        .iter()
510        .map(|(k, v)| format!("{k}={v}"))
511        .collect::<Vec<_>>()
512        .join("&");
513    format!("?{joined}")
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use std::sync::Arc;
520    use std::sync::atomic::{AtomicUsize, Ordering};
521
522    fn row(alias: &str, deployment_id: &str, routable: bool, base_path: &str) -> RouteRow {
523        RouteRow {
524            tenant_id: "acme".to_string(),
525            sor_name: "customer".to_string(),
526            alias: alias.to_string(),
527            deployment_id: deployment_id.to_string(),
528            pack_name: "pack".to_string(),
529            pack_version: "1.0.0".to_string(),
530            base_path: base_path.to_string(),
531            state_namespace: "ns".to_string(),
532            visibility: "public".to_string(),
533            routable,
534            traffic: 100,
535        }
536    }
537
538    /// A fake source returning a fixed table and counting fetch calls.
539    struct FakeSource {
540        rows: Vec<RouteRow>,
541        calls: Arc<AtomicUsize>,
542    }
543    impl RoutingTableSource for FakeSource {
544        fn fetch(
545            &self,
546            _url: &str,
547            _tenant: Option<&str>,
548            _sor: Option<&str>,
549        ) -> Result<Vec<RouteRow>, String> {
550            self.calls.fetch_add(1, Ordering::SeqCst);
551            Ok(self.rows.clone())
552        }
553    }
554
555    /// A fake source that succeeds once then errors, counting calls.
556    struct FlakySource {
557        rows: Vec<RouteRow>,
558        calls: Arc<AtomicUsize>,
559    }
560    impl RoutingTableSource for FlakySource {
561        fn fetch(
562            &self,
563            _url: &str,
564            _tenant: Option<&str>,
565            _sor: Option<&str>,
566        ) -> Result<Vec<RouteRow>, String> {
567            let n = self.calls.fetch_add(1, Ordering::SeqCst);
568            if n == 0 {
569                Ok(self.rows.clone())
570            } else {
571                Err("sorx unreachable".to_string())
572            }
573        }
574    }
575
576    fn upstreams(pairs: &[(&str, &str)]) -> StaticUpstreamRegistry {
577        StaticUpstreamRegistry::new(
578            pairs
579                .iter()
580                .map(|(k, v)| (k.to_string(), v.to_string()))
581                .collect(),
582        )
583    }
584
585    #[test]
586    fn resolve_returns_routable_deployment() {
587        let calls = Arc::new(AtomicUsize::new(0));
588        let source = FakeSource {
589            rows: vec![
590                row("v1", "dep-old", false, "/sor/customer"),
591                row("v1", "dep-new", true, "/sor/customer"),
592                row("v2", "dep-x", false, "/sor/customer"),
593            ],
594            calls: calls.clone(),
595        };
596        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
597
598        // The routable row for alias `v1` wins over the non-routable one.
599        let resolved = resolver
600            .resolve("http://sorx", "acme", "customer", "v1")
601            .expect("v1 should resolve to the routable deployment");
602        assert_eq!(resolved.deployment_id, "dep-new");
603        assert!(resolved.routable);
604
605        // alias `v2` is only present as non-routable -> None.
606        assert!(
607            resolver
608                .resolve("http://sorx", "acme", "customer", "v2")
609                .is_none()
610        );
611
612        // Unknown alias -> None.
613        assert!(
614            resolver
615                .resolve("http://sorx", "acme", "customer", "nope")
616                .is_none()
617        );
618    }
619
620    #[test]
621    fn resolve_ttl_refetch() {
622        let calls = Arc::new(AtomicUsize::new(0));
623        let source = FakeSource {
624            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
625            calls: calls.clone(),
626        };
627        // Tiny TTL so we can cross it deterministically.
628        let resolver = AliasResolver::new(Box::new(source), Duration::from_millis(30));
629
630        resolver.resolve("http://sorx", "acme", "customer", "v1");
631        resolver.resolve("http://sorx", "acme", "customer", "v1");
632        // Two resolves within TTL -> exactly one fetch.
633        assert_eq!(calls.load(Ordering::SeqCst), 1);
634
635        std::thread::sleep(Duration::from_millis(50));
636        resolver.resolve("http://sorx", "acme", "customer", "v1");
637        // After TTL expiry -> a refetch.
638        assert_eq!(calls.load(Ordering::SeqCst), 2);
639    }
640
641    #[test]
642    fn resolve_stale_on_error() {
643        let calls = Arc::new(AtomicUsize::new(0));
644        let source = FlakySource {
645            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
646            calls: calls.clone(),
647        };
648        let resolver = AliasResolver::new(Box::new(source), Duration::from_millis(10));
649
650        // First resolve: fetch succeeds, cache populated.
651        let first = resolver.resolve("http://sorx", "acme", "customer", "v1");
652        assert_eq!(first.expect("first resolve ok").deployment_id, "dep-1");
653
654        // Let the TTL expire so the next resolve attempts a refetch (which errs).
655        std::thread::sleep(Duration::from_millis(20));
656        let stale = resolver.resolve("http://sorx", "acme", "customer", "v1");
657        // Stale-on-error: still resolves from the last good table.
658        assert_eq!(stale.expect("stale resolve ok").deployment_id, "dep-1");
659        // Two fetches attempted (first ok, second errored).
660        assert_eq!(calls.load(Ordering::SeqCst), 2);
661    }
662
663    #[test]
664    fn resolve_no_cache_and_error_returns_none() {
665        let calls = Arc::new(AtomicUsize::new(0));
666        // Errors from the very first call -> nothing to fall back on.
667        struct AlwaysErr(Arc<AtomicUsize>);
668        impl RoutingTableSource for AlwaysErr {
669            fn fetch(
670                &self,
671                _u: &str,
672                _t: Option<&str>,
673                _s: Option<&str>,
674            ) -> Result<Vec<RouteRow>, String> {
675                self.0.fetch_add(1, Ordering::SeqCst);
676                Err("down".to_string())
677            }
678        }
679        let resolver =
680            AliasResolver::new(Box::new(AlwaysErr(calls.clone())), Duration::from_secs(60));
681        assert!(
682            resolver
683                .resolve("http://sorx", "acme", "customer", "v1")
684                .is_none()
685        );
686        assert_eq!(calls.load(Ordering::SeqCst), 1);
687    }
688
689    #[test]
690    fn route_request_forwards_to_upstream() {
691        let calls = Arc::new(AtomicUsize::new(0));
692        let source = FakeSource {
693            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
694            calls,
695        };
696        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
697        let reg = upstreams(&[("dep-1", "127.0.0.1:8088")]);
698
699        let outcome = route_request(
700            &resolver,
701            &reg,
702            "http://sorx",
703            "GET",
704            "/acme/customer/v1/orders/42",
705            &[],
706            &[],
707        );
708
709        match outcome {
710            ProxyOutcome::Forward {
711                upstream,
712                rewritten_path,
713                deployment_id,
714            } => {
715                assert_eq!(upstream, "127.0.0.1:8088");
716                // rest = "orders/42" forwarded under base_path "/sor/customer".
717                assert_eq!(rewritten_path, "/sor/customer/orders/42");
718                assert_eq!(deployment_id, "dep-1");
719            }
720            other => panic!("expected Forward, got {other:?}"),
721        }
722    }
723
724    #[test]
725    fn route_request_forwards_alias_root_under_base_path() {
726        let calls = Arc::new(AtomicUsize::new(0));
727        let source = FakeSource {
728            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
729            calls,
730        };
731        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
732        let reg = upstreams(&[("dep-1", "127.0.0.1:8088")]);
733
734        // No `rest` segment -> forward base_path only.
735        let outcome = route_request(
736            &resolver,
737            &reg,
738            "http://sorx",
739            "GET",
740            "/acme/customer/v1",
741            &[],
742            &[],
743        );
744        match outcome {
745            ProxyOutcome::Forward { rewritten_path, .. } => {
746                assert_eq!(rewritten_path, "/sor/customer");
747            }
748            other => panic!("expected Forward, got {other:?}"),
749        }
750    }
751
752    #[test]
753    fn route_request_unresolved_alias_404() {
754        let calls = Arc::new(AtomicUsize::new(0));
755        let source = FakeSource {
756            rows: vec![row("v1", "dep-1", false, "/sor/customer")], // non-routable
757            calls,
758        };
759        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
760        let reg = upstreams(&[("dep-1", "127.0.0.1:8088")]);
761
762        let outcome = route_request(
763            &resolver,
764            &reg,
765            "http://sorx",
766            "GET",
767            "/acme/customer/v1/orders",
768            &[],
769            &[],
770        );
771        match outcome {
772            ProxyOutcome::NotFound { status, .. } => assert_eq!(status, 404),
773            other => panic!("expected 404 NotFound, got {other:?}"),
774        }
775    }
776
777    #[test]
778    fn route_request_no_upstream_503() {
779        let calls = Arc::new(AtomicUsize::new(0));
780        let source = FakeSource {
781            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
782            calls,
783        };
784        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
785        // Alias resolves, but no upstream registered for dep-1.
786        let reg = upstreams(&[("dep-other", "127.0.0.1:9999")]);
787
788        let outcome = route_request(
789            &resolver,
790            &reg,
791            "http://sorx",
792            "GET",
793            "/acme/customer/v1/orders",
794            &[],
795            &[],
796        );
797        match outcome {
798            ProxyOutcome::NotFound { status, .. } => assert_eq!(status, 503),
799            other => panic!("expected 503 NotFound, got {other:?}"),
800        }
801    }
802
803    #[test]
804    fn route_request_rejects_malformed_path() {
805        let calls = Arc::new(AtomicUsize::new(0));
806        let source = FakeSource {
807            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
808            calls,
809        };
810        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
811        let reg = upstreams(&[("dep-1", "127.0.0.1:8088")]);
812
813        for bad in ["/", "/acme", "/acme/customer", ""] {
814            let outcome = route_request(&resolver, &reg, "http://sorx", "GET", bad, &[], &[]);
815            match outcome {
816                ProxyOutcome::NotFound { status, .. } => {
817                    assert_eq!(status, 400, "path {bad:?} should be 400")
818                }
819                other => panic!("expected 400 for {bad:?}, got {other:?}"),
820            }
821        }
822    }
823
824    #[test]
825    fn parse_request_path_variants() {
826        let p = parse_request_path("/acme/customer/v1/orders/42").unwrap();
827        assert_eq!(p.tenant, "acme");
828        assert_eq!(p.sor, "customer");
829        assert_eq!(p.alias, "v1");
830        assert_eq!(p.rest, "orders/42");
831
832        // No rest.
833        let p = parse_request_path("/acme/customer/v1").unwrap();
834        assert_eq!(p.rest, "");
835
836        // Query string stripped.
837        let p = parse_request_path("/acme/customer/v1/orders?page=2").unwrap();
838        assert_eq!(p.rest, "orders");
839
840        // Too few segments.
841        assert!(parse_request_path("/acme/customer").is_none());
842    }
843
844    #[test]
845    fn rewrite_path_normalizes_slashes() {
846        assert_eq!(
847            rewrite_path("/sor/customer", "orders/42"),
848            "/sor/customer/orders/42"
849        );
850        assert_eq!(
851            rewrite_path("/sor/customer/", "/orders"),
852            "/sor/customer/orders"
853        );
854        assert_eq!(rewrite_path("/sor/customer", ""), "/sor/customer");
855        assert_eq!(rewrite_path("", "orders"), "/orders");
856        assert_eq!(rewrite_path("", ""), "/");
857    }
858
859    #[test]
860    fn describe_request_serializes_forward() {
861        let calls = Arc::new(AtomicUsize::new(0));
862        let source = FakeSource {
863            rows: vec![row("v1", "dep-1", true, "/sor/customer")],
864            calls,
865        };
866        let resolver = AliasResolver::new(Box::new(source), Duration::from_secs(60));
867        let reg = upstreams(&[("dep-1", "127.0.0.1:8088")]);
868
869        let decision = describe_request(
870            &resolver,
871            &reg,
872            "http://sorx",
873            "GET",
874            "/acme/customer/v1/orders",
875        );
876        let json = serde_json::to_value(&decision).unwrap();
877        assert_eq!(json["outcome"]["kind"], "forward");
878        assert_eq!(json["outcome"]["upstream"], "127.0.0.1:8088");
879        assert_eq!(json["outcome"]["rewritten_path"], "/sor/customer/orders");
880    }
881
882    #[test]
883    fn routing_table_deserializes_with_unknown_fields() {
884        // A SoRX schema bump (extra field) must not break decode.
885        let raw = r#"{
886            "schema": "sorx.routing-table.v1",
887            "routes": [
888                {
889                    "tenant_id": "acme",
890                    "sor_name": "customer",
891                    "alias": "v1",
892                    "deployment_id": "dep-1",
893                    "pack_name": "pack",
894                    "pack_version": "1.0.0",
895                    "base_path": "/sor/customer",
896                    "state_namespace": "ns",
897                    "visibility": "public",
898                    "routable": true,
899                    "traffic": 100,
900                    "future_field": "ignored"
901                }
902            ]
903        }"#;
904        let table: RoutingTable = serde_json::from_str(raw).unwrap();
905        assert_eq!(table.routes.len(), 1);
906        assert_eq!(table.routes[0].deployment_id, "dep-1");
907    }
908}