Skip to main content

systemprompt_api/services/middleware/negotiation/
mod.rs

1//! Content-negotiation middleware.
2//!
3//! Parses the `Accept` header into an [`AcceptedFormat`] (one of
4//! [`AcceptedMediaType`]) honouring `q=` quality weights, and stores it in the
5//! request extensions so handlers can serve JSON, Markdown, or HTML from a
6//! single route.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use axum::extract::Request;
12use axum::middleware::Next;
13use axum::response::Response;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub enum AcceptedMediaType {
17    #[default]
18    Json,
19    Markdown,
20    Html,
21}
22
23impl AcceptedMediaType {
24    pub const fn content_type(&self) -> &'static str {
25        match self {
26            Self::Json => "application/json",
27            Self::Markdown => "text/markdown; charset=utf-8",
28            Self::Html => "text/html; charset=utf-8",
29        }
30    }
31
32    pub const fn is_markdown(&self) -> bool {
33        matches!(self, Self::Markdown)
34    }
35}
36
37#[derive(Debug, Clone, Copy)]
38pub struct AcceptedFormat(pub AcceptedMediaType);
39
40impl Default for AcceptedFormat {
41    fn default() -> Self {
42        Self(AcceptedMediaType::Json)
43    }
44}
45
46impl AcceptedFormat {
47    pub const fn media_type(&self) -> AcceptedMediaType {
48        self.0
49    }
50
51    pub const fn is_markdown(&self) -> bool {
52        self.0.is_markdown()
53    }
54}
55
56struct MediaTypeEntry {
57    media_type: AcceptedMediaType,
58    quality: f32,
59}
60
61pub fn parse_accept_header(header_value: &str) -> AcceptedFormat {
62    let mut entries = Vec::new();
63
64    for part in header_value.split(',') {
65        let part = part.trim();
66        if part.is_empty() {
67            continue;
68        }
69
70        let (media_type_str, params) = part
71            .split_once(';')
72            .map_or((part, ""), |(m, p)| (m.trim(), p));
73
74        let quality = params
75            .split(';')
76            .find_map(|p| {
77                let p = p.trim();
78                p.strip_prefix("q=")
79                    .and_then(|q_str| q_str.parse::<f32>().ok().map(|q| q.clamp(0.0, 1.0)))
80            })
81            .unwrap_or(1.0);
82
83        let media_type = match media_type_str.to_lowercase().as_str() {
84            "text/markdown" | "text/x-markdown" => Some(AcceptedMediaType::Markdown),
85            "application/json" | "*/*" => Some(AcceptedMediaType::Json),
86            "text/html" | "application/xhtml+xml" => Some(AcceptedMediaType::Html),
87            _ => None,
88        };
89
90        if let Some(mt) = media_type {
91            entries.push(MediaTypeEntry {
92                media_type: mt,
93                quality,
94            });
95        }
96    }
97
98    entries.sort_by(|a, b| {
99        b.quality
100            .partial_cmp(&a.quality)
101            .unwrap_or(std::cmp::Ordering::Equal)
102    });
103
104    let media_type = entries
105        .first()
106        .map_or(AcceptedMediaType::Json, |e| e.media_type);
107
108    AcceptedFormat(media_type)
109}
110
111pub async fn content_negotiation_middleware(mut request: Request, next: Next) -> Response {
112    let accepted_format = request
113        .headers()
114        .get(http::header::ACCEPT)
115        .and_then(|v| v.to_str().ok())
116        .map_or_else(AcceptedFormat::default, parse_accept_header);
117
118    request.extensions_mut().insert(accepted_format);
119
120    next.run(request).await
121}