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