Skip to main content

cratefield_core/
http.rs

1//! HTTP plumbing every venture router shares (issue #2): the problem+json
2//! `Json` and `Form` extractors, the request-id middleware that creates
3//! the [`Scope`], and the `/v1/*` security headers.
4
5use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::ports::{Defer, IdGen};
15use crate::problem::Problem;
16use crate::scope::Scope;
17use tracing::info_span;
18
19/// `x-request-id`: accepted from the client when it matches
20/// `^[A-Za-z0-9_-]{8,128}$`, otherwise generated as a ULID. Always set on
21/// the response (architecture section 6).
22pub const X_REQUEST_ID: &str = "x-request-id";
23
24/// Default request body limit for `/v1/*` JSON endpoints.
25pub const MAX_BODY_BYTES: usize = 64 * 1024;
26
27// `Duration::from_days` is unstable on the pinned toolchain
28// (`duration_constructors`), so this stays in seconds.
29#[allow(clippy::duration_suboptimal_units)]
30const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
31
32/// The character class and length bounds of an accepted request id.
33pub fn request_id_is_valid(value: &str) -> bool {
34    (8..=128).contains(&value.len())
35        && value
36            .bytes()
37            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
38}
39
40/// State the request-id layer needs, resolved from `Ports` when the router
41/// is assembled.
42#[derive(Clone)]
43pub(crate) struct ScopeState {
44    pub defer: Arc<dyn Defer>,
45    pub id_gen: Arc<dyn IdGen>,
46}
47
48/// Middleware: resolve the request id, build the [`Scope`] (request id,
49/// defer, tracing span), insert it into extensions, echo the id on the
50/// response.
51pub(crate) async fn scope_layer(
52    axum::extract::State(state): axum::extract::State<ScopeState>,
53    mut request: Request,
54    next: Next,
55) -> AxumResponse {
56    use tracing::Instrument as _;
57    use tracing::field::Empty;
58
59    let incoming = request
60        .headers()
61        .get(X_REQUEST_ID)
62        .and_then(|value| value.to_str().ok())
63        .filter(|value| request_id_is_valid(value));
64    let request_id = match incoming {
65        Some(valid) => valid.to_owned(),
66        None => state.id_gen.ulid(),
67    };
68
69    // The one structured span per request (issue #14). `route`,
70    // `module`, `status` and `duration_ms` are recorded after the
71    // handler runs; no field ever carries an email (only `ip_hash`).
72    let method = request.method().as_str().to_owned();
73    let ip_hash = crate::logging::subject_hash(
74        &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
75    );
76    let ua_family = request
77        .headers()
78        .get(header::USER_AGENT)
79        .and_then(|value| value.to_str().ok())
80        .map_or_else(|| "unknown".to_owned(), ua_family_of);
81    let span = info_span!(
82        "request",
83        request_id = %request_id,
84        method = %method,
85        route = Empty,
86        module = Empty,
87        status = Empty,
88        duration_ms = Empty,
89        ip_hash = %ip_hash,
90        ua_family = %ua_family,
91    );
92
93    let scope = Scope {
94        defer: Arc::clone(&state.defer),
95        span: span.clone(),
96        request_id: request_id.clone(),
97    };
98    request.extensions_mut().insert(scope);
99
100    let route = request
101        .extensions()
102        .get::<axum::extract::MatchedPath>()
103        .map(|matched| matched.as_str().to_owned())
104        .unwrap_or_default();
105
106    // `std::time::Instant::now()` panics on wasm32-unknown-unknown with
107    // "time not implemented on this platform", which took down every
108    // request on Workers — `/__health` included. There is no monotonic
109    // clock in that target, and `Date.now()` is frozen between I/O in
110    // workerd, so a wall-clock delta would read 0 and look measured.
111    // Timing is therefore recorded only where a real clock exists;
112    // Cloudflare's own request logs carry it on Workers.
113    #[cfg(not(target_arch = "wasm32"))]
114    let started = std::time::Instant::now();
115    let future = next.run(request);
116    let mut response = future.instrument(span.clone()).await;
117
118    if let Ok(value) = HeaderValue::from_str(&request_id) {
119        response.headers_mut().insert(X_REQUEST_ID, value);
120    }
121    span.record("route", route.as_str());
122    span.record(
123        "module",
124        route
125            .strip_prefix("/v1/")
126            .and_then(|rest| rest.split('/').next())
127            .unwrap_or_default(),
128    );
129    span.record("status", response.status().as_u16());
130    #[cfg(not(target_arch = "wasm32"))]
131    {
132        let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
133        span.record("duration_ms", duration_ms);
134    }
135    response
136}
137
138/// Coarse user-agent family: the first product token, lowercased —
139/// enough to group browsers, bots and libraries without a UA parser.
140fn ua_family_of(user_agent: &str) -> String {
141    let token = user_agent
142        .split(['/', ' ', ';', '('])
143        .next()
144        .unwrap_or_default()
145        .to_lowercase();
146    let truncated: String = token.chars().take(24).collect();
147    if truncated.is_empty() {
148        "unknown".to_owned()
149    } else {
150        truncated
151    }
152}
153
154/// Middleware: `/v1/*` responses carry
155/// `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and
156/// `Referrer-Policy: no-referrer` (architecture section 6).
157pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
158    let is_api = request.uri().path().starts_with("/v1/");
159    let mut response = next.run(request).await;
160    if is_api {
161        let headers = response.headers_mut();
162        headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
163        headers.insert(
164            header::X_CONTENT_TYPE_OPTIONS,
165            HeaderValue::from_static("nosniff"),
166        );
167        headers.insert(
168            header::HeaderName::from_static("referrer-policy"),
169            HeaderValue::from_static("no-referrer"),
170        );
171    }
172    response
173}
174
175/// A `Json` extractor and response whose rejections and serializations are
176/// problem+json (architecture section 6). Deserialization failures become a
177/// `400 validation-failed` problem listing the field.
178pub struct Json<T>(pub T);
179
180impl<T, S> FromRequest<S> for Json<T>
181where
182    T: DeserializeOwned,
183    S: Send + Sync,
184{
185    type Rejection = Problem;
186
187    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
188        let instance = request
189            .extensions()
190            .get::<Scope>()
191            .map(|scope| scope.request_id.clone());
192        match axum::Json::<T>::from_request(request, state).await {
193            Ok(axum::Json(value)) => Ok(Json(value)),
194            Err(rejection) => {
195                // Body reads fail through the shared 413 slug (the size
196                // limit); everything else is a 400 validation problem.
197                let mut problem = match &rejection {
198                    axum::extract::rejection::JsonRejection::BytesRejection(_) => {
199                        Problem::request_too_large()
200                    }
201                    _ => Problem::validation_failed(rejection.body_text()),
202                };
203                if let Some(instance) = instance {
204                    problem = problem.instance(&instance);
205                }
206                Err(problem)
207            }
208        }
209    }
210}
211
212impl<T: serde::Serialize> IntoResponse for Json<T> {
213    fn into_response(self) -> AxumResponse {
214        axum::Json(self.0).into_response()
215    }
216}
217
218/// A form (`application/x-www-form-urlencoded`) extractor whose
219/// rejections are problem+json with the same shape as [`Json`]'s: body
220/// reads fail through the shared 413 slug (the size limit), everything
221/// else is a 400 validation problem. Needed for cross-site `form_post`
222/// callbacks (issue #46).
223pub struct Form<T>(pub T);
224
225impl<T, S> FromRequest<S> for Form<T>
226where
227    T: DeserializeOwned,
228    S: Send + Sync,
229{
230    type Rejection = Problem;
231
232    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
233        let instance = request
234            .extensions()
235            .get::<Scope>()
236            .map(|scope| scope.request_id.clone());
237        match axum::Form::<T>::from_request(request, state).await {
238            Ok(axum::Form(value)) => Ok(Form(value)),
239            Err(rejection) => {
240                let mut problem = match &rejection {
241                    axum::extract::rejection::FormRejection::BytesRejection(_) => {
242                        Problem::request_too_large()
243                    }
244                    _ => Problem::validation_failed(rejection.body_text()),
245                };
246                if let Some(instance) = instance {
247                    problem = problem.instance(&instance);
248                }
249                Err(problem)
250            }
251        }
252    }
253}
254
255/// A `429 rate-limited` problem carrying `Retry-After: <seconds>` when the
256/// limiter reported a pause (architecture section 6).
257pub fn rate_limited(retry_after: Option<Duration>) -> AxumResponse {
258    let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
259    let mut response = problem.into_response();
260    if let Some(pause) = retry_after {
261        let secs = pause.as_secs().max(1);
262        if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
263            response
264                .headers_mut()
265                .insert(header::HeaderName::from_static("retry-after"), value);
266        }
267    }
268    response
269}
270
271/// CORS allowlist from the venture's origins; never a wildcard
272/// (architecture section 6). Tower-http echoes the matched origin rather
273/// than emitting `*`, and requests from other origins get no CORS headers.
274pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
275    use tower_http::cors::{AllowOrigin, CorsLayer};
276    let allowed: Vec<HeaderValue> = origins
277        .iter()
278        .filter_map(|origin| HeaderValue::from_str(origin).ok())
279        .collect();
280    CorsLayer::new()
281        .allow_origin(AllowOrigin::list(allowed))
282        .allow_methods([
283            axum::http::Method::GET,
284            axum::http::Method::POST,
285            axum::http::Method::DELETE,
286            axum::http::Method::OPTIONS,
287        ])
288        .allow_headers([header::CONTENT_TYPE])
289        .max_age(CORS_PREFLIGHT_MAX_AGE)
290}