Skip to main content

systemprompt_api/routes/managed/contract/
mod.rs

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