docspec_http/
mime_parser.rs1use 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#[inline]
23pub fn negotiate_accept(header_value: Option<&HeaderValue>) -> Result<OutputFormat, HttpError> {
24 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#[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 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 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#[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#[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 #[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 #[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}