1use std::collections::HashMap;
37use std::sync::Mutex;
38use std::time::{Duration, Instant};
39
40use serde::{Deserialize, Serialize};
41
42#[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 pub base_path: String,
57 pub state_namespace: String,
58 pub visibility: String,
59 pub routable: bool,
62 pub traffic: u32,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct RoutingTable {
71 pub schema: String,
72 pub routes: Vec<RouteRow>,
73}
74
75pub trait RoutingTableSource: Send + Sync {
80 fn fetch(
85 &self,
86 sorx_base_url: &str,
87 tenant: Option<&str>,
88 sor: Option<&str>,
89 ) -> Result<Vec<RouteRow>, String>;
90}
91
92pub trait UpstreamRegistry: Send + Sync {
99 fn upstream_for(&self, deployment_id: &str) -> Option<String>;
102}
103
104#[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#[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
137pub 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 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 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 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 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 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
236fn 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#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum ProxyOutcome {
249 Forward {
251 upstream: String,
252 rewritten_path: String,
253 deployment_id: String,
254 },
255 NotFound { status: u16, reason: String },
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct RequestPath {
263 pub tenant: String,
264 pub sor: String,
265 pub alias: String,
266 pub rest: String,
269}
270
271pub fn parse_request_path(path: &str) -> Option<RequestPath> {
278 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 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
297fn 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
317pub 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#[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
412pub 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
442pub struct HttpRoutingTableSource {
448 client: reqwest::blocking::Client,
449}
450
451impl HttpRoutingTableSource {
452 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(¶ms);
484 }
485 url = format!("{url}{}", render_query_suffix(¶ms));
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 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 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 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 assert!(
607 resolver
608 .resolve("http://sorx", "acme", "customer", "v2")
609 .is_none()
610 );
611
612 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 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 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 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 let first = resolver.resolve("http://sorx", "acme", "customer", "v1");
652 assert_eq!(first.expect("first resolve ok").deployment_id, "dep-1");
653
654 std::thread::sleep(Duration::from_millis(20));
656 let stale = resolver.resolve("http://sorx", "acme", "customer", "v1");
657 assert_eq!(stale.expect("stale resolve ok").deployment_id, "dep-1");
659 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 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 ®,
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 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 let outcome = route_request(
736 &resolver,
737 ®,
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")], 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 ®,
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 let reg = upstreams(&[("dep-other", "127.0.0.1:9999")]);
787
788 let outcome = route_request(
789 &resolver,
790 ®,
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, ®, "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 let p = parse_request_path("/acme/customer/v1").unwrap();
834 assert_eq!(p.rest, "");
835
836 let p = parse_request_path("/acme/customer/v1/orders?page=2").unwrap();
838 assert_eq!(p.rest, "orders");
839
840 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 ®,
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 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}