Skip to main content

docspec_http/
router.rs

1//! HTTP router and route definitions.
2
3use axum::{http::Request, Router};
4use metrics_exporter_prometheus::PrometheusHandle;
5use tower_http::request_id::{MakeRequestId, RequestId};
6
7use crate::metrics::{metrics_handler, middleware::record_http_metrics};
8
9/// A [`MakeRequestId`] implementation that never generates a request ID.
10///
11/// Used for `X-Trace-ID`: the header is echoed if present but never generated
12/// if absent. This matches docspecio/api's behavior where `X-Trace-ID` is only
13/// propagated from upstream, never self-assigned.
14#[derive(Clone, Copy)]
15struct EchoOnly;
16
17impl MakeRequestId for EchoOnly {
18    #[inline]
19    fn make_request_id<B>(&mut self, _request: &Request<B>) -> Option<RequestId> {
20        None
21    }
22}
23
24/// Build the HTTP API router with all routes and middleware.
25#[inline]
26pub fn router() -> Router {
27    use axum::extract::DefaultBodyLimit;
28    use axum::http::header::HeaderName;
29    use axum::middleware;
30    use axum::routing::{get, post};
31    use tower::ServiceBuilder;
32    use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
33    use tower_http::trace::{DefaultOnFailure, TraceLayer};
34
35    use crate::cache::cache_control_layer;
36    use crate::handlers::{
37        conversion::{options_conversion, post_conversion},
38        fallback::{conversion_method_not_allowed, health_method_not_allowed, not_found},
39        health::{get_health, head_health, options_health},
40    };
41
42    // Sentry-only imports — Body and Next are only used in the attach_sentry_tags
43    // closure; option_layer and telemetry are only used in the sentry middleware stack.
44    #[cfg(feature = "sentry")]
45    use crate::telemetry;
46    #[cfg(feature = "sentry")]
47    use axum::body::Body;
48    #[cfg(feature = "sentry")]
49    use axum::middleware::Next;
50    #[cfg(feature = "sentry")]
51    use tower::util::option_layer;
52
53    let x_request_id = HeaderName::from_static("x-request-id");
54    let x_trace_id = HeaderName::from_static("x-trace-id");
55
56    let conversion_route = post(post_conversion)
57        .options(options_conversion)
58        .fallback(conversion_method_not_allowed);
59
60    let health_route = get(get_health)
61        .head(head_health)
62        .options(options_health)
63        .fallback(health_method_not_allowed);
64
65    #[cfg(feature = "sentry")]
66    let attach_sentry_tags = |request: Request<Body>, next: Next| async move {
67        if sentry::Hub::current().client().is_some() {
68            let request_id = request
69                .extensions()
70                .get::<RequestId>()
71                .and_then(|req_id| req_id.header_value().to_str().ok())
72                .unwrap_or_default()
73                .to_owned();
74            let trace_id = request
75                .headers()
76                .get("x-trace-id")
77                .and_then(|head| head.to_str().ok())
78                .unwrap_or_default()
79                .to_owned();
80            sentry::configure_scope(|scope| {
81                if !request_id.is_empty() {
82                    scope.set_tag("request_id", &request_id);
83                }
84                if !trace_id.is_empty() {
85                    scope.set_tag("trace_id", &trace_id);
86                }
87            });
88        }
89        next.run(request).await
90    };
91
92    // Two cfg-exclusive service stacks — one that includes the sentry
93    // hub-binding / HTTP layers and the tag middleware, one without.
94    // Using two `let middleware_stack` bindings (mutually exclusive via cfg)
95    // is the idiomatic Rust approach when each branch produces a different
96    // ServiceBuilder type due to different layer counts.
97    #[cfg(feature = "sentry")]
98    let middleware_stack = ServiceBuilder::new()
99        // Reason: axum's `Bytes`/`String`/`Json` extractors silently
100        // cap request bodies at 2 MiB by default. The README pledges
101        // `Body size: No limit ... DoS risk is accepted.`, so we
102        // disable the cap globally. Without this layer the
103        // `/conversion` handler would 413 on bodies >2 MiB.
104        .layer(DefaultBodyLimit::disable())
105        .layer(SetRequestIdLayer::new(
106            x_request_id.clone(),
107            MakeRequestUuid,
108        ))
109        .layer(SetRequestIdLayer::new(x_trace_id.clone(), EchoOnly))
110        .layer(option_layer(telemetry::tower_new_layer()))
111        .layer(option_layer(telemetry::tower_http_layer()))
112        .layer(middleware::from_fn(attach_sentry_tags))
113        .layer(
114            TraceLayer::new_for_http()
115                .on_failure(DefaultOnFailure::new().level(tracing::Level::DEBUG)),
116        )
117        .layer(middleware::from_fn(record_http_metrics))
118        .layer(PropagateRequestIdLayer::new(x_request_id))
119        .layer(PropagateRequestIdLayer::new(x_trace_id))
120        .layer(cache_control_layer());
121
122    #[cfg(not(feature = "sentry"))]
123    let middleware_stack = ServiceBuilder::new()
124        // Reason: axum's `Bytes`/`String`/`Json` extractors silently
125        // cap request bodies at 2 MiB by default. The README pledges
126        // `Body size: No limit ... DoS risk is accepted.`, so we
127        // disable the cap globally. Without this layer the
128        // `/conversion` handler would 413 on bodies >2 MiB.
129        .layer(DefaultBodyLimit::disable())
130        .layer(SetRequestIdLayer::new(
131            x_request_id.clone(),
132            MakeRequestUuid,
133        ))
134        .layer(SetRequestIdLayer::new(x_trace_id.clone(), EchoOnly))
135        .layer(
136            TraceLayer::new_for_http()
137                .on_failure(DefaultOnFailure::new().level(tracing::Level::DEBUG)),
138        )
139        .layer(middleware::from_fn(record_http_metrics))
140        .layer(PropagateRequestIdLayer::new(x_request_id))
141        .layer(PropagateRequestIdLayer::new(x_trace_id))
142        .layer(cache_control_layer());
143
144    Router::new()
145        .route("/conversion", conversion_route)
146        .route("/health", health_route)
147        .fallback(not_found)
148        .layer(middleware_stack)
149}
150
151/// Builds the public router and additionally registers `GET /metrics`
152/// using the provided Prometheus handle.
153///
154/// The `/metrics` route is intentionally registered **after** the
155/// `ServiceBuilder` layer stack so it does NOT inherit:
156///   - `cache_control_layer` (Prometheus scrapers ignore cache headers,
157///     but a clean exposition response is preferred)
158///   - request-id / trace-id propagation
159///   - sentry hub binding
160///   - the HTTP metrics middleware (double-skip protection — even if
161///     someone removes the `MatchedPath == "/metrics"` guard in
162///     `record_http_metrics`, the router topology still prevents the
163///     scrape from incrementing counters).
164#[inline]
165pub fn router_with_metrics(handle: PrometheusHandle) -> Router {
166    router().route("/metrics", axum::routing::get(metrics_handler(handle)))
167}