Skip to main content

systemprompt_api/routes/managed/contract/
mod.rs

1//! Uniform request bounds and problem details for the consumer surface.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5mod bounds;
6use axum::Json;
7use axum::extract::Request;
8use axum::http::{StatusCode, header};
9use axum::middleware::Next;
10use axum::response::{IntoResponse, Response};
11use serde::Serialize;
12
13/// Problem details shared by authentication, extraction and domain failures.
14#[derive(Debug, Clone, Serialize)]
15pub struct Problem {
16    #[serde(rename = "type")]
17    pub kind: String,
18    pub title: String,
19    pub status: u16,
20    pub detail: String,
21}
22
23pub(crate) fn problem(status: StatusCode, detail: impl Into<String>) -> Response {
24    (
25        status,
26        [
27            (header::CONTENT_TYPE, "application/problem+json"),
28            (header::CACHE_CONTROL, "no-store"),
29        ],
30        Json(Problem {
31            kind: "about:blank".to_owned(),
32            title: status
33                .canonical_reason()
34                .unwrap_or("Request failed")
35                .to_owned(),
36            status: status.as_u16(),
37            detail: detail.into(),
38        }),
39    )
40        .into_response()
41}
42pub async fn normalize(request: Request, next: Next) -> Response {
43    let request = match bounds::validate(request).await {
44        Ok(request) => request,
45        Err(response) => return response,
46    };
47    let mut response = next.run(request).await;
48    if (response.status().is_client_error() || response.status().is_server_error())
49        && response
50            .headers()
51            .get(header::CONTENT_TYPE)
52            .and_then(|value| value.to_str().ok())
53            .is_none_or(|value| !value.starts_with("application/problem+json"))
54    {
55        let status = response.status();
56        let mut normalized = problem(
57            status,
58            match status {
59                StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
60                    "Invalid request body, query or path parameters"
61                },
62                StatusCode::UNAUTHORIZED => "Valid authentication is required",
63                StatusCode::FORBIDDEN => "This identity or browser origin is not authorized",
64                StatusCode::NOT_FOUND => "The requested resource is unavailable",
65                StatusCode::TOO_MANY_REQUESTS => {
66                    "Request limit reached; retry after the indicated delay"
67                },
68                _ => "The operation could not be completed",
69            },
70        );
71        for name in [header::WWW_AUTHENTICATE, header::RETRY_AFTER] {
72            if let Some(value) = response.headers().get(&name) {
73                normalized.headers_mut().insert(name, value.clone());
74            }
75        }
76        return normalized;
77    }
78    response.headers_mut().insert(
79        header::CACHE_CONTROL,
80        http::HeaderValue::from_static("no-store"),
81    );
82    response
83}