Skip to main content

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            let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
183            return Err((
184                StatusCode::UNSUPPORTED_MEDIA_TYPE,
185                [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
186                body,
187            )
188                .into_response());
189        }
190    }
191    Ok(())
192}
193
194/// Validate Content-Length header matches actual body size
195#[allow(clippy::result_large_err, clippy::collapsible_if)]
196pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
197    if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
198        let Some(declared_length) = parse_ascii_usize(content_length_header.as_bytes()) else {
199            return Ok(());
200        };
201        if declared_length != actual_size {
202            let problem = ProblemDetails::new(
203                "https://spikard.dev/errors/content-length-mismatch",
204                "Content-Length header mismatch",
205                StatusCode::BAD_REQUEST,
206            )
207            .with_detail("Content-Length header does not match actual body size");
208            let body = problem.to_json().unwrap_or_else(|_| {
209                json!({"error": "Content-Length header does not match actual body size"}).to_string()
210            });
211            return Err((
212                StatusCode::BAD_REQUEST,
213                [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
214                body,
215            )
216                .into_response());
217        }
218    }
219    Ok(())
220}
221
222fn parse_ascii_usize(bytes: &[u8]) -> Option<usize> {
223    if bytes.is_empty() {
224        return None;
225    }
226    let mut value: usize = 0;
227    for &b in bytes {
228        if !b.is_ascii_digit() {
229            return None;
230        }
231        value = value.saturating_mul(10).saturating_add((b - b'0') as usize);
232    }
233    Some(value)
234}
235
236/// Validate Content-Type header and related requirements
237#[allow(clippy::result_large_err)]
238pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
239    validate_content_type_headers_and_classify(headers, _declared_body_size).map(|_| ())
240}
241
242/// Validate Content-Type and return its classification (if present).
243#[allow(clippy::result_large_err)]
244pub fn validate_content_type_headers_and_classify(
245    headers: &HeaderMap,
246    _declared_body_size: usize,
247) -> Result<Option<ContentTypeKind>, Response> {
248    let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) else {
249        return Ok(None);
250    };
251
252    if !content_type.as_bytes().is_ascii() && content_type.to_str().is_err() {
253        // Keep legacy behavior: invalid bytes should fail fast.
254        let error_body = json!({
255            "error": "Invalid Content-Type header"
256        });
257        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
258    }
259
260    let token = token_before_semicolon(content_type.as_bytes());
261    if !is_valid_content_type_token(token) {
262        let error_body = json!({
263            "error": "Invalid Content-Type header"
264        });
265        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
266    }
267
268    let is_json = is_json_like_token(token);
269    let is_multipart = is_multipart_form_data_token(token);
270    let is_form = is_form_urlencoded_token(token);
271
272    if is_multipart && !multipart_has_boundary(content_type) {
273        let error_body = json!({
274            "error": "multipart/form-data requires 'boundary' parameter"
275        });
276        return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
277    }
278
279    if is_json
280        && let Some(charset) = json_charset_value(content_type)
281        && !charset.eq_ignore_ascii_case(b"utf-8")
282        && !charset.eq_ignore_ascii_case(b"utf8")
283    {
284        let charset_str = String::from_utf8_lossy(charset);
285        let problem = ProblemDetails::new(
286            "https://spikard.dev/errors/unsupported-charset",
287            "Unsupported Charset",
288            StatusCode::UNSUPPORTED_MEDIA_TYPE,
289        )
290        .with_detail(format!(
291            "Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
292            charset_str
293        ));
294
295        let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
296        return Err((
297            StatusCode::UNSUPPORTED_MEDIA_TYPE,
298            [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
299            body,
300        )
301            .into_response());
302    }
303
304    let kind = if is_json {
305        ContentTypeKind::Json
306    } else if is_multipart {
307        ContentTypeKind::Multipart
308    } else if is_form {
309        ContentTypeKind::FormUrlencoded
310    } else {
311        ContentTypeKind::Other
312    };
313
314    Ok(Some(kind))
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use axum::http::HeaderValue;
321
322    #[test]
323    fn validate_content_length_accepts_matching_sizes() {
324        let mut headers = HeaderMap::new();
325        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
326
327        assert!(validate_content_length(&headers, 5).is_ok());
328    }
329
330    #[test]
331    fn validate_content_length_rejects_mismatched_sizes() {
332        let mut headers = HeaderMap::new();
333        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
334
335        let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
336        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
337        assert_eq!(
338            err.headers()
339                .get(axum::http::header::CONTENT_TYPE)
340                .and_then(|value| value.to_str().ok()),
341            Some(CONTENT_TYPE_PROBLEM_JSON)
342        );
343    }
344
345    #[test]
346    fn test_multipart_without_boundary() {
347        let mut headers = HeaderMap::new();
348        headers.insert(
349            axum::http::header::CONTENT_TYPE,
350            HeaderValue::from_static("multipart/form-data"),
351        );
352
353        let result = validate_content_type_headers(&headers, 0);
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_multipart_with_boundary() {
359        let mut headers = HeaderMap::new();
360        headers.insert(
361            axum::http::header::CONTENT_TYPE,
362            HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
363        );
364
365        let result = validate_content_type_headers(&headers, 0);
366        assert!(result.is_ok());
367    }
368
369    #[test]
370    fn test_json_with_utf16_charset() {
371        let mut headers = HeaderMap::new();
372        headers.insert(
373            axum::http::header::CONTENT_TYPE,
374            HeaderValue::from_static("application/json; charset=utf-16"),
375        );
376
377        let result = validate_content_type_headers(&headers, 0);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn test_json_with_utf8_charset() {
383        let mut headers = HeaderMap::new();
384        headers.insert(
385            axum::http::header::CONTENT_TYPE,
386            HeaderValue::from_static("application/json; charset=utf-8"),
387        );
388
389        let result = validate_content_type_headers(&headers, 0);
390        assert!(result.is_ok());
391    }
392
393    #[test]
394    fn test_json_without_charset() {
395        let mut headers = HeaderMap::new();
396        headers.insert(
397            axum::http::header::CONTENT_TYPE,
398            HeaderValue::from_static("application/json"),
399        );
400
401        let result = validate_content_type_headers(&headers, 0);
402        assert!(result.is_ok());
403    }
404
405    #[test]
406    fn test_vendor_json_accepted() {
407        let mut headers = HeaderMap::new();
408        headers.insert(
409            axum::http::header::CONTENT_TYPE,
410            HeaderValue::from_static("application/vnd.api+json"),
411        );
412
413        let result = validate_content_type_headers(&headers, 0);
414        assert!(result.is_ok());
415    }
416
417    #[test]
418    fn test_problem_json_accepted() {
419        let mut headers = HeaderMap::new();
420        headers.insert(
421            axum::http::header::CONTENT_TYPE,
422            HeaderValue::from_static("application/problem+json"),
423        );
424
425        let result = validate_content_type_headers(&headers, 0);
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    fn test_vendor_json_with_utf16_charset_rejected() {
431        let mut headers = HeaderMap::new();
432        headers.insert(
433            axum::http::header::CONTENT_TYPE,
434            HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
435        );
436
437        let result = validate_content_type_headers(&headers, 0);
438        assert!(result.is_err());
439    }
440
441    #[test]
442    fn test_vendor_json_with_utf8_charset_accepted() {
443        let mut headers = HeaderMap::new();
444        headers.insert(
445            axum::http::header::CONTENT_TYPE,
446            HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
447        );
448
449        let result = validate_content_type_headers(&headers, 0);
450        assert!(result.is_ok());
451    }
452
453    #[test]
454    fn test_is_json_content_type() {
455        let mime = "application/json".parse::<mime::Mime>().unwrap();
456        assert!(is_json_content_type(&mime));
457
458        let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
459        assert!(is_json_content_type(&mime));
460
461        let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
462        assert!(is_json_content_type(&mime));
463
464        let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
465        assert!(is_json_content_type(&mime));
466
467        let mime = "text/plain".parse::<mime::Mime>().unwrap();
468        assert!(!is_json_content_type(&mime));
469
470        let mime = "application/xml".parse::<mime::Mime>().unwrap();
471        assert!(!is_json_content_type(&mime));
472
473        let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
474        assert!(!is_json_content_type(&mime));
475    }
476
477    #[test]
478    fn test_json_with_utf8_uppercase_charset() {
479        let mut headers = HeaderMap::new();
480        headers.insert(
481            axum::http::header::CONTENT_TYPE,
482            HeaderValue::from_static("application/json; charset=UTF-8"),
483        );
484
485        let result = validate_content_type_headers(&headers, 0);
486        assert!(result.is_ok(), "UTF-8 in uppercase should be accepted");
487    }
488
489    #[test]
490    fn test_json_with_utf8_no_hyphen_charset() {
491        let mut headers = HeaderMap::new();
492        headers.insert(
493            axum::http::header::CONTENT_TYPE,
494            HeaderValue::from_static("application/json; charset=utf8"),
495        );
496
497        let result = validate_content_type_headers(&headers, 0);
498        assert!(result.is_ok(), "utf8 without hyphen should be accepted");
499    }
500
501    #[test]
502    fn test_json_with_iso88591_charset_rejected() {
503        let mut headers = HeaderMap::new();
504        headers.insert(
505            axum::http::header::CONTENT_TYPE,
506            HeaderValue::from_static("application/json; charset=iso-8859-1"),
507        );
508
509        let result = validate_content_type_headers(&headers, 0);
510        assert!(result.is_err(), "iso-8859-1 should be rejected for JSON");
511    }
512
513    #[test]
514    fn test_json_with_utf32_charset_rejected() {
515        let mut headers = HeaderMap::new();
516        headers.insert(
517            axum::http::header::CONTENT_TYPE,
518            HeaderValue::from_static("application/json; charset=utf-32"),
519        );
520
521        let result = validate_content_type_headers(&headers, 0);
522        assert!(result.is_err(), "UTF-32 should be rejected for JSON");
523    }
524
525    #[test]
526    fn test_multipart_with_boundary_and_charset() {
527        let mut headers = HeaderMap::new();
528        headers.insert(
529            axum::http::header::CONTENT_TYPE,
530            HeaderValue::from_static("multipart/form-data; boundary=abc123; charset=utf-8"),
531        );
532
533        let result = validate_content_type_headers(&headers, 0);
534        assert!(
535            result.is_ok(),
536            "multipart with boundary should accept charset parameter"
537        );
538    }
539
540    #[test]
541    fn test_validate_content_length_no_header() {
542        let headers = HeaderMap::new();
543
544        let result = validate_content_length(&headers, 1024);
545        assert!(result.is_ok(), "Missing Content-Length header should pass");
546    }
547
548    #[test]
549    fn test_validate_content_length_zero_bytes() {
550        let mut headers = HeaderMap::new();
551        headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("0"));
552
553        assert!(validate_content_length(&headers, 0).is_ok());
554    }
555
556    #[test]
557    fn test_validate_content_length_large_body() {
558        let mut headers = HeaderMap::new();
559        let large_size = 1024 * 1024 * 100;
560        headers.insert(
561            axum::http::header::CONTENT_LENGTH,
562            HeaderValue::from_str(&large_size.to_string()).unwrap(),
563        );
564
565        assert!(validate_content_length(&headers, large_size).is_ok());
566    }
567
568    #[test]
569    fn test_validate_content_length_invalid_header_format() {
570        let mut headers = HeaderMap::new();
571        headers.insert(
572            axum::http::header::CONTENT_LENGTH,
573            HeaderValue::from_static("not-a-number"),
574        );
575
576        let result = validate_content_length(&headers, 100);
577        assert!(
578            result.is_ok(),
579            "Invalid Content-Length format should be skipped gracefully"
580        );
581    }
582
583    #[test]
584    fn test_invalid_content_type_format() {
585        let mut headers = HeaderMap::new();
586        headers.insert(
587            axum::http::header::CONTENT_TYPE,
588            HeaderValue::from_static("not/a/valid/type"),
589        );
590
591        let result = validate_content_type_headers(&headers, 0);
592        assert!(result.is_err(), "Invalid mime type format should be rejected");
593    }
594
595    #[test]
596    fn test_unsupported_content_type_xml() {
597        let mut headers = HeaderMap::new();
598        headers.insert(
599            axum::http::header::CONTENT_TYPE,
600            HeaderValue::from_static("application/xml"),
601        );
602
603        let result = validate_content_type_headers(&headers, 0);
604        assert!(
605            result.is_ok(),
606            "XML should pass header validation (routing layer rejects if needed)"
607        );
608    }
609
610    #[test]
611    fn test_unsupported_content_type_plain_text() {
612        let mut headers = HeaderMap::new();
613        headers.insert(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
614
615        let result = validate_content_type_headers(&headers, 0);
616        assert!(result.is_ok(), "Plain text should pass header validation");
617    }
618
619    #[test]
620    fn test_content_type_with_boundary_missing_boundary_param() {
621        let mut headers = HeaderMap::new();
622        headers.insert(
623            axum::http::header::CONTENT_TYPE,
624            HeaderValue::from_static("multipart/form-data; charset=utf-8"),
625        );
626
627        let result = validate_content_type_headers(&headers, 0);
628        assert!(
629            result.is_err(),
630            "multipart/form-data without boundary parameter should be rejected"
631        );
632    }
633
634    #[test]
635    fn test_content_type_form_urlencoded() {
636        let mut headers = HeaderMap::new();
637        headers.insert(
638            axum::http::header::CONTENT_TYPE,
639            HeaderValue::from_static("application/x-www-form-urlencoded"),
640        );
641
642        let result = validate_content_type_headers(&headers, 0);
643        assert!(result.is_ok(), "form-urlencoded should be accepted");
644    }
645
646    #[test]
647    fn test_is_json_content_type_with_hal_json() {
648        let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
649        assert!(is_json_content_type(&mime), "HAL+JSON should be recognized as JSON");
650    }
651
652    #[test]
653    fn test_is_json_content_type_with_ld_json() {
654        let mime = "application/ld+json".parse::<mime::Mime>().unwrap();
655        assert!(is_json_content_type(&mime), "LD+JSON should be recognized as JSON");
656    }
657
658    #[test]
659    fn test_is_json_content_type_rejects_json_patch() {
660        let mime = "application/json-patch+json".parse::<mime::Mime>().unwrap();
661        assert!(is_json_content_type(&mime), "JSON-Patch should be recognized as JSON");
662    }
663
664    #[test]
665    fn test_is_json_content_type_rejects_html() {
666        let mime = "text/html".parse::<mime::Mime>().unwrap();
667        assert!(!is_json_content_type(&mime), "HTML should not be JSON");
668    }
669
670    #[test]
671    fn test_is_json_content_type_rejects_csv() {
672        let mime = "text/csv".parse::<mime::Mime>().unwrap();
673        assert!(!is_json_content_type(&mime), "CSV should not be JSON");
674    }
675
676    #[test]
677    fn test_is_json_content_type_rejects_image_png() {
678        let mime = "image/png".parse::<mime::Mime>().unwrap();
679        assert!(!is_json_content_type(&mime), "PNG should not be JSON");
680    }
681
682    #[test]
683    fn test_validate_json_content_type_missing_header() {
684        let headers = HeaderMap::new();
685        let result = validate_json_content_type(&headers);
686        assert!(
687            result.is_ok(),
688            "Missing Content-Type for JSON route should be OK (routing layer handles)"
689        );
690    }
691
692    #[test]
693    fn test_validate_json_content_type_accepts_form_urlencoded() {
694        let mut headers = HeaderMap::new();
695        headers.insert(
696            axum::http::header::CONTENT_TYPE,
697            HeaderValue::from_static("application/x-www-form-urlencoded"),
698        );
699
700        let result = validate_json_content_type(&headers);
701        assert!(result.is_ok(), "Form-urlencoded should be accepted for JSON routes");
702    }
703
704    #[test]
705    fn test_validate_json_content_type_accepts_multipart() {
706        let mut headers = HeaderMap::new();
707        headers.insert(
708            axum::http::header::CONTENT_TYPE,
709            HeaderValue::from_static("multipart/form-data; boundary=abc123"),
710        );
711
712        let result = validate_json_content_type(&headers);
713        assert!(result.is_ok(), "Multipart should be accepted for JSON routes");
714    }
715
716    #[test]
717    fn test_validate_json_content_type_rejects_xml() {
718        let mut headers = HeaderMap::new();
719        headers.insert(
720            axum::http::header::CONTENT_TYPE,
721            HeaderValue::from_static("application/xml"),
722        );
723
724        let result = validate_json_content_type(&headers);
725        assert!(result.is_err(), "XML should be rejected for JSON-expecting routes");
726        assert_eq!(
727            result.unwrap_err().status(),
728            StatusCode::UNSUPPORTED_MEDIA_TYPE,
729            "Should return 415 Unsupported Media Type"
730        );
731    }
732
733    #[test]
734    fn test_content_type_with_multiple_parameters() {
735        let mut headers = HeaderMap::new();
736        headers.insert(
737            axum::http::header::CONTENT_TYPE,
738            HeaderValue::from_static("application/json; charset=utf-8; boundary=xyz"),
739        );
740
741        let result = validate_content_type_headers(&headers, 0);
742        assert!(result.is_ok(), "Multiple parameters should be parsed correctly");
743    }
744
745    #[test]
746    fn test_content_type_with_quoted_parameter() {
747        let mut headers = HeaderMap::new();
748        headers.insert(
749            axum::http::header::CONTENT_TYPE,
750            HeaderValue::from_static(r#"multipart/form-data; boundary="----WebKitFormBoundary""#),
751        );
752
753        let result = validate_content_type_headers(&headers, 0);
754        assert!(result.is_ok(), "Quoted boundary parameter should be handled");
755    }
756
757    #[test]
758    fn test_content_type_case_insensitive_type() {
759        let mut headers = HeaderMap::new();
760        headers.insert(
761            axum::http::header::CONTENT_TYPE,
762            HeaderValue::from_static("Application/JSON"),
763        );
764
765        let result = validate_content_type_headers(&headers, 0);
766        assert!(result.is_ok(), "Content-Type type/subtype should be case-insensitive");
767    }
768}