spikard_http/middleware/
validation.rs

1//! JSON schema validation middleware
2
3use axum::http::HeaderValue;
4use axum::http::{HeaderMap, StatusCode};
5use axum::response::{IntoResponse, Response};
6use serde_json::json;
7use spikard_core::problem::{CONTENT_TYPE_PROBLEM_JSON, ProblemDetails};
8
9/// Check if a media type is JSON or has a +json suffix
10pub fn is_json_content_type(mime: &mime::Mime) -> bool {
11    (mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON) || mime.suffix() == Some(mime::JSON)
12}
13
14fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
15    let mut start = 0usize;
16    let mut end = bytes.len();
17    while start < end && (bytes[start] == b' ' || bytes[start] == b'\t') {
18        start += 1;
19    }
20    while end > start && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
21        end -= 1;
22    }
23    &bytes[start..end]
24}
25
26fn token_before_semicolon(bytes: &[u8]) -> &[u8] {
27    let mut i = 0usize;
28    while i < bytes.len() {
29        let b = bytes[i];
30        if b == b';' {
31            break;
32        }
33        i += 1;
34    }
35    trim_ascii_whitespace(&bytes[..i])
36}
37
38#[inline]
39fn is_json_like_token(token: &[u8]) -> bool {
40    if token.eq_ignore_ascii_case(b"application/json") {
41        return true;
42    }
43    // vendor JSON: application/vnd.foo+json
44    token.len() >= 5 && token[token.len() - 5..].eq_ignore_ascii_case(b"+json")
45}
46
47#[inline]
48fn is_multipart_form_data_token(token: &[u8]) -> bool {
49    token.eq_ignore_ascii_case(b"multipart/form-data")
50}
51
52#[inline]
53fn is_form_urlencoded_token(token: &[u8]) -> bool {
54    token.eq_ignore_ascii_case(b"application/x-www-form-urlencoded")
55}
56
57fn is_valid_content_type_token(token: &[u8]) -> bool {
58    // Minimal fast validation:
59    // - exactly one '/' separating type and subtype
60    // - no whitespace
61    // - type and subtype are non-empty
62    if token.is_empty() {
63        return false;
64    }
65    let mut slash_pos: Option<usize> = None;
66    for (idx, &b) in token.iter().enumerate() {
67        if b == b' ' || b == b'\t' {
68            return false;
69        }
70        if b == b'/' {
71            if slash_pos.is_some() {
72                return false;
73            }
74            slash_pos = Some(idx);
75        }
76    }
77    match slash_pos {
78        None => false,
79        Some(0) => false,
80        Some(pos) if pos + 1 >= token.len() => false,
81        Some(_) => true,
82    }
83}
84
85fn ascii_contains_ignore_case(haystack: &[u8], needle: &[u8]) -> bool {
86    if needle.is_empty() {
87        return true;
88    }
89    if haystack.len() < needle.len() {
90        return false;
91    }
92    haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
93}
94
95/// Fast classification: does this Content-Type represent JSON (application/json or +json)?
96pub fn is_json_like(content_type: &HeaderValue) -> bool {
97    let token = token_before_semicolon(content_type.as_bytes());
98    is_json_like_token(token)
99}
100
101/// Fast classification for already-extracted header strings.
102///
103/// This is used in hot paths where headers are stored as `String` values in `RequestData`.
104pub fn is_json_like_str(content_type: &str) -> bool {
105    let token = token_before_semicolon(content_type.as_bytes());
106    is_json_like_token(token)
107}
108
109/// Fast classification: is this Content-Type multipart/form-data?
110pub fn is_multipart_form_data(content_type: &HeaderValue) -> bool {
111    let token = token_before_semicolon(content_type.as_bytes());
112    is_multipart_form_data_token(token)
113}
114
115/// Fast classification: is this Content-Type application/x-www-form-urlencoded?
116pub fn is_form_urlencoded(content_type: &HeaderValue) -> bool {
117    let token = token_before_semicolon(content_type.as_bytes());
118    is_form_urlencoded_token(token)
119}
120
121/// Classify Content-Type header values after validation.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ContentTypeKind {
124    Json,
125    Multipart,
126    FormUrlencoded,
127    Other,
128}
129
130fn multipart_has_boundary(content_type: &HeaderValue) -> bool {
131    ascii_contains_ignore_case(content_type.as_bytes(), b"boundary=")
132}
133
134fn json_charset_value(content_type: &HeaderValue) -> Option<&[u8]> {
135    let bytes = content_type.as_bytes();
136    if !ascii_contains_ignore_case(bytes, b"charset=") {
137        return None;
138    }
139
140    // Extract first charset token after "charset=" up to ';' or whitespace.
141    let mut i = 0usize;
142    while i + 8 <= bytes.len() {
143        if bytes[i..i + 8].eq_ignore_ascii_case(b"charset=") {
144            let mut j = i + 8;
145            while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
146                j += 1;
147            }
148            let start = j;
149            while j < bytes.len() {
150                let b = bytes[j];
151                if b == b';' || b == b' ' || b == b'\t' {
152                    break;
153                }
154                j += 1;
155            }
156            return Some(&bytes[start..j]);
157        }
158        i += 1;
159    }
160    None
161}
162
163/// Validate that Content-Type is JSON-compatible when route expects JSON
164#[allow(clippy::result_large_err)]
165pub fn validate_json_content_type(headers: &HeaderMap) -> Result<(), Response> {
166    if let Some(content_type_header) = headers.get(axum::http::header::CONTENT_TYPE) {
167        if content_type_header.to_str().is_err() {
168            return Ok(());
169        }
170
171        let token = token_before_semicolon(content_type_header.as_bytes());
172        let is_json = is_json_like_token(token);
173        let is_form = is_form_urlencoded_token(token) || is_multipart_form_data_token(token);
174
175        if !is_json && !is_form {
176            let problem = ProblemDetails::new(
177                "https://spikard.dev/errors/unsupported-media-type",
178                "Unsupported Media Type",
179                StatusCode::UNSUPPORTED_MEDIA_TYPE,
180            )
181            .with_detail("Unsupported media type");
182
183            let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
184            return Err((
185                StatusCode::UNSUPPORTED_MEDIA_TYPE,
186                [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
187                body,
188            )
189                .into_response());
190        }
191    }
192    Ok(())
193}
194
195/// Validate Content-Length header matches actual body size
196#[allow(clippy::result_large_err, clippy::collapsible_if)]
197pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
198    if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
199        let Some(declared_length) = parse_ascii_usize(content_length_header.as_bytes()) else {
200            return Ok(());
201        };
202        if declared_length != actual_size {
203            let problem = ProblemDetails::bad_request(format!(
204                "Content-Length header ({}) does not match actual body size ({})",
205                declared_length, actual_size
206            ));
207
208            let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
209            return Err((
210                StatusCode::BAD_REQUEST,
211                [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
212                body,
213            )
214                .into_response());
215        }
216    }
217    Ok(())
218}
219
220fn parse_ascii_usize(bytes: &[u8]) -> Option<usize> {
221    if bytes.is_empty() {
222        return None;
223    }
224    let mut value: usize = 0;
225    for &b in bytes {
226        if !b.is_ascii_digit() {
227            return None;
228        }
229        value = value.saturating_mul(10).saturating_add((b - b'0') as usize);
230    }
231    Some(value)
232}
233
234/// Validate Content-Type header and related requirements
235#[allow(clippy::result_large_err)]
236pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
237    validate_content_type_headers_and_classify(headers, _declared_body_size).map(|_| ())
238}
239
240/// Validate Content-Type and return its classification (if present).
241#[allow(clippy::result_large_err)]
242pub fn validate_content_type_headers_and_classify(
243    headers: &HeaderMap,
244    _declared_body_size: usize,
245) -> Result<Option<ContentTypeKind>, Response> {
246    let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) else {
247        return Ok(None);
248    };
249
250    if !content_type.as_bytes().is_ascii() && content_type.to_str().is_err() {
251        // Keep legacy behavior: invalid bytes should fail fast.
252        let error_body = json!({
253            "error": "Invalid Content-Type header"
254        });
255        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
256    }
257
258    let token = token_before_semicolon(content_type.as_bytes());
259    if !is_valid_content_type_token(token) {
260        let error_body = json!({
261            "error": "Invalid Content-Type header"
262        });
263        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
264    }
265
266    let is_json = is_json_like_token(token);
267    let is_multipart = is_multipart_form_data_token(token);
268    let is_form = is_form_urlencoded_token(token);
269
270    if is_multipart && !multipart_has_boundary(content_type) {
271        let error_body = json!({
272            "error": "multipart/form-data requires 'boundary' parameter"
273        });
274        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
275    }
276
277    if is_json
278        && let Some(charset) = json_charset_value(content_type)
279        && !charset.eq_ignore_ascii_case(b"utf-8")
280        && !charset.eq_ignore_ascii_case(b"utf8")
281    {
282        let charset_str = String::from_utf8_lossy(charset);
283        let problem = ProblemDetails::new(
284            "https://spikard.dev/errors/unsupported-charset",
285            "Unsupported Charset",
286            StatusCode::UNSUPPORTED_MEDIA_TYPE,
287        )
288        .with_detail(format!(
289            "Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
290            charset_str
291        ));
292
293        let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
294        return Err((
295            StatusCode::UNSUPPORTED_MEDIA_TYPE,
296            [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
297            body,
298        )
299            .into_response());
300    }
301
302    let kind = if is_json {
303        ContentTypeKind::Json
304    } else if is_multipart {
305        ContentTypeKind::Multipart
306    } else if is_form {
307        ContentTypeKind::FormUrlencoded
308    } else {
309        ContentTypeKind::Other
310    };
311
312    Ok(Some(kind))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use axum::http::HeaderValue;
319
320    #[test]
321    fn validate_content_length_accepts_matching_sizes() {
322        let mut headers = HeaderMap::new();
323        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
324
325        assert!(validate_content_length(&headers, 5).is_ok());
326    }
327
328    #[test]
329    fn validate_content_length_rejects_mismatched_sizes() {
330        let mut headers = HeaderMap::new();
331        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
332
333        let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
334        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
335        assert_eq!(
336            err.headers()
337                .get(axum::http::header::CONTENT_TYPE)
338                .and_then(|value| value.to_str().ok()),
339            Some(CONTENT_TYPE_PROBLEM_JSON)
340        );
341    }
342
343    #[test]
344    fn test_multipart_without_boundary() {
345        let mut headers = HeaderMap::new();
346        headers.insert(
347            axum::http::header::CONTENT_TYPE,
348            HeaderValue::from_static("multipart/form-data"),
349        );
350
351        let result = validate_content_type_headers(&headers, 0);
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn test_multipart_with_boundary() {
357        let mut headers = HeaderMap::new();
358        headers.insert(
359            axum::http::header::CONTENT_TYPE,
360            HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
361        );
362
363        let result = validate_content_type_headers(&headers, 0);
364        assert!(result.is_ok());
365    }
366
367    #[test]
368    fn test_json_with_utf16_charset() {
369        let mut headers = HeaderMap::new();
370        headers.insert(
371            axum::http::header::CONTENT_TYPE,
372            HeaderValue::from_static("application/json; charset=utf-16"),
373        );
374
375        let result = validate_content_type_headers(&headers, 0);
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_json_with_utf8_charset() {
381        let mut headers = HeaderMap::new();
382        headers.insert(
383            axum::http::header::CONTENT_TYPE,
384            HeaderValue::from_static("application/json; charset=utf-8"),
385        );
386
387        let result = validate_content_type_headers(&headers, 0);
388        assert!(result.is_ok());
389    }
390
391    #[test]
392    fn test_json_without_charset() {
393        let mut headers = HeaderMap::new();
394        headers.insert(
395            axum::http::header::CONTENT_TYPE,
396            HeaderValue::from_static("application/json"),
397        );
398
399        let result = validate_content_type_headers(&headers, 0);
400        assert!(result.is_ok());
401    }
402
403    #[test]
404    fn test_vendor_json_accepted() {
405        let mut headers = HeaderMap::new();
406        headers.insert(
407            axum::http::header::CONTENT_TYPE,
408            HeaderValue::from_static("application/vnd.api+json"),
409        );
410
411        let result = validate_content_type_headers(&headers, 0);
412        assert!(result.is_ok());
413    }
414
415    #[test]
416    fn test_problem_json_accepted() {
417        let mut headers = HeaderMap::new();
418        headers.insert(
419            axum::http::header::CONTENT_TYPE,
420            HeaderValue::from_static("application/problem+json"),
421        );
422
423        let result = validate_content_type_headers(&headers, 0);
424        assert!(result.is_ok());
425    }
426
427    #[test]
428    fn test_vendor_json_with_utf16_charset_rejected() {
429        let mut headers = HeaderMap::new();
430        headers.insert(
431            axum::http::header::CONTENT_TYPE,
432            HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
433        );
434
435        let result = validate_content_type_headers(&headers, 0);
436        assert!(result.is_err());
437    }
438
439    #[test]
440    fn test_vendor_json_with_utf8_charset_accepted() {
441        let mut headers = HeaderMap::new();
442        headers.insert(
443            axum::http::header::CONTENT_TYPE,
444            HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
445        );
446
447        let result = validate_content_type_headers(&headers, 0);
448        assert!(result.is_ok());
449    }
450
451    #[test]
452    fn test_is_json_content_type() {
453        let mime = "application/json".parse::<mime::Mime>().unwrap();
454        assert!(is_json_content_type(&mime));
455
456        let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
457        assert!(is_json_content_type(&mime));
458
459        let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
460        assert!(is_json_content_type(&mime));
461
462        let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
463        assert!(is_json_content_type(&mime));
464
465        let mime = "text/plain".parse::<mime::Mime>().unwrap();
466        assert!(!is_json_content_type(&mime));
467
468        let mime = "application/xml".parse::<mime::Mime>().unwrap();
469        assert!(!is_json_content_type(&mime));
470
471        let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
472        assert!(!is_json_content_type(&mime));
473    }
474
475    #[test]
476    fn test_json_with_utf8_uppercase_charset() {
477        let mut headers = HeaderMap::new();
478        headers.insert(
479            axum::http::header::CONTENT_TYPE,
480            HeaderValue::from_static("application/json; charset=UTF-8"),
481        );
482
483        let result = validate_content_type_headers(&headers, 0);
484        assert!(result.is_ok(), "UTF-8 in uppercase should be accepted");
485    }
486
487    #[test]
488    fn test_json_with_utf8_no_hyphen_charset() {
489        let mut headers = HeaderMap::new();
490        headers.insert(
491            axum::http::header::CONTENT_TYPE,
492            HeaderValue::from_static("application/json; charset=utf8"),
493        );
494
495        let result = validate_content_type_headers(&headers, 0);
496        assert!(result.is_ok(), "utf8 without hyphen should be accepted");
497    }
498
499    #[test]
500    fn test_json_with_iso88591_charset_rejected() {
501        let mut headers = HeaderMap::new();
502        headers.insert(
503            axum::http::header::CONTENT_TYPE,
504            HeaderValue::from_static("application/json; charset=iso-8859-1"),
505        );
506
507        let result = validate_content_type_headers(&headers, 0);
508        assert!(result.is_err(), "iso-8859-1 should be rejected for JSON");
509    }
510
511    #[test]
512    fn test_json_with_utf32_charset_rejected() {
513        let mut headers = HeaderMap::new();
514        headers.insert(
515            axum::http::header::CONTENT_TYPE,
516            HeaderValue::from_static("application/json; charset=utf-32"),
517        );
518
519        let result = validate_content_type_headers(&headers, 0);
520        assert!(result.is_err(), "UTF-32 should be rejected for JSON");
521    }
522
523    #[test]
524    fn test_multipart_with_boundary_and_charset() {
525        let mut headers = HeaderMap::new();
526        headers.insert(
527            axum::http::header::CONTENT_TYPE,
528            HeaderValue::from_static("multipart/form-data; boundary=abc123; charset=utf-8"),
529        );
530
531        let result = validate_content_type_headers(&headers, 0);
532        assert!(
533            result.is_ok(),
534            "multipart with boundary should accept charset parameter"
535        );
536    }
537
538    #[test]
539    fn test_validate_content_length_no_header() {
540        let headers = HeaderMap::new();
541
542        let result = validate_content_length(&headers, 1024);
543        assert!(result.is_ok(), "Missing Content-Length header should pass");
544    }
545
546    #[test]
547    fn test_validate_content_length_zero_bytes() {
548        let mut headers = HeaderMap::new();
549        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("0"));
550
551        assert!(validate_content_length(&headers, 0).is_ok());
552    }
553
554    #[test]
555    fn test_validate_content_length_large_body() {
556        let mut headers = HeaderMap::new();
557        let large_size = 1024 * 1024 * 100;
558        headers.insert(
559            axum::http::header::CONTENT_LENGTH,
560            HeaderValue::from_str(&large_size.to_string()).unwrap(),
561        );
562
563        assert!(validate_content_length(&headers, large_size).is_ok());
564    }
565
566    #[test]
567    fn test_validate_content_length_invalid_header_format() {
568        let mut headers = HeaderMap::new();
569        headers.insert(
570            axum::http::header::CONTENT_LENGTH,
571            HeaderValue::from_static("not-a-number"),
572        );
573
574        let result = validate_content_length(&headers, 100);
575        assert!(
576            result.is_ok(),
577            "Invalid Content-Length format should be skipped gracefully"
578        );
579    }
580
581    #[test]
582    fn test_invalid_content_type_format() {
583        let mut headers = HeaderMap::new();
584        headers.insert(
585            axum::http::header::CONTENT_TYPE,
586            HeaderValue::from_static("not/a/valid/type"),
587        );
588
589        let result = validate_content_type_headers(&headers, 0);
590        assert!(result.is_err(), "Invalid mime type format should be rejected");
591    }
592
593    #[test]
594    fn test_unsupported_content_type_xml() {
595        let mut headers = HeaderMap::new();
596        headers.insert(
597            axum::http::header::CONTENT_TYPE,
598            HeaderValue::from_static("application/xml"),
599        );
600
601        let result = validate_content_type_headers(&headers, 0);
602        assert!(
603            result.is_ok(),
604            "XML should pass header validation (routing layer rejects if needed)"
605        );
606    }
607
608    #[test]
609    fn test_unsupported_content_type_plain_text() {
610        let mut headers = HeaderMap::new();
611        headers.insert(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
612
613        let result = validate_content_type_headers(&headers, 0);
614        assert!(result.is_ok(), "Plain text should pass header validation");
615    }
616
617    #[test]
618    fn test_content_type_with_boundary_missing_boundary_param() {
619        let mut headers = HeaderMap::new();
620        headers.insert(
621            axum::http::header::CONTENT_TYPE,
622            HeaderValue::from_static("multipart/form-data; charset=utf-8"),
623        );
624
625        let result = validate_content_type_headers(&headers, 0);
626        assert!(
627            result.is_err(),
628            "multipart/form-data without boundary parameter should be rejected"
629        );
630    }
631
632    #[test]
633    fn test_content_type_form_urlencoded() {
634        let mut headers = HeaderMap::new();
635        headers.insert(
636            axum::http::header::CONTENT_TYPE,
637            HeaderValue::from_static("application/x-www-form-urlencoded"),
638        );
639
640        let result = validate_content_type_headers(&headers, 0);
641        assert!(result.is_ok(), "form-urlencoded should be accepted");
642    }
643
644    #[test]
645    fn test_is_json_content_type_with_hal_json() {
646        let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
647        assert!(is_json_content_type(&mime), "HAL+JSON should be recognized as JSON");
648    }
649
650    #[test]
651    fn test_is_json_content_type_with_ld_json() {
652        let mime = "application/ld+json".parse::<mime::Mime>().unwrap();
653        assert!(is_json_content_type(&mime), "LD+JSON should be recognized as JSON");
654    }
655
656    #[test]
657    fn test_is_json_content_type_rejects_json_patch() {
658        let mime = "application/json-patch+json".parse::<mime::Mime>().unwrap();
659        assert!(is_json_content_type(&mime), "JSON-Patch should be recognized as JSON");
660    }
661
662    #[test]
663    fn test_is_json_content_type_rejects_html() {
664        let mime = "text/html".parse::<mime::Mime>().unwrap();
665        assert!(!is_json_content_type(&mime), "HTML should not be JSON");
666    }
667
668    #[test]
669    fn test_is_json_content_type_rejects_csv() {
670        let mime = "text/csv".parse::<mime::Mime>().unwrap();
671        assert!(!is_json_content_type(&mime), "CSV should not be JSON");
672    }
673
674    #[test]
675    fn test_is_json_content_type_rejects_image_png() {
676        let mime = "image/png".parse::<mime::Mime>().unwrap();
677        assert!(!is_json_content_type(&mime), "PNG should not be JSON");
678    }
679
680    #[test]
681    fn test_validate_json_content_type_missing_header() {
682        let headers = HeaderMap::new();
683        let result = validate_json_content_type(&headers);
684        assert!(
685            result.is_ok(),
686            "Missing Content-Type for JSON route should be OK (routing layer handles)"
687        );
688    }
689
690    #[test]
691    fn test_validate_json_content_type_accepts_form_urlencoded() {
692        let mut headers = HeaderMap::new();
693        headers.insert(
694            axum::http::header::CONTENT_TYPE,
695            HeaderValue::from_static("application/x-www-form-urlencoded"),
696        );
697
698        let result = validate_json_content_type(&headers);
699        assert!(result.is_ok(), "Form-urlencoded should be accepted for JSON routes");
700    }
701
702    #[test]
703    fn test_validate_json_content_type_accepts_multipart() {
704        let mut headers = HeaderMap::new();
705        headers.insert(
706            axum::http::header::CONTENT_TYPE,
707            HeaderValue::from_static("multipart/form-data; boundary=abc123"),
708        );
709
710        let result = validate_json_content_type(&headers);
711        assert!(result.is_ok(), "Multipart should be accepted for JSON routes");
712    }
713
714    #[test]
715    fn test_validate_json_content_type_rejects_xml() {
716        let mut headers = HeaderMap::new();
717        headers.insert(
718            axum::http::header::CONTENT_TYPE,
719            HeaderValue::from_static("application/xml"),
720        );
721
722        let result = validate_json_content_type(&headers);
723        assert!(result.is_err(), "XML should be rejected for JSON-expecting routes");
724        assert_eq!(
725            result.unwrap_err().status(),
726            StatusCode::UNSUPPORTED_MEDIA_TYPE,
727            "Should return 415 Unsupported Media Type"
728        );
729    }
730
731    #[test]
732    fn test_content_type_with_multiple_parameters() {
733        let mut headers = HeaderMap::new();
734        headers.insert(
735            axum::http::header::CONTENT_TYPE,
736            HeaderValue::from_static("application/json; charset=utf-8; boundary=xyz"),
737        );
738
739        let result = validate_content_type_headers(&headers, 0);
740        assert!(result.is_ok(), "Multiple parameters should be parsed correctly");
741    }
742
743    #[test]
744    fn test_content_type_with_quoted_parameter() {
745        let mut headers = HeaderMap::new();
746        headers.insert(
747            axum::http::header::CONTENT_TYPE,
748            HeaderValue::from_static(r#"multipart/form-data; boundary="----WebKitFormBoundary""#),
749        );
750
751        let result = validate_content_type_headers(&headers, 0);
752        assert!(result.is_ok(), "Quoted boundary parameter should be handled");
753    }
754
755    #[test]
756    fn test_content_type_case_insensitive_type() {
757        let mut headers = HeaderMap::new();
758        headers.insert(
759            axum::http::header::CONTENT_TYPE,
760            HeaderValue::from_static("Application/JSON"),
761        );
762
763        let result = validate_content_type_headers(&headers, 0);
764        assert!(result.is_ok(), "Content-Type type/subtype should be case-insensitive");
765    }
766}