Skip to main content

aro_web/
middleware.rs

1//! Built-in middleware helpers for common cross-cutting concerns.
2//!
3//! Provides pre-configured CORS and request-ID layers, plus re-exports of
4//! Axum's middleware combinators.
5
6use axum::http::{HeaderName, HeaderValue, Method, Request, header};
7use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
8use tower_http::request_id::{
9    MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer,
10};
11use tower_http::set_header::SetResponseHeaderLayer;
12
13// Re-export axum's middleware combinators so users can write
14// `aro::middleware::from_fn` without depending on axum directly.
15pub use axum::middleware::*;
16
17/// Header name used for request IDs.
18pub static X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
19
20// ---------------------------------------------------------------------------
21// CORS
22// ---------------------------------------------------------------------------
23
24/// Configuration for the CORS middleware.
25#[derive(Debug, Clone)]
26pub struct CorsConfig {
27    /// Origins allowed to access the API (see [`AllowOrigin`]).
28    pub allow_origins: AllowOrigin,
29    /// HTTP methods allowed for cross-origin requests.
30    pub allow_methods: AllowMethods,
31    /// Request headers browsers may send on cross-origin requests.
32    pub allow_headers: AllowHeaders,
33}
34
35impl Default for CorsConfig {
36    /// Returns a permissive configuration suitable for development.
37    fn default() -> Self {
38        Self {
39            allow_origins: AllowOrigin::any(),
40            allow_methods: AllowMethods::list([
41                Method::GET,
42                Method::POST,
43                Method::PUT,
44                Method::PATCH,
45                Method::DELETE,
46                Method::OPTIONS,
47            ]),
48            allow_headers: AllowHeaders::list([
49                header::CONTENT_TYPE,
50                header::AUTHORIZATION,
51                header::ACCEPT,
52                X_REQUEST_ID.clone(),
53            ]),
54        }
55    }
56}
57
58/// Returns a CORS layer with permissive defaults suitable for development.
59///
60/// Allows any origin, common HTTP methods, and typical headers.
61pub fn cors() -> CorsLayer {
62    cors_with_config(CorsConfig::default())
63}
64
65/// Returns a CORS layer configured according to the given [`CorsConfig`].
66pub fn cors_with_config(config: CorsConfig) -> CorsLayer {
67    CorsLayer::new()
68        .allow_origin(config.allow_origins)
69        .allow_methods(config.allow_methods)
70        .allow_headers(config.allow_headers)
71}
72
73// ---------------------------------------------------------------------------
74// Request ID
75// ---------------------------------------------------------------------------
76
77/// UUID v4 request ID generator.
78#[derive(Clone, Copy)]
79pub struct UuidRequestId;
80
81impl MakeRequestId for UuidRequestId {
82    fn make_request_id<B>(&mut self, _request: &Request<B>) -> Option<RequestId> {
83        let id = uuid::Uuid::new_v4().to_string();
84        Some(RequestId::new(HeaderValue::from_str(&id).ok()?))
85    }
86}
87
88/// Returns a tuple of layers that set and propagate an `x-request-id` header.
89///
90/// - `SetRequestIdLayer` assigns a UUID v4 request ID on incoming requests
91///   that do not already carry one.
92/// - `PropagateRequestIdLayer` copies the request ID to the response.
93pub fn request_id() -> (SetRequestIdLayer<UuidRequestId>, PropagateRequestIdLayer) {
94    (
95        SetRequestIdLayer::new(X_REQUEST_ID.clone(), UuidRequestId),
96        PropagateRequestIdLayer::new(X_REQUEST_ID.clone()),
97    )
98}
99
100// ---------------------------------------------------------------------------
101// Compression
102// ---------------------------------------------------------------------------
103
104/// Returns a compression layer that negotiates encoding based on the
105/// `Accept-Encoding` request header.
106///
107/// Enabled algorithms depend on the active feature flags:
108/// - `compression` enables gzip and brotli
109/// - `compression-full` adds zstd and deflate
110#[cfg(feature = "compression")]
111pub fn compression() -> tower_http::compression::CompressionLayer {
112    tower_http::compression::CompressionLayer::new()
113}
114
115// ---------------------------------------------------------------------------
116// Timeout
117// ---------------------------------------------------------------------------
118
119/// Returns a layer that fails requests which are not completed within the
120/// given `duration`.
121///
122/// Requests that exceed the timeout receive a `408 Request Timeout` response.
123///
124/// Use this when composing layers on a raw [`axum::Router`]. When building
125/// via [`App`](crate::App), prefer [`App::timeout`](crate::App::timeout),
126/// which applies the same layer to all routes including stateful DI routes.
127pub fn timeout(duration: std::time::Duration) -> tower_http::timeout::TimeoutLayer {
128    tower_http::timeout::TimeoutLayer::with_status_code(
129        axum::http::StatusCode::REQUEST_TIMEOUT,
130        duration,
131    )
132}
133
134// ---------------------------------------------------------------------------
135// Cache-Control
136// ---------------------------------------------------------------------------
137
138/// Returns a layer that sets the `Cache-Control` response header if not
139/// already present.
140///
141/// The provided `directive` is used as the header value (e.g.
142/// `"max-age=3600"` or `"no-cache, no-store, must-revalidate"`).
143#[expect(
144    clippy::expect_used,
145    reason = "caller-provided directive is validated at the API boundary"
146)]
147pub fn cache_control(directive: &str) -> SetResponseHeaderLayer<HeaderValue> {
148    let value = HeaderValue::from_str(directive).expect("valid Cache-Control header value");
149    SetResponseHeaderLayer::if_not_present(header::CACHE_CONTROL, value)
150}
151
152/// Returns a layer that sets `Cache-Control: no-cache, no-store, must-revalidate`
153/// if the response does not already include a `Cache-Control` header.
154pub fn no_cache() -> SetResponseHeaderLayer<HeaderValue> {
155    cache_control("no-cache, no-store, must-revalidate")
156}
157
158/// Returns a layer that sets `Cache-Control: no-store` if the response does
159/// not already include a `Cache-Control` header.
160pub fn no_store() -> SetResponseHeaderLayer<HeaderValue> {
161    cache_control("no-store")
162}
163
164// ---------------------------------------------------------------------------
165// Request Decompression
166// ---------------------------------------------------------------------------
167
168/// Returns a layer that transparently decompresses request bodies based on
169/// the `Content-Encoding` header (gzip, br, and optionally zstd/deflate
170/// when the `decompression-full` feature is enabled).
171#[cfg(feature = "decompression")]
172pub fn decompression() -> tower_http::decompression::RequestDecompressionLayer {
173    tower_http::decompression::RequestDecompressionLayer::new()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use axum::Router;
180    use axum::body::Body;
181    use axum::http::{self, StatusCode};
182    use axum::routing::get;
183    use tower::ServiceExt;
184
185    async fn ok_handler() -> &'static str {
186        "ok"
187    }
188
189    #[tokio::test]
190    async fn cors_allows_cross_origin_with_default_config() {
191        let app = Router::new().route("/", get(ok_handler)).layer(cors());
192
193        let request = http::Request::builder()
194            .uri("/")
195            .header(header::ORIGIN, "http://example.com")
196            .body(Body::empty())
197            .unwrap();
198
199        let response = app.oneshot(request).await.unwrap();
200        assert_eq!(response.status(), StatusCode::OK);
201        assert!(
202            response
203                .headers()
204                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
205                .is_some()
206        );
207    }
208
209    #[tokio::test]
210    async fn cors_can_be_customized_with_specific_origins() {
211        let config = CorsConfig {
212            allow_origins: AllowOrigin::exact("http://allowed.com".parse().unwrap()),
213            ..CorsConfig::default()
214        };
215        let app = Router::new()
216            .route("/", get(ok_handler))
217            .layer(cors_with_config(config));
218
219        // Request from allowed origin
220        let request = http::Request::builder()
221            .uri("/")
222            .header(header::ORIGIN, "http://allowed.com")
223            .body(Body::empty())
224            .unwrap();
225
226        let response = app.oneshot(request).await.unwrap();
227        assert_eq!(response.status(), StatusCode::OK);
228        assert_eq!(
229            response
230                .headers()
231                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
232                .unwrap(),
233            "http://allowed.com"
234        );
235    }
236
237    #[tokio::test]
238    async fn request_id_adds_header_to_response() {
239        let (set_id, propagate_id) = request_id();
240        let app = Router::new()
241            .route("/", get(ok_handler))
242            .layer(propagate_id)
243            .layer(set_id);
244
245        let request = http::Request::builder()
246            .uri("/")
247            .body(Body::empty())
248            .unwrap();
249
250        let response = app.oneshot(request).await.unwrap();
251        assert_eq!(response.status(), StatusCode::OK);
252
253        let rid = response
254            .headers()
255            .get("x-request-id")
256            .expect("x-request-id header should be present");
257        // Should be a valid UUID v4
258        let parsed = uuid::Uuid::parse_str(rid.to_str().unwrap());
259        assert!(parsed.is_ok(), "x-request-id should be a valid UUID");
260    }
261
262    #[tokio::test]
263    async fn request_id_preserves_existing_header() {
264        let existing_id = "my-custom-request-id-123";
265        let (set_id, propagate_id) = request_id();
266        let app = Router::new()
267            .route("/", get(ok_handler))
268            .layer(propagate_id)
269            .layer(set_id);
270
271        let request = http::Request::builder()
272            .uri("/")
273            .header("x-request-id", existing_id)
274            .body(Body::empty())
275            .unwrap();
276
277        let response = app.oneshot(request).await.unwrap();
278        assert_eq!(response.status(), StatusCode::OK);
279        assert_eq!(
280            response
281                .headers()
282                .get("x-request-id")
283                .unwrap()
284                .to_str()
285                .unwrap(),
286            existing_id
287        );
288    }
289
290    #[tokio::test]
291    async fn middleware_composes_with_app_layer() {
292        let (set_id, propagate_id) = request_id();
293        let app = crate::App::new()
294            .routes(Router::new().route("/", get(ok_handler)))
295            .layer(cors())
296            .layer(propagate_id)
297            .layer(set_id)
298            .build();
299
300        let request = http::Request::builder()
301            .uri("/")
302            .header(header::ORIGIN, "http://example.com")
303            .body(Body::empty())
304            .unwrap();
305
306        let response = ServiceExt::<http::Request<Body>>::oneshot(app, request)
307            .await
308            .unwrap();
309        assert_eq!(response.status(), StatusCode::OK);
310
311        // CORS header present
312        assert!(
313            response
314                .headers()
315                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
316                .is_some()
317        );
318        // Request ID present
319        assert!(response.headers().get("x-request-id").is_some());
320    }
321
322    #[tokio::test]
323    async fn axum_from_fn_is_accessible() {
324        // Verify the re-export works by creating a middleware from a function
325        async fn noop_middleware(
326            request: http::Request<Body>,
327            next: axum::middleware::Next,
328        ) -> axum::response::Response {
329            next.run(request).await
330        }
331
332        let _layer = from_fn::<_, ()>(noop_middleware);
333    }
334
335    #[tokio::test]
336    async fn cache_control_sets_header_on_response() {
337        let app = Router::new()
338            .route("/", get(ok_handler))
339            .layer(cache_control("max-age=3600"));
340
341        let request = http::Request::builder()
342            .uri("/")
343            .body(Body::empty())
344            .unwrap();
345
346        let response = app.oneshot(request).await.unwrap();
347        assert_eq!(response.status(), StatusCode::OK);
348        assert_eq!(
349            response
350                .headers()
351                .get(header::CACHE_CONTROL)
352                .unwrap()
353                .to_str()
354                .unwrap(),
355            "max-age=3600"
356        );
357    }
358
359    #[tokio::test]
360    async fn cache_control_does_not_overwrite_handler_set_header() {
361        async fn handler_with_cache() -> impl axum::response::IntoResponse {
362            ([(header::CACHE_CONTROL, "private, max-age=60")], "ok")
363        }
364
365        let app = Router::new()
366            .route("/", get(handler_with_cache))
367            .layer(no_cache());
368
369        let request = http::Request::builder()
370            .uri("/")
371            .body(Body::empty())
372            .unwrap();
373
374        let response = app.oneshot(request).await.unwrap();
375        assert_eq!(response.status(), StatusCode::OK);
376        assert_eq!(
377            response
378                .headers()
379                .get(header::CACHE_CONTROL)
380                .unwrap()
381                .to_str()
382                .unwrap(),
383            "private, max-age=60"
384        );
385    }
386
387    #[tokio::test]
388    async fn no_cache_sets_full_directive() {
389        let app = Router::new().route("/", get(ok_handler)).layer(no_cache());
390
391        let request = http::Request::builder()
392            .uri("/")
393            .body(Body::empty())
394            .unwrap();
395
396        let response = app.oneshot(request).await.unwrap();
397        assert_eq!(response.status(), StatusCode::OK);
398        assert_eq!(
399            response
400                .headers()
401                .get(header::CACHE_CONTROL)
402                .unwrap()
403                .to_str()
404                .unwrap(),
405            "no-cache, no-store, must-revalidate"
406        );
407    }
408}