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::body::Body;
28    use axum::extract::DefaultBodyLimit;
29    use axum::http::header::HeaderName;
30    use axum::middleware::{self, Next};
31    use axum::routing::{get, post};
32    use tower::util::option_layer;
33    use tower::ServiceBuilder;
34    use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
35    use tower_http::trace::TraceLayer;
36
37    use crate::cache::cache_control_layer;
38    use crate::handlers::{
39        conversion::{options_conversion, post_conversion},
40        fallback::{conversion_method_not_allowed, health_method_not_allowed, not_found},
41        health::{get_health, head_health, options_health},
42    };
43    use crate::telemetry;
44
45    let attach_sentry_tags = |request: Request<Body>, next: Next| async move {
46        if sentry::Hub::current().client().is_some() {
47            let request_id = request
48                .extensions()
49                .get::<RequestId>()
50                .and_then(|req_id| req_id.header_value().to_str().ok())
51                .unwrap_or_default()
52                .to_owned();
53            let trace_id = request
54                .headers()
55                .get("x-trace-id")
56                .and_then(|head| head.to_str().ok())
57                .unwrap_or_default()
58                .to_owned();
59            sentry::configure_scope(|scope| {
60                if !request_id.is_empty() {
61                    scope.set_tag("request_id", &request_id);
62                }
63                if !trace_id.is_empty() {
64                    scope.set_tag("trace_id", &trace_id);
65                }
66            });
67        }
68        next.run(request).await
69    };
70
71    let x_request_id = HeaderName::from_static("x-request-id");
72    let x_trace_id = HeaderName::from_static("x-trace-id");
73
74    let conversion_route = post(post_conversion)
75        .options(options_conversion)
76        .fallback(conversion_method_not_allowed);
77
78    let health_route = get(get_health)
79        .head(head_health)
80        .options(options_health)
81        .fallback(health_method_not_allowed);
82
83    Router::new()
84        .route("/conversion", conversion_route)
85        .route("/health", health_route)
86        .fallback(not_found)
87        .layer(
88            ServiceBuilder::new()
89                // Reason: axum's `Bytes`/`String`/`Json` extractors silently
90                // cap request bodies at 2 MiB by default. The README pledges
91                // `Body size: No limit ... DoS risk is accepted.`, so we
92                // disable the cap globally. Without this layer the
93                // `/conversion` handler would 413 on bodies >2 MiB.
94                .layer(DefaultBodyLimit::disable())
95                .layer(SetRequestIdLayer::new(
96                    x_request_id.clone(),
97                    MakeRequestUuid,
98                ))
99                .layer(SetRequestIdLayer::new(x_trace_id.clone(), EchoOnly))
100                .layer(option_layer(telemetry::tower_new_layer()))
101                .layer(option_layer(telemetry::tower_http_layer()))
102                .layer(middleware::from_fn(attach_sentry_tags))
103                .layer(TraceLayer::new_for_http())
104                .layer(middleware::from_fn(record_http_metrics))
105                .layer(PropagateRequestIdLayer::new(x_request_id))
106                .layer(PropagateRequestIdLayer::new(x_trace_id))
107                .layer(cache_control_layer()),
108        )
109}
110
111/// Builds the public router and additionally registers `GET /metrics`
112/// using the provided Prometheus handle.
113///
114/// The `/metrics` route is intentionally registered **after** the
115/// `ServiceBuilder` layer stack so it does NOT inherit:
116///   - `cache_control_layer` (Prometheus scrapers ignore cache headers,
117///     but a clean exposition response is preferred)
118///   - request-id / trace-id propagation
119///   - sentry hub binding
120///   - the HTTP metrics middleware (double-skip protection — even if
121///     someone removes the `MatchedPath == "/metrics"` guard in
122///     `record_http_metrics`, the router topology still prevents the
123///     scrape from incrementing counters).
124#[inline]
125pub fn router_with_metrics(handle: PrometheusHandle) -> Router {
126    router().route("/metrics", axum::routing::get(metrics_handler(handle)))
127}