Skip to main content

saluki_app/
api.rs

1//! API server.
2//!
3//! [`APIBuilder`] serves a set of statically registered handlers alongside routes that come and go at runtime: it
4//! subscribes to notifications from the dataspace registry and registers and unregisters routes as they're asserted
5//! and retracted.
6
7use std::{
8    convert::Infallible,
9    error::Error,
10    panic::{catch_unwind, AssertUnwindSafe},
11    sync::Arc,
12    task::{Context, Poll},
13};
14
15use arc_swap::ArcSwap;
16use async_trait::async_trait;
17use axum::{body::Body as AxumBody, routing::future::RouteFuture, Router};
18use http::{Request, Response};
19use rcgen::{generate_simple_self_signed, CertifiedKey};
20use rustls::{pki_types::PrivateKeyDer, ServerConfig};
21use rustls_pki_types::PrivatePkcs8KeyDer;
22use saluki_api::{APIHandler, DynamicRoute, EndpointType};
23use saluki_common::{collections::FastIndexMap, sync::shutdown::ShutdownHandle};
24use saluki_core::runtime::{
25    state::{DataspaceRegistry, DataspaceUpdate, Identifier, IdentifierFilter, Subscription},
26    InitializationError, Supervisable, Supervisor, SupervisorFuture,
27};
28use saluki_error::{generic_error, GenericError};
29use saluki_io::net::{
30    server::{grpc::unmatched_route, http::HttpServer},
31    ListenAddress,
32};
33use saluki_tls::ensure_server_config_fips_compliant;
34use tokio::{pin, select};
35use tonic::{body::Body as GrpcBody, server::NamedService, service::RoutesBuilder};
36use tower::Service;
37use tracing::{debug, info, warn};
38
39/// An API server whose routes can be added and removed at runtime.
40///
41/// `APIBuilder` serves HTTP and gRPC on a given address, on a single port. gRPC is HTTP/2 with a distinct route
42/// naming convention, so both protocols share one router and one server. Route additions and removals are handled by
43/// subscribing to assertions/retractions of [`DynamicRoute`] in the [`DataspaceRegistry`].
44///
45/// # Adding and removing routes
46///
47/// Any process that wants to dynamically register API routes can simply assert a [`DynamicRoute`] in the
48/// [`DataspaceRegistry`]. Retracting the assertion will remove the route, either when retracted manually or when the
49/// process owning the route assertions exits.
50///
51/// If the API server is restarted, it will re-register any routes that were previously asserted.
52///
53/// # Static handlers and services
54///
55/// In addition to dynamic routes, callers can register static HTTP handlers and gRPC services up-front via
56/// [`with_handler`][Self::with_handler], [`with_optional_handler`][Self::with_optional_handler], and
57/// [`with_grpc_service`][Self::with_grpc_service]. These form a base router that's cloned on every rebuild and merged
58/// with the currently asserted dynamic routes. Static routes take precedence on conflicts: a dynamic route whose path
59/// and method overlap with a static route is skipped (with a warning) until the conflict clears.
60///
61/// HTTP and gRPC routes share one path space, so a gRPC route can, _in principle_, collide with an HTTP one. In
62/// practice, this should never occur because the structure of gRPC routes includes programmatic aspects
63/// (`/<package>.<service>/<method>`) that don't collide with any typical HTTP routes registered by components.
64///
65/// # Assertions
66///
67/// See [`HttpServer`] for more information on available assertions. The server ID provided by `APIBuilder` to the
68/// underlying `HttpServer` will be `privileged-api` or `unprivileged-api`, depending on the configured endpoint type.
69///
70/// # Supervision
71///
72/// The API can't be run directly: [`into_supervisor`][Self::into_supervisor] turns it into the [`Supervisor`] that
73/// runs it, which is then added to another supervisor like any other child.
74pub struct APIBuilder {
75    endpoint_type: EndpointType,
76    listen_address: ListenAddress,
77    tls_config: Option<ServerConfig>,
78    http_router: Router,
79    grpc_router: RoutesBuilder,
80}
81
82impl APIBuilder {
83    /// Creates a new `APIBuilder` for the given endpoint type and listen address.
84    pub fn new(endpoint_type: EndpointType, listen_address: ListenAddress) -> Self {
85        Self {
86            endpoint_type,
87            listen_address,
88            tls_config: None,
89            http_router: Router::new(),
90            grpc_router: RoutesBuilder::default(),
91        }
92    }
93
94    /// Adds the given handler as a static HTTP handler.
95    ///
96    /// The handler's initial state and routes are merged into the base router. These routes are always served by the
97    /// API regardless of which dynamic routes are currently asserted.
98    pub fn with_handler<H>(mut self, handler: H) -> Self
99    where
100        H: APIHandler,
101    {
102        let handler_router = handler.generate_routes();
103        let handler_state = handler.generate_initial_state();
104        self.http_router = self.http_router.merge(handler_router.with_state(handler_state));
105        self
106    }
107
108    /// Adds the given optional handler as a static HTTP handler.
109    ///
110    /// If `handler` is `Some`, its initial state and routes are merged into the base router. Otherwise the builder is
111    /// returned unchanged.
112    pub fn with_optional_handler<H>(self, handler: Option<H>) -> Self
113    where
114        H: APIHandler,
115    {
116        if let Some(handler) = handler {
117            self.with_handler(handler)
118        } else {
119            self
120        }
121    }
122
123    /// Adds the given gRPC service as a static service on the base router.
124    pub fn with_grpc_service<S>(mut self, svc: S) -> Self
125    where
126        S: Service<Request<GrpcBody>, Response = Response<GrpcBody>, Error = Infallible>
127            + NamedService
128            + Clone
129            + Send
130            + Sync
131            + 'static,
132        S::Future: Send + 'static,
133        S::Error: Into<Box<dyn Error + Send + Sync>> + Send,
134    {
135        self.grpc_router.add_service(svc);
136        self
137    }
138
139    /// Sets the TLS configuration for the server.
140    pub fn with_tls_config(mut self, config: ServerConfig) -> Self {
141        self.tls_config = Some(config);
142        self
143    }
144
145    /// Sets the TLS configuration for the server based on a dynamically generated, self-signed certificate.
146    pub fn with_self_signed_tls(self) -> Self {
147        self.try_with_self_signed_tls()
148            .expect("self-signed server TLS configuration should build and pass FIPS validation")
149    }
150
151    /// Sets the TLS configuration for the server based on a dynamically generated, self-signed certificate.
152    ///
153    /// # Errors
154    ///
155    /// If the certificate cannot be generated, the TLS configuration cannot be built, or the resulting TLS
156    /// configuration is not FIPS compliant, an error is returned.
157    pub fn try_with_self_signed_tls(self) -> Result<Self, GenericError> {
158        let CertifiedKey { cert, signing_key } = generate_simple_self_signed(["localhost".to_owned()])?;
159        let cert_chain = vec![cert.der().clone()];
160        let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
161
162        let mut config = ServerConfig::builder()
163            .with_no_client_auth()
164            .with_single_cert(cert_chain, key)?;
165
166        ensure_server_config_fips_compliant(&mut config)?;
167
168        Ok(self.with_tls_config(config))
169    }
170}
171
172impl APIBuilder {
173    /// Converts this builder into a supervisor.
174    ///
175    /// The supervisor is configured to run the underlying HTTP/gRPC server, as well as a worker to manage
176    /// the dynamic route updates as routes are asserted and retracted.
177    pub fn into_supervisor(self) -> Supervisor {
178        // Build the static base router, folding the gRPC routes in alongside the HTTP ones.
179        //
180        // Every router that goes into a merge has its fallback reset first: `axum` refuses to merge two routers that
181        // both define one, and `tonic`'s `Routes` always carries its own `unimplemented` fallback. The single fallback
182        // that does the right thing for both protocols is applied by `apply_fallback` once merging is finished, which
183        // is also why the base itself is kept fallback-free -- it gets re-merged on every rebuild.
184        let base = self
185            .http_router
186            .reset_fallback()
187            .merge(self.grpc_router.routes().into_axum_router().reset_fallback());
188
189        // Create our dynamic router and then our HTTP server, which will be set to use it.
190        let (inner, outer) = create_dynamic_router(apply_fallback(base.clone()));
191
192        let mut http_server = HttpServer::from_listen_address(self.listen_address.clone())
193            .with_routes(outer)
194            .with_server_id(format!("{}-api", self.endpoint_type.name()));
195        if let Some(tls_config) = self.tls_config {
196            http_server = http_server.with_tls_config(tls_config);
197        }
198
199        let endpoint_type = self.endpoint_type;
200        let name = match endpoint_type {
201            EndpointType::Unprivileged => "unprivileged-api",
202            EndpointType::Privileged => "privileged-api",
203        };
204
205        let mut supervisor = Supervisor::new(name).expect("API supervisor name is a non-empty constant");
206
207        supervisor.add_worker(http_server.into_supervisor());
208        supervisor.add_worker(RouteUpdaterWorker {
209            inner,
210            base,
211            endpoint_type,
212            listen_address: self.listen_address,
213        });
214
215        supervisor
216    }
217}
218
219/// Keeps an [`APIBuilder`]'s router in step with the dynamic routes currently asserted.
220struct RouteUpdaterWorker {
221    inner: Arc<ArcSwap<Router>>,
222    base: Router,
223    endpoint_type: EndpointType,
224    listen_address: ListenAddress,
225}
226
227#[async_trait]
228impl Supervisable for RouteUpdaterWorker {
229    fn name(&self) -> &str {
230        "route_updater"
231    }
232
233    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
234        let dataspace = DataspaceRegistry::try_current().ok_or_else(|| generic_error!("Dataspace not available."))?;
235
236        let inner = Arc::clone(&self.inner);
237        let base = self.base.clone();
238        let endpoint_type = self.endpoint_type;
239        let listen_address = self.listen_address.clone();
240
241        Ok(Box::pin(async move {
242            info!("Serving {} API on {}.", endpoint_type.name(), listen_address);
243
244            // Subscribe to all dynamic route assertions.
245            let route_assertions = dataspace.subscribe::<DynamicRoute>(IdentifierFilter::All);
246
247            run_event_loop(process_shutdown, inner, base, route_assertions, endpoint_type).await
248        }))
249    }
250}
251
252/// A [`tower::Service`] that routes a request based on a dynamically updated [`Router`].
253///
254/// When installed as the fallback service for a top-level [`Router`], `DynamicRouterService` dynamically routes
255/// requests based on the currently defined "inner" router, which itself can be hot-swapped at runtime. This allows for
256/// seamless updates to the API endpoint routing without requiring a restart of the HTTP listener or complicated
257/// configuration changes.
258#[derive(Clone)]
259struct DynamicRouterService {
260    inner_router: Arc<ArcSwap<Router>>,
261}
262
263impl DynamicRouterService {
264    fn from_inner(inner_router: &Arc<ArcSwap<Router>>) -> Self {
265        Self {
266            inner_router: Arc::clone(inner_router),
267        }
268    }
269}
270
271impl Service<http::Request<AxumBody>> for DynamicRouterService {
272    type Response = Response<AxumBody>;
273    type Error = Infallible;
274    type Future = RouteFuture<Infallible>;
275
276    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
277        Poll::Ready(Ok(()))
278    }
279
280    fn call(&mut self, request: http::Request<AxumBody>) -> Self::Future {
281        let mut router = Arc::unwrap_or_clone(self.inner_router.load_full());
282        router.call(request)
283    }
284}
285
286/// Runs the event loop that listens for route assertions/retractions and hot-swaps the inner router.
287async fn run_event_loop(
288    process_shutdown: ShutdownHandle, inner: Arc<ArcSwap<Router>>, base: Router,
289    mut route_assertions: Subscription<DynamicRoute>, endpoint_type: EndpointType,
290) -> Result<(), GenericError> {
291    // HTTP and gRPC handlers share a map because they share a router: a gRPC route is a route like any other, just
292    // one whose path follows the gRPC naming convention.
293    let mut handlers = FastIndexMap::default();
294
295    pin!(process_shutdown);
296
297    loop {
298        select! {
299            _ = &mut process_shutdown => break,
300            maybe_update = route_assertions.recv() => match maybe_update {
301                None => {
302                    debug!("Dataspace subscription ended unexpectedly.");
303                    break
304                },
305                Some(update) => {
306                    match update {
307                        DataspaceUpdate::Asserted(id, route) => {
308                            if route.endpoint_type() != endpoint_type {
309                                continue;
310                            }
311
312                            debug!(?id, "Registering dynamic {} handler.", route.endpoint_protocol().name());
313
314                            handlers.insert(id, route.into_router());
315                        }
316                        DataspaceUpdate::Retracted(id) => {
317                            if handlers.swap_remove(&id).is_none() {
318                                continue;
319                            }
320
321                            debug!(?id, "Withdrawing dynamic handler.");
322                        }
323                        // Routes are modeled as assertions; transient messages are not meaningful here.
324                        DataspaceUpdate::Message(..) => continue,
325                    }
326
327                    rebuild_router(&inner, &base, &handlers);
328                }
329            }
330        }
331    }
332
333    Ok(())
334}
335
336/// Creates a dynamic router pair: a swappable inner router (seeded with `initial`) and an outer router that delegates
337/// to it.
338fn create_dynamic_router(initial: Router) -> (Arc<ArcSwap<Router>>, Router) {
339    let inner = Arc::new(ArcSwap::from_pointee(initial));
340    let outer = Router::new().fallback_service(DynamicRouterService::from_inner(&inner));
341    (inner, outer)
342}
343
344/// Attempts to merge `other` into `base`, returning the merged router on success.
345///
346/// `Router::merge` panics when two routers define overlapping routes (same path and HTTP method) and axum exposes no
347/// fallible alternative. Since `Router` is opaque -- there is no public API to inspect which paths/methods a router
348/// carries -- we can't detect conflicts ahead of time.
349///
350/// To recover from the panic without losing the accumulated router state, we clone `base` before the merge attempt.
351/// The clone is passed into `catch_unwind`: if the merge panics, only the clone is in a partially mutated state and it
352/// is simply dropped. The original `base` remains intact and is returned as-is. `AssertUnwindSafe` is sound here
353/// because:
354///
355/// - The closure captures only the clone (`candidate`) and a clone of `other`. Neither aliases mutable state that
356///   outlives the closure.
357/// - The panic originates from a deterministic format string in axum's `panic_on_err!` macro -- no locks are held and
358///   no resources are leaked in the panic path.
359/// - On panic, `candidate` is dropped without further use, so any internal inconsistency is irrelevant.
360fn try_merge_router(base: &Router, id: &Identifier, other: &Router) -> Result<Router, String> {
361    let candidate = base.clone();
362    match catch_unwind(AssertUnwindSafe(|| candidate.merge(other.clone()))) {
363        Ok(merged) => Ok(merged),
364        Err(payload) => {
365            let reason = payload
366                .downcast_ref::<String>()
367                .map(|s| s.as_str())
368                .or_else(|| payload.downcast_ref::<&str>().copied())
369                .unwrap_or("unknown");
370            Err(format!("failed to merge dynamic handler {id:?}: {reason}"))
371        }
372    }
373}
374
375/// Rebuilds the merged inner router from the static `base` and all currently registered dynamic handlers, applies the
376/// protocol-aware fallback, then stores the result in the [`ArcSwap`].
377fn rebuild_router(inner_router: &Arc<ArcSwap<Router>>, base: &Router, handlers: &FastIndexMap<Identifier, Router>) {
378    let mut merged = base.clone();
379    let mut skipped = 0usize;
380
381    for (id, router) in handlers.iter() {
382        let resetable = router.clone().reset_fallback();
383        match try_merge_router(&merged, id, &resetable) {
384            Ok(new_merged) => merged = new_merged,
385            Err(reason) => {
386                warn!(%reason, "Skipping dynamic handler due to overlapping route.");
387                skipped += 1;
388            }
389        }
390    }
391
392    inner_router.store(Arc::new(apply_fallback(merged)));
393    debug!(handler_count = handlers.len(), skipped, "Rebuilt inner router.");
394}
395
396/// Adds the fallback that answers unmatched requests in whichever protocol the caller spoke.
397///
398/// Kept separate from the base router so that the base can be re-merged on every rebuild, which axum only allows for
399/// routers without an explicit fallback.
400fn apply_fallback(router: Router) -> Router {
401    router.fallback(unmatched_route)
402}
403
404#[cfg(test)]
405mod tests {
406    use std::{net::SocketAddr, time::Duration};
407
408    use axum::Router;
409    use http_body_util::{BodyExt as _, Empty};
410    use hyper::{body::Bytes, StatusCode};
411    use hyper_util::{client::legacy::Client, rt::TokioExecutor};
412    use saluki_api::{APIHandler, DynamicRoute, EndpointType};
413    use saluki_core::runtime::{
414        state::{DataspaceUpdate, Identifier, IdentifierFilter},
415        test_util::TestSupervisor,
416    };
417    use saluki_io::net::BoundListenAddress;
418    use tokio::time::{sleep, timeout, Instant};
419    use tonic::service::Routes;
420
421    use super::*;
422
423    struct SimpleHandler {
424        path: &'static str,
425        body: &'static str,
426    }
427
428    impl APIHandler for SimpleHandler {
429        type State = ();
430
431        fn generate_initial_state(&self) -> Self::State {}
432
433        fn generate_routes(&self) -> Router<Self::State> {
434            let body = self.body;
435            Router::new().route(self.path, axum::routing::get(move || async move { body }))
436        }
437    }
438
439    /// A gRPC service that answers every method with an `OK` status.
440    #[derive(Clone)]
441    struct OkService;
442
443    impl NamedService for OkService {
444        const NAME: &'static str = "test.Ok";
445    }
446
447    impl Service<Request<GrpcBody>> for OkService {
448        type Response = Response<GrpcBody>;
449        type Error = Infallible;
450        type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
451
452        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
453            Poll::Ready(Ok(()))
454        }
455
456        fn call(&mut self, _request: Request<GrpcBody>) -> Self::Future {
457            let response = Response::builder()
458                .header("grpc-status", "0")
459                .body(GrpcBody::empty())
460                .unwrap();
461            std::future::ready(Ok(response))
462        }
463    }
464
465    struct TestHarness {
466        addr: SocketAddr,
467        supervisor: TestSupervisor,
468    }
469
470    impl TestHarness {
471        fn assert_route(&self, id: impl Into<Identifier>, route: DynamicRoute) {
472            self.supervisor.dataspace().assert(route, id);
473        }
474
475        fn retract_route(&self, id: impl Into<Identifier>) {
476            self.supervisor.dataspace().retract::<DynamicRoute>(id);
477        }
478    }
479
480    async fn setup_test_harness(endpoint_type: EndpointType) -> TestHarness {
481        setup_test_harness_with(endpoint_type, |b| b).await
482    }
483
484    async fn setup_test_harness_with<F>(endpoint_type: EndpointType, configure: F) -> TestHarness
485    where
486        F: FnOnce(APIBuilder) -> APIBuilder,
487    {
488        let api_builder = configure(APIBuilder::new(endpoint_type, ListenAddress::tcp_any(0)));
489        let supervisor = TestSupervisor::start_with("test-dynamic-api", |supervisor| {
490            supervisor.add_worker(api_builder.into_supervisor());
491        })
492        .await;
493
494        // Wait for the API server to assert its bound address.
495        let bound_addr_name = match endpoint_type {
496            EndpointType::Unprivileged => "http-server-unprivileged-api",
497            EndpointType::Privileged => "http-server-privileged-api",
498        };
499        let mut addr_sub = supervisor
500            .dataspace()
501            .subscribe::<BoundListenAddress>(IdentifierFilter::exact(Identifier::named(bound_addr_name)));
502        let addr = match timeout(Duration::from_secs(5), addr_sub.recv()).await {
503            Ok(Some(DataspaceUpdate::Asserted(_, BoundListenAddress::Tcp(mut addr)))) => {
504                // Convert 0.0.0.0 to 127.0.0.1 so the test client can connect.
505                if addr.ip().is_unspecified() {
506                    addr.set_ip(std::net::Ipv4Addr::LOCALHOST.into());
507                }
508                addr
509            }
510            other => panic!("expected a bound TCP address, got {other:?}"),
511        };
512
513        TestHarness { addr, supervisor }
514    }
515
516    async fn http_get(addr: SocketAddr, path: &str) -> (StatusCode, String) {
517        let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
518        let uri = format!("http://{}{}", addr, path);
519        let resp = client.get(uri.parse().unwrap()).await.unwrap();
520        let status = resp.status();
521        let body = resp.into_body().collect().await.unwrap().to_bytes();
522        let body_str = String::from_utf8_lossy(&body).into_owned();
523        (status, body_str)
524    }
525
526    async fn grpc_post(addr: SocketAddr, path: &str) -> (StatusCode, http::HeaderMap) {
527        let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
528        let uri: hyper::Uri = format!("http://{}{}", addr, path).parse().unwrap();
529        let req = hyper::Request::builder()
530            .uri(uri)
531            .method(hyper::Method::POST)
532            .header(hyper::header::CONTENT_TYPE, "application/grpc")
533            .body(Empty::<Bytes>::new())
534            .unwrap();
535        let resp = client.request(req).await.unwrap();
536        (resp.status(), resp.headers().clone())
537    }
538
539    async fn assert_grpc_status_eventually(addr: SocketAddr, path: &str, expected: &str) {
540        let deadline = Instant::now() + Duration::from_secs(2);
541        loop {
542            let (_, headers) = grpc_post(addr, path).await;
543            let grpc_status = headers.get("grpc-status").and_then(|v| v.to_str().ok());
544            if grpc_status == Some(expected) {
545                return;
546            }
547            if Instant::now() > deadline {
548                panic!(
549                    "expected grpc-status {} for {} but got {:?}",
550                    expected, path, grpc_status
551                );
552            }
553            sleep(Duration::from_millis(50)).await;
554        }
555    }
556
557    async fn assert_status_eventually(addr: SocketAddr, path: &str, expected: StatusCode) -> String {
558        let deadline = Instant::now() + Duration::from_secs(2);
559        loop {
560            let (status, body) = http_get(addr, path).await;
561            if status == expected {
562                return body;
563            }
564            if Instant::now() > deadline {
565                panic!("expected {} for {} but got {}", expected, path, status);
566            }
567            sleep(Duration::from_millis(50)).await;
568        }
569    }
570
571    // -- Tests ---------------------------------------------------------------------------
572
573    #[tokio::test]
574    async fn serves_asserted_http_route() {
575        let harness = setup_test_harness(EndpointType::Unprivileged).await;
576
577        let route = DynamicRoute::http(
578            EndpointType::Unprivileged,
579            SimpleHandler {
580                path: "/health",
581                body: "ok",
582            },
583        );
584        harness.assert_route("health", route);
585
586        let body = assert_status_eventually(harness.addr, "/health", StatusCode::OK).await;
587        assert_eq!(body, "ok");
588    }
589
590    #[tokio::test]
591    async fn returns_404_for_unknown_route() {
592        let harness = setup_test_harness(EndpointType::Unprivileged).await;
593        let (status, _) = http_get(harness.addr, "/nonexistent").await;
594        assert_eq!(status, StatusCode::NOT_FOUND);
595    }
596
597    #[tokio::test]
598    async fn route_retraction_removes_route() {
599        let harness = setup_test_harness(EndpointType::Unprivileged).await;
600
601        let route = DynamicRoute::http(
602            EndpointType::Unprivileged,
603            SimpleHandler {
604                path: "/temp",
605                body: "temporary",
606            },
607        );
608        harness.assert_route("temp", route);
609        assert_status_eventually(harness.addr, "/temp", StatusCode::OK).await;
610
611        harness.retract_route("temp");
612        assert_status_eventually(harness.addr, "/temp", StatusCode::NOT_FOUND).await;
613    }
614
615    #[tokio::test]
616    async fn multiple_routes_independent_lifecycle() {
617        let harness = setup_test_harness(EndpointType::Unprivileged).await;
618
619        let route_a = DynamicRoute::http(
620            EndpointType::Unprivileged,
621            SimpleHandler {
622                path: "/a",
623                body: "alpha",
624            },
625        );
626        let route_b = DynamicRoute::http(
627            EndpointType::Unprivileged,
628            SimpleHandler {
629                path: "/b",
630                body: "bravo",
631            },
632        );
633        harness.assert_route("a", route_a);
634        harness.assert_route("b", route_b);
635
636        assert_status_eventually(harness.addr, "/a", StatusCode::OK).await;
637        assert_status_eventually(harness.addr, "/b", StatusCode::OK).await;
638
639        // Retract only /a -- /b should remain.
640        harness.retract_route("a");
641        assert_status_eventually(harness.addr, "/a", StatusCode::NOT_FOUND).await;
642
643        let body = assert_status_eventually(harness.addr, "/b", StatusCode::OK).await;
644        assert_eq!(body, "bravo");
645    }
646
647    #[tokio::test]
648    async fn ignores_routes_for_different_endpoint_type() {
649        let harness = setup_test_harness(EndpointType::Unprivileged).await;
650
651        // Assert a Privileged route on an Unprivileged server -- should be ignored.
652        let wrong_route = DynamicRoute::http(
653            EndpointType::Privileged,
654            SimpleHandler {
655                path: "/secret",
656                body: "secret",
657            },
658        );
659        harness.assert_route("secret", wrong_route);
660
661        let (status, _) = http_get(harness.addr, "/secret").await;
662        assert_eq!(status, StatusCode::NOT_FOUND);
663
664        // Now assert the same path with the correct endpoint type.
665        let right_route = DynamicRoute::http(
666            EndpointType::Unprivileged,
667            SimpleHandler {
668                path: "/secret",
669                body: "not secret",
670            },
671        );
672        harness.assert_route("secret-unpriv", right_route);
673
674        let body = assert_status_eventually(harness.addr, "/secret", StatusCode::OK).await;
675        assert_eq!(body, "not secret");
676    }
677
678    #[tokio::test]
679    async fn overlapping_routes_do_not_crash_server() {
680        let harness = setup_test_harness(EndpointType::Unprivileged).await;
681
682        // Assert a route at /health with identifier "health-1".
683        let route_1 = DynamicRoute::http(
684            EndpointType::Unprivileged,
685            SimpleHandler {
686                path: "/health",
687                body: "health-1",
688            },
689        );
690        harness.assert_route("health-1", route_1);
691        let body = assert_status_eventually(harness.addr, "/health", StatusCode::OK).await;
692        assert_eq!(body, "health-1");
693
694        // Assert a DIFFERENT identifier with the SAME path/method. Previously this caused a panic
695        // in rebuild_router. The server should remain alive with first-writer-wins semantics.
696        let route_2 = DynamicRoute::http(
697            EndpointType::Unprivileged,
698            SimpleHandler {
699                path: "/health",
700                body: "health-2",
701            },
702        );
703        harness.assert_route("health-2", route_2);
704
705        // Give the event loop time to process and rebuild.
706        sleep(Duration::from_millis(200)).await;
707
708        // Server is still alive; first handler wins.
709        let (status, body) = http_get(harness.addr, "/health").await;
710        assert_eq!(status, StatusCode::OK);
711        assert_eq!(body, "health-1");
712
713        // Non-overlapping routes are unaffected.
714        let route_info = DynamicRoute::http(
715            EndpointType::Unprivileged,
716            SimpleHandler {
717                path: "/info",
718                body: "info",
719            },
720        );
721        harness.assert_route("info", route_info);
722        let body = assert_status_eventually(harness.addr, "/info", StatusCode::OK).await;
723        assert_eq!(body, "info");
724
725        // Retract the first /health handler -- the previously skipped second handler should now
726        // become active since the conflict no longer exists.
727        harness.retract_route("health-1");
728        let body = assert_status_eventually(harness.addr, "/health", StatusCode::OK).await;
729        assert_eq!(body, "health-2");
730    }
731
732    #[tokio::test]
733    async fn overlapping_route_retraction_then_reassertion() {
734        let harness = setup_test_harness(EndpointType::Unprivileged).await;
735
736        // Assert two overlapping handlers.
737        let route_a = DynamicRoute::http(
738            EndpointType::Unprivileged,
739            SimpleHandler {
740                path: "/overlap",
741                body: "a",
742            },
743        );
744        let route_b = DynamicRoute::http(
745            EndpointType::Unprivileged,
746            SimpleHandler {
747                path: "/overlap",
748                body: "b",
749            },
750        );
751        harness.assert_route("ov-a", route_a);
752        harness.assert_route("ov-b", route_b);
753
754        // Server alive; first writer wins.
755        let body = assert_status_eventually(harness.addr, "/overlap", StatusCode::OK).await;
756        assert_eq!(body, "a");
757
758        // Retract both.
759        harness.retract_route("ov-a");
760        harness.retract_route("ov-b");
761        assert_status_eventually(harness.addr, "/overlap", StatusCode::NOT_FOUND).await;
762
763        // Re-assert a single handler -- should work cleanly.
764        let route_c = DynamicRoute::http(
765            EndpointType::Unprivileged,
766            SimpleHandler {
767                path: "/overlap",
768                body: "c",
769            },
770        );
771        harness.assert_route("ov-c", route_c);
772        let body = assert_status_eventually(harness.addr, "/overlap", StatusCode::OK).await;
773        assert_eq!(body, "c");
774    }
775
776    #[tokio::test]
777    async fn static_handler_served_without_dynamic_routes() {
778        let harness = setup_test_harness_with(EndpointType::Unprivileged, |b| {
779            b.with_handler(SimpleHandler {
780                path: "/static",
781                body: "static",
782            })
783        })
784        .await;
785
786        let body = assert_status_eventually(harness.addr, "/static", StatusCode::OK).await;
787        assert_eq!(body, "static");
788    }
789
790    #[tokio::test]
791    async fn static_and_dynamic_routes_coexist() {
792        let harness = setup_test_harness_with(EndpointType::Unprivileged, |b| {
793            b.with_handler(SimpleHandler {
794                path: "/static",
795                body: "static",
796            })
797        })
798        .await;
799
800        // Static route is served immediately.
801        let body = assert_status_eventually(harness.addr, "/static", StatusCode::OK).await;
802        assert_eq!(body, "static");
803
804        // Add a dynamic route on a different path -- both should serve.
805        let dynamic_route = DynamicRoute::http(
806            EndpointType::Unprivileged,
807            SimpleHandler {
808                path: "/dynamic",
809                body: "dynamic",
810            },
811        );
812        harness.assert_route("dyn", dynamic_route);
813
814        let body = assert_status_eventually(harness.addr, "/dynamic", StatusCode::OK).await;
815        assert_eq!(body, "dynamic");
816
817        let (status, body) = http_get(harness.addr, "/static").await;
818        assert_eq!(status, StatusCode::OK);
819        assert_eq!(body, "static");
820
821        // Retracting the dynamic route leaves the static route untouched.
822        harness.retract_route("dyn");
823        assert_status_eventually(harness.addr, "/dynamic", StatusCode::NOT_FOUND).await;
824
825        let (status, body) = http_get(harness.addr, "/static").await;
826        assert_eq!(status, StatusCode::OK);
827        assert_eq!(body, "static");
828    }
829
830    #[tokio::test]
831    async fn unknown_grpc_method_returns_unimplemented() {
832        let harness = setup_test_harness(EndpointType::Unprivileged).await;
833        let (status, headers) = grpc_post(harness.addr, "/some.Service/Method").await;
834
835        // gRPC errors are reported with HTTP 200 plus a `grpc-status` header. UNIMPLEMENTED is code 12.
836        assert_eq!(status, StatusCode::OK);
837        let grpc_status = headers.get("grpc-status").and_then(|v| v.to_str().ok());
838        assert_eq!(grpc_status, Some("12"));
839    }
840
841    #[tokio::test]
842    async fn serves_asserted_grpc_route() {
843        let harness = setup_test_harness(EndpointType::Unprivileged).await;
844
845        // gRPC errors are reported with HTTP 200 plus a `grpc-status` header: OK is code 0, UNIMPLEMENTED is code 12.
846        harness.assert_route(
847            "ok",
848            DynamicRoute::grpc(EndpointType::Unprivileged, Routes::new(OkService)),
849        );
850        assert_grpc_status_eventually(harness.addr, "/test.Ok/Call", "0").await;
851
852        harness.retract_route("ok");
853        assert_grpc_status_eventually(harness.addr, "/test.Ok/Call", "12").await;
854    }
855
856    #[tokio::test]
857    async fn static_route_wins_overlap_with_dynamic() {
858        let harness = setup_test_harness_with(EndpointType::Unprivileged, |b| {
859            b.with_handler(SimpleHandler {
860                path: "/overlap",
861                body: "static",
862            })
863        })
864        .await;
865
866        // Static route is served.
867        let body = assert_status_eventually(harness.addr, "/overlap", StatusCode::OK).await;
868        assert_eq!(body, "static");
869
870        // Asserting a dynamic route at the same path is skipped due to overlap -- static still wins.
871        let dynamic_route = DynamicRoute::http(
872            EndpointType::Unprivileged,
873            SimpleHandler {
874                path: "/overlap",
875                body: "dynamic",
876            },
877        );
878        harness.assert_route("dyn-overlap", dynamic_route);
879
880        sleep(Duration::from_millis(200)).await;
881
882        let (status, body) = http_get(harness.addr, "/overlap").await;
883        assert_eq!(status, StatusCode::OK);
884        assert_eq!(body, "static");
885    }
886}