Skip to main content

docspec_http/
mime_parser.rs

1//! HTTP `Accept` negotiation and `Content-Type` validation for the conversion API.
2
3use axum::http::HeaderValue;
4use docspec::{InputFormat, OutputFormat};
5
6use crate::error::HttpError;
7use crate::mime::{MIME_BLOCKNOTE, MIME_BLOCKNOTE_ALIAS, MIME_HTML, MIME_OXA, MIME_PANDOC_NATIVE};
8
9/// Negotiates the `Accept` header for the `/conversion` endpoint.
10///
11/// Returns [`OutputFormat::Oxa`] for `Accept: application/vnd.oxa+json`,
12/// [`OutputFormat::PandocNative`] for `Accept: application/vnd.pandoc.native`,
13/// [`OutputFormat::Html`] for `Accept: text/html`, and [`OutputFormat::Blocknote`]
14/// for the `BlockNote` MIMEs, `application/*`, `*/*`, and missing `Accept`.
15/// Wildcards default to `BlockNote` for back-compat with pre-oxa clients. When
16/// `Accept` lists multiple types, the first whose bare MIME matches a supported
17/// value wins (case-insensitive); `q=...` is stripped.
18///
19/// # Errors
20///
21/// Returns [`HttpError::NotAcceptable`] if no acceptable MIME type found.
22#[inline]
23pub fn negotiate_accept(header_value: Option<&HeaderValue>) -> Result<OutputFormat, HttpError> {
24    // Missing Accept == */* per RFC 7231 §5.3.2
25    let Some(header_val) = header_value else {
26        return Ok(OutputFormat::Blocknote);
27    };
28    let header_str = header_val
29        .to_str()
30        .map_err(|_err| HttpError::NotAcceptable)?;
31
32    for part in header_str.split(',') {
33        let type_part = part.trim().split(';').next().map_or("", str::trim);
34        if type_part.eq_ignore_ascii_case(MIME_OXA) {
35            return Ok(OutputFormat::Oxa);
36        }
37        if type_part.eq_ignore_ascii_case(MIME_PANDOC_NATIVE) {
38            return Ok(OutputFormat::PandocNative);
39        }
40        if type_part.eq_ignore_ascii_case(MIME_HTML) {
41            return Ok(OutputFormat::Html);
42        }
43        if type_part.eq_ignore_ascii_case("*/*")
44            || type_part.eq_ignore_ascii_case("application/*")
45            || type_part.eq_ignore_ascii_case(MIME_BLOCKNOTE)
46            || type_part.eq_ignore_ascii_case(MIME_BLOCKNOTE_ALIAS)
47        {
48            return Ok(OutputFormat::Blocknote);
49        }
50    }
51    Err(HttpError::NotAcceptable)
52}
53
54/// Validates the `Content-Type` header for the `/conversion` endpoint and
55/// resolves it to the matching reader format.
56///
57/// Accepts `text/markdown` ([`InputFormat::Markdown`]), `text/html`
58/// ([`InputFormat::Html`]), and
59/// `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
60/// ([`InputFormat::Docx`]). Text types accept no charset, or `charset=utf-8`
61/// (case-insensitive). DOCX is a binary format — no parameters are allowed at
62/// all. Any other charset is rejected — the handler always decodes the body as
63/// UTF-8, so a non-UTF-8 charset is unsupportable. Returns `Err` if the header
64/// is missing, malformed, the MIME type is unsupported, the charset is anything
65/// other than `utf-8` for text types, or any parameter is present on DOCX.
66///
67/// # Errors
68///
69/// Returns [`HttpError::UnsupportedMediaType`] with the received value (or `None` if missing).
70#[inline]
71pub fn validate_content_type(header_value: Option<&HeaderValue>) -> Result<InputFormat, HttpError> {
72    let Some(header_val) = header_value else {
73        return Err(HttpError::UnsupportedMediaType { received: None });
74    };
75    let header_str = header_val
76        .to_str()
77        .ok()
78        .ok_or_else(|| HttpError::UnsupportedMediaType {
79            received: Some("<invalid header value>".to_owned()),
80        })?;
81    let parsed: mime::Mime =
82        header_str
83            .parse()
84            .ok()
85            .ok_or_else(|| HttpError::UnsupportedMediaType {
86                received: Some(header_str.to_owned()),
87            })?;
88    // DOCX: strict — no parameters allowed (binary format; charset is meaningless)
89    if parsed.type_() == mime::APPLICATION
90        && parsed.subtype().as_str()
91            == "vnd.openxmlformats-officedocument.wordprocessingml.document"
92    {
93        if parsed.params().next().is_some() {
94            return Err(HttpError::UnsupportedMediaType {
95                received: Some(header_str.to_owned()),
96            });
97        }
98        return Ok(InputFormat::Docx);
99    }
100    let format = match (parsed.type_(), parsed.subtype().as_str()) {
101        (mime::TEXT, "markdown") => InputFormat::Markdown,
102        (mime::TEXT, "html") => InputFormat::Html,
103        _ => {
104            return Err(HttpError::UnsupportedMediaType {
105                received: Some(header_str.to_owned()),
106            });
107        }
108    };
109    if let Some(charset) = parsed.get_param(mime::CHARSET) {
110        if !charset.as_str().eq_ignore_ascii_case("utf-8") {
111            return Err(HttpError::UnsupportedMediaType {
112                received: Some(header_str.to_owned()),
113            });
114        }
115    }
116    // Strict: only the optional charset parameter is allowed. Unknown params
117    // (e.g. `boundary`, `format`) cause 415 to prevent accidental acceptance
118    // of unrelated media types that happen to share the text/markdown prefix.
119    for (name, _) in parsed.params() {
120        if name != mime::CHARSET {
121            return Err(HttpError::UnsupportedMediaType {
122                received: Some(header_str.to_owned()),
123            });
124        }
125    }
126    Ok(format)
127}
128
129/// Returns the bounded `input_mime_type` label value for a `Content-Type` header.
130///
131/// This function is intentionally MORE permissive than [`validate_content_type`]:
132/// it returns the matching label for any `text/markdown`, `text/html`, or DOCX
133/// value regardless of charset or other parameters, because the label answers
134/// "what did the client try to send?" rather than "is it valid?".
135///
136/// # Label values
137///
138/// - [`crate::metrics::INPUT_MIME_NONE`] — header absent
139/// - [`crate::mime::MIME_MARKDOWN`] — `text/markdown` (any params)
140/// - [`crate::mime::MIME_HTML`] — `text/html` (any params)
141/// - [`crate::mime::MIME_DOCX`] — `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (any params)
142/// - [`crate::metrics::INPUT_MIME_UNSUPPORTED`] — anything else
143#[must_use]
144#[inline]
145pub fn bucket_input_mime(header_value: Option<&HeaderValue>) -> &'static str {
146    let Some(header_val) = header_value else {
147        return crate::metrics::INPUT_MIME_NONE;
148    };
149    let Ok(header_str) = header_val.to_str() else {
150        return crate::metrics::INPUT_MIME_UNSUPPORTED;
151    };
152    let Ok(parsed) = header_str.parse::<mime::Mime>() else {
153        return crate::metrics::INPUT_MIME_UNSUPPORTED;
154    };
155    match (parsed.type_(), parsed.subtype().as_str()) {
156        (mime::TEXT, "markdown") => crate::mime::MIME_MARKDOWN,
157        (mime::TEXT, "html") => crate::mime::MIME_HTML,
158        (mime::APPLICATION, "vnd.openxmlformats-officedocument.wordprocessingml.document") => {
159            crate::mime::MIME_DOCX
160        }
161        _ => crate::metrics::INPUT_MIME_UNSUPPORTED,
162    }
163}
164
165/// Returns the bounded `output_mime_type` label value for a conversion outcome.
166///
167/// `chosen_format` is `None` for any error path, and `Some(format)` on success.
168#[inline]
169#[must_use]
170pub fn bucket_output_mime(chosen_format: Option<OutputFormat>) -> &'static str {
171    match chosen_format {
172        Some(OutputFormat::Blocknote) => crate::mime::MIME_BLOCKNOTE,
173        Some(OutputFormat::Html) => crate::mime::MIME_HTML,
174        Some(OutputFormat::Oxa) => crate::mime::MIME_OXA,
175        Some(OutputFormat::PandocNative) => crate::mime::MIME_PANDOC_NATIVE,
176        None | Some(_) => crate::metrics::OUTPUT_MIME_NONE,
177    }
178}
179
180#[cfg(test)]
181mod bucket_tests {
182    #![allow(
183        clippy::tests_outside_test_module,
184        clippy::unwrap_used,
185        clippy::expect_used
186    )]
187
188    use super::*;
189    use axum::http::HeaderValue;
190
191    // ─── bucket_input_mime tests ───────────────────────────────────────────
192
193    #[test]
194    fn bucket_input_mime_none_when_header_absent() {
195        assert_eq!(bucket_input_mime(None), crate::metrics::INPUT_MIME_NONE);
196    }
197
198    #[test]
199    fn bucket_input_mime_markdown_when_text_markdown() {
200        let val = HeaderValue::from_static("text/markdown");
201        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_MARKDOWN);
202    }
203
204    #[test]
205    fn bucket_input_mime_markdown_when_text_markdown_with_charset() {
206        let val = HeaderValue::from_static("text/markdown; charset=utf-8");
207        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_MARKDOWN);
208    }
209
210    #[test]
211    fn bucket_input_mime_markdown_case_insensitive() {
212        let val = HeaderValue::from_static("TEXT/MARKDOWN");
213        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_MARKDOWN);
214    }
215
216    #[test]
217    fn bucket_input_mime_html_when_text_html() {
218        let val = HeaderValue::from_static("text/html");
219        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_HTML);
220    }
221
222    #[test]
223    fn bucket_input_mime_html_when_text_html_with_charset() {
224        let val = HeaderValue::from_static("text/html; charset=utf-8");
225        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_HTML);
226    }
227
228    #[test]
229    fn bucket_input_mime_html_case_insensitive() {
230        let val = HeaderValue::from_static("TEXT/HTML");
231        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_HTML);
232    }
233
234    #[test]
235    fn bucket_input_mime_html_with_non_utf8_charset_still_buckets_html() {
236        let val = HeaderValue::from_static("text/html; charset=iso-8859-1");
237        assert_eq!(bucket_input_mime(Some(&val)), crate::mime::MIME_HTML);
238    }
239
240    #[test]
241    fn bucket_input_mime_unsupported_when_other_format() {
242        let val = HeaderValue::from_static("application/pdf");
243        assert_eq!(
244            bucket_input_mime(Some(&val)),
245            crate::metrics::INPUT_MIME_UNSUPPORTED
246        );
247    }
248
249    #[test]
250    fn bucket_input_mime_unsupported_when_malformed() {
251        let val = HeaderValue::from_static("not a mime type at all");
252        assert_eq!(
253            bucket_input_mime(Some(&val)),
254            crate::metrics::INPUT_MIME_UNSUPPORTED
255        );
256    }
257
258    #[test]
259    fn bucket_input_mime_unsupported_when_non_ascii() {
260        let val = HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap();
261        assert_eq!(
262            bucket_input_mime(Some(&val)),
263            crate::metrics::INPUT_MIME_UNSUPPORTED
264        );
265    }
266
267    // ─── bucket_output_mime tests ──────────────────────────────────────────
268
269    #[test]
270    fn bucket_output_mime_blocknote_when_blocknote_succeeded() {
271        assert_eq!(
272            bucket_output_mime(Some(OutputFormat::Blocknote)),
273            crate::mime::MIME_BLOCKNOTE
274        );
275    }
276
277    #[test]
278    fn bucket_output_mime_html_when_html_succeeded() {
279        assert_eq!(
280            bucket_output_mime(Some(OutputFormat::Html)),
281            crate::mime::MIME_HTML
282        );
283    }
284
285    #[test]
286    fn bucket_output_mime_oxa_when_oxa_succeeded() {
287        assert_eq!(
288            bucket_output_mime(Some(OutputFormat::Oxa)),
289            crate::mime::MIME_OXA
290        );
291    }
292
293    #[test]
294    fn bucket_output_mime_pandoc_native_when_pandoc_native_succeeded() {
295        assert_eq!(
296            bucket_output_mime(Some(OutputFormat::PandocNative)),
297            crate::mime::MIME_PANDOC_NATIVE
298        );
299    }
300
301    #[test]
302    fn bucket_output_mime_none_when_no_format_chosen() {
303        assert_eq!(bucket_output_mime(None), crate::metrics::OUTPUT_MIME_NONE);
304    }
305}