Skip to main content

armature_core/
content_negotiation.rs

1//! Content negotiation for HTTP requests.
2//!
3//! This module provides support for HTTP content negotiation, allowing servers
4//! to serve different representations of a resource based on client preferences.
5//!
6//! # Supported Headers
7//!
8//! - `Accept` - Media type negotiation (e.g., `application/json`, `text/html`)
9//! - `Accept-Language` - Language negotiation (e.g., `en-US`, `fr`)
10//! - `Accept-Charset` - Character set negotiation (e.g., `utf-8`, `iso-8859-1`)
11//! - `Accept-Encoding` - Encoding negotiation (e.g., `gzip`, `br`)
12//!
13//! # Examples
14//!
15//! ```
16//! use armature_core::content_negotiation::{Accept, MediaType, negotiate_media_type};
17//!
18//! // Parse Accept header
19//! let accept = Accept::parse("application/json, text/html;q=0.9, */*;q=0.1");
20//!
21//! // Negotiate best media type from available options
22//! let available = vec![
23//!     MediaType::json(),
24//!     MediaType::html(),
25//!     MediaType::xml(),
26//! ];
27//! let best = negotiate_media_type(&accept, &available);
28//! assert_eq!(best, Some(&MediaType::json()));
29//! ```
30
31use crate::{Error, HttpRequest, HttpResponse};
32use bytes::Bytes;
33use serde::Serialize;
34use std::cmp::Ordering;
35use std::collections::HashMap;
36use std::fmt;
37
38// ============================================================================
39// Media Types
40// ============================================================================
41
42/// Represents a media type (MIME type) with optional parameters.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct MediaType {
45    /// The type (e.g., "application", "text", "image")
46    pub type_: String,
47    /// The subtype (e.g., "json", "html", "png")
48    pub subtype: String,
49    /// Optional parameters (e.g., charset=utf-8)
50    pub params: HashMap<String, String>,
51}
52
53impl MediaType {
54    /// Create a new media type.
55    pub fn new(type_: impl Into<String>, subtype: impl Into<String>) -> Self {
56        Self {
57            type_: type_.into(),
58            subtype: subtype.into(),
59            params: HashMap::new(),
60        }
61    }
62
63    /// Create a media type with a parameter.
64    pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
65        self.params.insert(key.into(), value.into());
66        self
67    }
68
69    /// Create `application/json` media type.
70    pub fn json() -> Self {
71        Self::new("application", "json")
72    }
73
74    /// Create `text/html` media type.
75    pub fn html() -> Self {
76        Self::new("text", "html")
77    }
78
79    /// Create `text/plain` media type.
80    pub fn plain_text() -> Self {
81        Self::new("text", "plain")
82    }
83
84    /// Create `application/xml` media type.
85    pub fn xml() -> Self {
86        Self::new("application", "xml")
87    }
88
89    /// Create `text/xml` media type.
90    pub fn text_xml() -> Self {
91        Self::new("text", "xml")
92    }
93
94    /// Create `application/x-www-form-urlencoded` media type.
95    pub fn form_urlencoded() -> Self {
96        Self::new("application", "x-www-form-urlencoded")
97    }
98
99    /// Create `multipart/form-data` media type.
100    pub fn multipart_form_data() -> Self {
101        Self::new("multipart", "form-data")
102    }
103
104    /// Create `application/octet-stream` media type.
105    pub fn octet_stream() -> Self {
106        Self::new("application", "octet-stream")
107    }
108
109    /// Create `*/*` wildcard media type.
110    pub fn any() -> Self {
111        Self::new("*", "*")
112    }
113
114    /// Parse a media type from a string (without quality value).
115    pub fn parse(s: &str) -> Option<Self> {
116        let s = s.trim();
117        let mut parts = s.split(';');
118
119        let type_subtype = parts.next()?.trim();
120        let mut type_parts = type_subtype.splitn(2, '/');
121
122        let type_ = type_parts.next()?.trim().to_lowercase();
123        let subtype = type_parts.next()?.trim().to_lowercase();
124
125        let mut params = HashMap::new();
126        for param in parts {
127            let param = param.trim();
128            if let Some((key, value)) = param.split_once('=') {
129                let key = key.trim().to_lowercase();
130                let value = value.trim().trim_matches('"').to_string();
131                // Skip quality parameter
132                if key != "q" {
133                    params.insert(key, value);
134                }
135            }
136        }
137
138        Some(Self {
139            type_,
140            subtype,
141            params,
142        })
143    }
144
145    /// Check if this media type matches another (considering wildcards).
146    pub fn matches(&self, other: &MediaType) -> bool {
147        let type_matches = self.type_ == "*" || other.type_ == "*" || self.type_ == other.type_;
148        let subtype_matches =
149            self.subtype == "*" || other.subtype == "*" || self.subtype == other.subtype;
150        type_matches && subtype_matches
151    }
152
153    /// Check if this is a wildcard type (`*/*`).
154    pub fn is_any(&self) -> bool {
155        self.type_ == "*" && self.subtype == "*"
156    }
157
158    /// Check if the type is a wildcard (`*/something`).
159    pub fn is_type_wildcard(&self) -> bool {
160        self.type_ == "*"
161    }
162
163    /// Check if the subtype is a wildcard (`something/*`).
164    pub fn is_subtype_wildcard(&self) -> bool {
165        self.subtype == "*"
166    }
167
168    /// Get the full MIME type string.
169    pub fn mime_type(&self) -> String {
170        format!("{}/{}", self.type_, self.subtype)
171    }
172
173    /// Get the full MIME type string with parameters.
174    pub fn to_header_value(&self) -> String {
175        let mut result = self.mime_type();
176        for (key, value) in &self.params {
177            result.push_str(&format!("; {}={}", key, value));
178        }
179        result
180    }
181}
182
183impl fmt::Display for MediaType {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(f, "{}", self.to_header_value())
186    }
187}
188
189/// Find the byte offset of a case-insensitive `;q=` in `s` without allocating.
190///
191/// Searching a lowercased copy would be incorrect: lowercasing can change the
192/// byte length of non-ASCII text, so offsets found in the copy may not be
193/// valid (or even char-aligned) in the original string.
194fn find_quality_param(s: &str) -> Option<usize> {
195    s.as_bytes()
196        .windows(3)
197        .position(|w| w[0] == b';' && w[1].eq_ignore_ascii_case(&b'q') && w[2] == b'=')
198}
199
200// ============================================================================
201// Accept Header
202// ============================================================================
203
204/// Represents a parsed `Accept` header with quality values.
205#[derive(Debug, Clone)]
206pub struct Accept {
207    /// Media types with their quality values, sorted by preference.
208    pub media_types: Vec<(MediaType, f32)>,
209}
210
211impl Default for Accept {
212    /// A missing Accept header accepts anything, per the HTTP spec.
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl Accept {
219    /// Create an empty Accept header (accepts anything).
220    pub fn new() -> Self {
221        Self {
222            media_types: vec![(MediaType::any(), 1.0)],
223        }
224    }
225
226    /// Parse an Accept header string.
227    ///
228    /// # Example
229    ///
230    /// ```
231    /// use armature_core::content_negotiation::Accept;
232    ///
233    /// let accept = Accept::parse("application/json, text/html;q=0.9, */*;q=0.1");
234    /// assert_eq!(accept.media_types.len(), 3);
235    /// ```
236    pub fn parse(header: &str) -> Self {
237        let mut media_types: Vec<(MediaType, f32)> = header
238            .split(',')
239            .filter_map(|part| {
240                let part = part.trim();
241                if part.is_empty() {
242                    return None;
243                }
244
245                // Extract quality value
246                let (media_part, quality) = Self::extract_quality(part);
247
248                MediaType::parse(media_part).map(|mt| (mt, quality))
249            })
250            .collect();
251
252        // Sort by quality (highest first), then by specificity
253        media_types.sort_by(|a, b| {
254            // First compare by quality
255            match b.1.partial_cmp(&a.1) {
256                Some(Ordering::Equal) | None => {}
257                Some(ord) => return ord,
258            }
259
260            // Then by specificity (more specific = higher priority)
261            let a_specificity = Self::specificity(&a.0);
262            let b_specificity = Self::specificity(&b.0);
263            b_specificity.cmp(&a_specificity)
264        });
265
266        Self { media_types }
267    }
268
269    /// Extract quality value from a media type string.
270    fn extract_quality(s: &str) -> (&str, f32) {
271        // Find q= parameter
272        if let Some(q_pos) = find_quality_param(s) {
273            let media_part = &s[..q_pos];
274            let q_part = &s[q_pos + 3..];
275
276            // Parse quality value
277            let quality = q_part
278                .split(';')
279                .next()
280                .and_then(|q| q.trim().parse::<f32>().ok())
281                .unwrap_or(1.0)
282                .clamp(0.0, 1.0);
283
284            (media_part, quality)
285        } else {
286            (s, 1.0)
287        }
288    }
289
290    /// Calculate specificity of a media type.
291    fn specificity(mt: &MediaType) -> u8 {
292        let mut score = 0u8;
293        if mt.type_ != "*" {
294            score += 2;
295        }
296        if mt.subtype != "*" {
297            score += 1;
298        }
299        score
300    }
301
302    /// Check if a media type is acceptable.
303    pub fn accepts(&self, media_type: &MediaType) -> bool {
304        self.quality_for(media_type) > 0.0
305    }
306
307    /// Get the quality value for a specific media type.
308    pub fn quality_for(&self, media_type: &MediaType) -> f32 {
309        for (mt, quality) in &self.media_types {
310            if mt.matches(media_type) {
311                return *quality;
312            }
313        }
314        0.0
315    }
316
317    /// Get the preferred media type from this Accept header.
318    pub fn preferred(&self) -> Option<&MediaType> {
319        self.media_types.first().map(|(mt, _)| mt)
320    }
321
322    /// Check if JSON is preferred over HTML.
323    pub fn prefers_json(&self) -> bool {
324        self.quality_for(&MediaType::json()) > self.quality_for(&MediaType::html())
325    }
326
327    /// Check if HTML is preferred over JSON.
328    pub fn prefers_html(&self) -> bool {
329        self.quality_for(&MediaType::html()) > self.quality_for(&MediaType::json())
330    }
331}
332
333/// Negotiate the best media type from available options.
334///
335/// Returns the media type from `available` that best matches the client's
336/// preferences in `accept`.
337pub fn negotiate_media_type<'a>(
338    accept: &Accept,
339    available: &'a [MediaType],
340) -> Option<&'a MediaType> {
341    let mut best: Option<(&'a MediaType, f32, u8)> = None;
342
343    for available_mt in available {
344        let quality = accept.quality_for(available_mt);
345        if quality > 0.0 {
346            let specificity = Accept::specificity(available_mt);
347            match &best {
348                None => best = Some((available_mt, quality, specificity)),
349                Some((_, best_q, best_s)) => {
350                    if quality > *best_q || (quality == *best_q && specificity > *best_s) {
351                        best = Some((available_mt, quality, specificity));
352                    }
353                }
354            }
355        }
356    }
357
358    best.map(|(mt, _, _)| mt)
359}
360
361// ============================================================================
362// Accept-Language Header
363// ============================================================================
364
365/// Represents a language tag with quality value.
366#[derive(Debug, Clone, PartialEq)]
367pub struct LanguageTag {
368    /// The primary language (e.g., "en", "fr", "de")
369    pub primary: String,
370    /// Optional subtag (e.g., "US", "GB" for en-US, en-GB)
371    pub subtag: Option<String>,
372}
373
374impl LanguageTag {
375    /// Create a new language tag.
376    pub fn new(primary: impl Into<String>) -> Self {
377        Self {
378            primary: primary.into().to_lowercase(),
379            subtag: None,
380        }
381    }
382
383    /// Create a language tag with subtag.
384    pub fn with_subtag(primary: impl Into<String>, subtag: impl Into<String>) -> Self {
385        Self {
386            primary: primary.into().to_lowercase(),
387            subtag: Some(subtag.into().to_uppercase()),
388        }
389    }
390
391    /// Parse a language tag from a string.
392    pub fn parse(s: &str) -> Option<Self> {
393        let s = s.trim();
394        if s.is_empty() || s == "*" {
395            return Some(Self::new("*"));
396        }
397
398        let mut parts = s.splitn(2, '-');
399        let primary = parts.next()?.trim().to_lowercase();
400        let subtag = parts.next().map(|s| s.trim().to_uppercase());
401
402        Some(Self { primary, subtag })
403    }
404
405    /// Check if this tag matches another (considering wildcards).
406    pub fn matches(&self, other: &LanguageTag) -> bool {
407        if self.primary == "*" || other.primary == "*" {
408            return true;
409        }
410        if self.primary != other.primary {
411            return false;
412        }
413        // If we have a subtag, it must match
414        match (&self.subtag, &other.subtag) {
415            (Some(a), Some(b)) => a == b,
416            (None, _) => true,        // "en" matches "en-US"
417            (Some(_), None) => false, // "en-US" doesn't match just "en"
418        }
419    }
420}
421
422impl fmt::Display for LanguageTag {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        match &self.subtag {
425            Some(sub) => write!(f, "{}-{}", self.primary, sub),
426            None => write!(f, "{}", self.primary),
427        }
428    }
429}
430
431/// Represents a parsed `Accept-Language` header.
432#[derive(Debug, Clone, Default)]
433pub struct AcceptLanguage {
434    /// Language tags with their quality values, sorted by preference.
435    pub languages: Vec<(LanguageTag, f32)>,
436}
437
438impl AcceptLanguage {
439    /// Parse an Accept-Language header string.
440    ///
441    /// # Example
442    ///
443    /// ```
444    /// use armature_core::content_negotiation::AcceptLanguage;
445    ///
446    /// let accept = AcceptLanguage::parse("en-US, en;q=0.9, fr;q=0.8, *;q=0.1");
447    /// assert_eq!(accept.languages.len(), 4);
448    /// ```
449    pub fn parse(header: &str) -> Self {
450        let mut languages: Vec<(LanguageTag, f32)> = header
451            .split(',')
452            .filter_map(|part| {
453                let part = part.trim();
454                if part.is_empty() {
455                    return None;
456                }
457
458                let (lang_part, quality) = Self::extract_quality(part);
459                LanguageTag::parse(lang_part).map(|lt| (lt, quality))
460            })
461            .collect();
462
463        // Sort by quality (highest first)
464        languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
465
466        Self { languages }
467    }
468
469    fn extract_quality(s: &str) -> (&str, f32) {
470        if let Some(q_pos) = find_quality_param(s) {
471            let lang_part = &s[..q_pos];
472            let q_part = &s[q_pos + 3..];
473
474            let quality = q_part.trim().parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
475
476            (lang_part, quality)
477        } else {
478            (s, 1.0)
479        }
480    }
481
482    /// Get the quality value for a specific language.
483    pub fn quality_for(&self, language: &LanguageTag) -> f32 {
484        for (lt, quality) in &self.languages {
485            if lt.matches(language) {
486                return *quality;
487            }
488        }
489        0.0
490    }
491
492    /// Get the preferred language.
493    pub fn preferred(&self) -> Option<&LanguageTag> {
494        self.languages.first().map(|(lt, _)| lt)
495    }
496}
497
498/// Negotiate the best language from available options.
499pub fn negotiate_language<'a>(
500    accept: &AcceptLanguage,
501    available: &'a [LanguageTag],
502) -> Option<&'a LanguageTag> {
503    let mut best: Option<(&'a LanguageTag, f32)> = None;
504
505    for available_lt in available {
506        let quality = accept.quality_for(available_lt);
507        if quality > 0.0 {
508            match &best {
509                None => best = Some((available_lt, quality)),
510                Some((_, best_q)) if quality > *best_q => {
511                    best = Some((available_lt, quality));
512                }
513                _ => {}
514            }
515        }
516    }
517
518    best.map(|(lt, _)| lt)
519}
520
521// ============================================================================
522// Accept-Encoding Header
523// ============================================================================
524
525/// Supported content encodings.
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
527pub enum Encoding {
528    /// Gzip compression
529    Gzip,
530    /// Deflate compression
531    Deflate,
532    /// Brotli compression
533    Brotli,
534    /// Zstandard compression
535    Zstd,
536    /// No encoding (identity)
537    Identity,
538}
539
540impl Encoding {
541    /// Parse an encoding from a string.
542    pub fn parse(s: &str) -> Option<Self> {
543        match s.trim().to_lowercase().as_str() {
544            "gzip" | "x-gzip" => Some(Self::Gzip),
545            "deflate" => Some(Self::Deflate),
546            "br" => Some(Self::Brotli),
547            "zstd" => Some(Self::Zstd),
548            "identity" => Some(Self::Identity),
549            _ => None,
550        }
551    }
552
553    /// Get the header value for this encoding.
554    pub fn to_header_value(&self) -> &'static str {
555        match self {
556            Self::Gzip => "gzip",
557            Self::Deflate => "deflate",
558            Self::Brotli => "br",
559            Self::Zstd => "zstd",
560            Self::Identity => "identity",
561        }
562    }
563}
564
565impl fmt::Display for Encoding {
566    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
567        write!(f, "{}", self.to_header_value())
568    }
569}
570
571/// Represents a parsed `Accept-Encoding` header.
572#[derive(Debug, Clone, Default)]
573pub struct AcceptEncoding {
574    /// Encodings with their quality values, sorted by preference.
575    pub encodings: Vec<(Encoding, f32)>,
576}
577
578impl AcceptEncoding {
579    /// Parse an Accept-Encoding header string.
580    ///
581    /// # Example
582    ///
583    /// ```
584    /// use armature_core::content_negotiation::AcceptEncoding;
585    ///
586    /// let accept = AcceptEncoding::parse("gzip, deflate, br;q=0.9");
587    /// assert_eq!(accept.encodings.len(), 3);
588    /// ```
589    pub fn parse(header: &str) -> Self {
590        let mut encodings: Vec<(Encoding, f32)> = header
591            .split(',')
592            .filter_map(|part| {
593                let part = part.trim();
594                if part.is_empty() {
595                    return None;
596                }
597
598                let (enc_part, quality) = Self::extract_quality(part);
599                Encoding::parse(enc_part).map(|enc| (enc, quality))
600            })
601            .collect();
602
603        // Sort by quality (highest first)
604        encodings.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
605
606        Self { encodings }
607    }
608
609    fn extract_quality(s: &str) -> (&str, f32) {
610        if let Some(q_pos) = find_quality_param(s) {
611            let enc_part = &s[..q_pos];
612            let q_part = &s[q_pos + 3..];
613
614            let quality = q_part.trim().parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
615
616            (enc_part, quality)
617        } else {
618            (s, 1.0)
619        }
620    }
621
622    /// Get the quality value for a specific encoding.
623    pub fn quality_for(&self, encoding: Encoding) -> f32 {
624        for (enc, quality) in &self.encodings {
625            if *enc == encoding {
626                return *quality;
627            }
628        }
629        0.0
630    }
631
632    /// Get the preferred encoding.
633    pub fn preferred(&self) -> Option<Encoding> {
634        self.encodings.first().map(|(enc, _)| *enc)
635    }
636
637    /// Check if an encoding is acceptable.
638    pub fn accepts(&self, encoding: Encoding) -> bool {
639        self.quality_for(encoding) > 0.0
640    }
641}
642
643/// Negotiate the best encoding from available options.
644pub fn negotiate_encoding(accept: &AcceptEncoding, available: &[Encoding]) -> Option<Encoding> {
645    let mut best: Option<(Encoding, f32)> = None;
646
647    for &enc in available {
648        let quality = accept.quality_for(enc);
649        if quality > 0.0 {
650            match &best {
651                None => best = Some((enc, quality)),
652                Some((_, best_q)) if quality > *best_q => {
653                    best = Some((enc, quality));
654                }
655                _ => {}
656            }
657        }
658    }
659
660    best.map(|(enc, _)| enc)
661}
662
663// ============================================================================
664// Accept-Charset Header
665// ============================================================================
666
667/// Represents a parsed `Accept-Charset` header.
668#[derive(Debug, Clone, Default)]
669pub struct AcceptCharset {
670    /// Charsets with their quality values, sorted by preference.
671    pub charsets: Vec<(String, f32)>,
672}
673
674impl AcceptCharset {
675    /// Parse an Accept-Charset header string.
676    pub fn parse(header: &str) -> Self {
677        let mut charsets: Vec<(String, f32)> = header
678            .split(',')
679            .filter_map(|part| {
680                let part = part.trim();
681                if part.is_empty() {
682                    return None;
683                }
684
685                let (charset_part, quality) = Self::extract_quality(part);
686                Some((charset_part.trim().to_lowercase(), quality))
687            })
688            .collect();
689
690        // Sort by quality (highest first)
691        charsets.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
692
693        Self { charsets }
694    }
695
696    fn extract_quality(s: &str) -> (&str, f32) {
697        if let Some(q_pos) = find_quality_param(s) {
698            let charset_part = &s[..q_pos];
699            let q_part = &s[q_pos + 3..];
700
701            let quality = q_part.trim().parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
702
703            (charset_part, quality)
704        } else {
705            (s, 1.0)
706        }
707    }
708
709    /// Get the quality value for a specific charset.
710    pub fn quality_for(&self, charset: &str) -> f32 {
711        let charset = charset.to_lowercase();
712        for (cs, quality) in &self.charsets {
713            if cs == &charset || cs == "*" {
714                return *quality;
715            }
716        }
717        // UTF-8 is acceptable by default per HTTP spec
718        if charset == "utf-8" {
719            return 1.0;
720        }
721        0.0
722    }
723
724    /// Get the preferred charset.
725    pub fn preferred(&self) -> Option<&str> {
726        self.charsets.first().map(|(cs, _)| cs.as_str())
727    }
728}
729
730// ============================================================================
731// HttpRequest Extensions
732// ============================================================================
733
734/// Extension methods for HttpRequest to support content negotiation.
735impl HttpRequest {
736    /// Get the Accept header parsed into media types.
737    pub fn accept(&self) -> Accept {
738        self.headers
739            .get("Accept")
740            .or_else(|| self.headers.get("accept"))
741            .map(Accept::parse)
742            .unwrap_or_default()
743    }
744
745    /// Get the Accept-Language header parsed into language tags.
746    pub fn accept_language(&self) -> AcceptLanguage {
747        self.headers
748            .get("Accept-Language")
749            .or_else(|| self.headers.get("accept-language"))
750            .map(AcceptLanguage::parse)
751            .unwrap_or_default()
752    }
753
754    /// Get the Accept-Encoding header parsed into encodings.
755    pub fn accept_encoding(&self) -> AcceptEncoding {
756        self.headers
757            .get("Accept-Encoding")
758            .or_else(|| self.headers.get("accept-encoding"))
759            .map(AcceptEncoding::parse)
760            .unwrap_or_default()
761    }
762
763    /// Get the Accept-Charset header parsed into charsets.
764    pub fn accept_charset(&self) -> AcceptCharset {
765        self.headers
766            .get("Accept-Charset")
767            .or_else(|| self.headers.get("accept-charset"))
768            .map(AcceptCharset::parse)
769            .unwrap_or_default()
770    }
771
772    /// Check if the client accepts a specific media type.
773    pub fn accepts(&self, media_type: &MediaType) -> bool {
774        self.accept().accepts(media_type)
775    }
776
777    /// Check if the client prefers JSON over HTML.
778    pub fn prefers_json(&self) -> bool {
779        self.accept().prefers_json()
780    }
781
782    /// Check if the client prefers HTML over JSON.
783    pub fn prefers_html(&self) -> bool {
784        self.accept().prefers_html()
785    }
786
787    /// Negotiate the best media type from available options.
788    pub fn negotiate_media_type<'a>(&self, available: &'a [MediaType]) -> Option<&'a MediaType> {
789        negotiate_media_type(&self.accept(), available)
790    }
791
792    /// Negotiate the best language from available options.
793    pub fn negotiate_language<'a>(&self, available: &'a [LanguageTag]) -> Option<&'a LanguageTag> {
794        negotiate_language(&self.accept_language(), available)
795    }
796
797    /// Negotiate the best encoding from available options.
798    pub fn negotiate_encoding(&self, available: &[Encoding]) -> Option<Encoding> {
799        negotiate_encoding(&self.accept_encoding(), available)
800    }
801}
802
803// ============================================================================
804// Content Negotiation Response Helper
805// ============================================================================
806
807/// Helper for building responses with content negotiation.
808///
809/// # Example
810///
811/// ```ignore
812/// use armature_core::content_negotiation::{ContentNegotiator, MediaType};
813///
814/// let negotiator = ContentNegotiator::new()
815///     .json(|| serde_json::json!({"message": "Hello"}))
816///     .html(|| "<h1>Hello</h1>".to_string())
817///     .plain_text(|| "Hello".to_string());
818///
819/// let response = negotiator.negotiate(&request)?;
820/// ```
821pub struct ContentNegotiator<J, H, T, X>
822where
823    J: FnOnce() -> serde_json::Value,
824    H: FnOnce() -> String,
825    T: FnOnce() -> String,
826    X: FnOnce() -> String,
827{
828    json_fn: Option<J>,
829    html_fn: Option<H>,
830    text_fn: Option<T>,
831    xml_fn: Option<X>,
832    default_media_type: MediaType,
833}
834
835impl ContentNegotiator<fn() -> serde_json::Value, fn() -> String, fn() -> String, fn() -> String> {
836    /// Create a new content negotiator with JSON as the default.
837    pub fn new() -> Self {
838        Self {
839            json_fn: None,
840            html_fn: None,
841            text_fn: None,
842            xml_fn: None,
843            default_media_type: MediaType::json(),
844        }
845    }
846}
847
848impl<J, H, T, X> ContentNegotiator<J, H, T, X>
849where
850    J: FnOnce() -> serde_json::Value,
851    H: FnOnce() -> String,
852    T: FnOnce() -> String,
853    X: FnOnce() -> String,
854{
855    /// Set the JSON response generator.
856    pub fn json<NJ: FnOnce() -> serde_json::Value>(self, f: NJ) -> ContentNegotiator<NJ, H, T, X> {
857        ContentNegotiator {
858            json_fn: Some(f),
859            html_fn: self.html_fn,
860            text_fn: self.text_fn,
861            xml_fn: self.xml_fn,
862            default_media_type: self.default_media_type,
863        }
864    }
865
866    /// Set the HTML response generator.
867    pub fn html<NH: FnOnce() -> String>(self, f: NH) -> ContentNegotiator<J, NH, T, X> {
868        ContentNegotiator {
869            json_fn: self.json_fn,
870            html_fn: Some(f),
871            text_fn: self.text_fn,
872            xml_fn: self.xml_fn,
873            default_media_type: self.default_media_type,
874        }
875    }
876
877    /// Set the plain text response generator.
878    pub fn plain_text<NT: FnOnce() -> String>(self, f: NT) -> ContentNegotiator<J, H, NT, X> {
879        ContentNegotiator {
880            json_fn: self.json_fn,
881            html_fn: self.html_fn,
882            text_fn: Some(f),
883            xml_fn: self.xml_fn,
884            default_media_type: self.default_media_type,
885        }
886    }
887
888    /// Set the XML response generator.
889    pub fn xml<NX: FnOnce() -> String>(self, f: NX) -> ContentNegotiator<J, H, T, NX> {
890        ContentNegotiator {
891            json_fn: self.json_fn,
892            html_fn: self.html_fn,
893            text_fn: self.text_fn,
894            xml_fn: Some(f),
895            default_media_type: self.default_media_type,
896        }
897    }
898
899    /// Set the default media type when no Accept header is present.
900    pub fn default_to(mut self, media_type: MediaType) -> Self {
901        self.default_media_type = media_type;
902        self
903    }
904
905    /// Negotiate and build the response based on the request's Accept header.
906    pub fn negotiate(self, request: &HttpRequest) -> Result<HttpResponse, Error> {
907        let accept = request.accept();
908
909        // Build list of available media types
910        let mut available = Vec::new();
911        if self.json_fn.is_some() {
912            available.push(MediaType::json());
913        }
914        if self.html_fn.is_some() {
915            available.push(MediaType::html());
916        }
917        if self.text_fn.is_some() {
918            available.push(MediaType::plain_text());
919        }
920        if self.xml_fn.is_some() {
921            available.push(MediaType::xml());
922        }
923
924        // If no formats available, return error
925        if available.is_empty() {
926            return Err(Error::Internal(
927                "No response formats configured".to_string(),
928            ));
929        }
930
931        // Negotiate best media type
932        let best = negotiate_media_type(&accept, &available)
933            .cloned()
934            .unwrap_or_else(|| self.default_media_type.clone());
935
936        // Build response based on negotiated type
937        let mut response = HttpResponse::ok();
938
939        if best.matches(&MediaType::json()) {
940            if let Some(f) = self.json_fn {
941                let value = f();
942                let body =
943                    serde_json::to_vec(&value).map_err(|e| Error::Serialization(e.to_string()))?;
944                response.body = Bytes::from(body);
945                response
946                    .headers
947                    .insert("Content-Type".to_string(), "application/json".to_string());
948            }
949        } else if best.matches(&MediaType::html()) {
950            if let Some(f) = self.html_fn {
951                let html = f();
952                response.body = Bytes::from(html.into_bytes());
953                response.headers.insert(
954                    "Content-Type".to_string(),
955                    "text/html; charset=utf-8".to_string(),
956                );
957            }
958        } else if best.matches(&MediaType::plain_text()) {
959            if let Some(f) = self.text_fn {
960                let text = f();
961                response.body = Bytes::from(text.into_bytes());
962                response.headers.insert(
963                    "Content-Type".to_string(),
964                    "text/plain; charset=utf-8".to_string(),
965                );
966            }
967        } else if best.matches(&MediaType::xml()) {
968            if let Some(f) = self.xml_fn {
969                let xml = f();
970                response.body = Bytes::from(xml.into_bytes());
971                response.headers.insert(
972                    "Content-Type".to_string(),
973                    "application/xml; charset=utf-8".to_string(),
974                );
975            }
976        } else {
977            return Err(Error::NotAcceptable(format!(
978                "Cannot produce response in requested format: {}",
979                best
980            )));
981        }
982
983        // Add Vary header
984        response
985            .headers
986            .insert("Vary".to_string(), "Accept".to_string());
987
988        Ok(response)
989    }
990}
991
992impl Default
993    for ContentNegotiator<fn() -> serde_json::Value, fn() -> String, fn() -> String, fn() -> String>
994{
995    fn default() -> Self {
996        Self::new()
997    }
998}
999
1000// ============================================================================
1001// Simple Response Helpers
1002// ============================================================================
1003
1004/// Create a response that adapts to the client's Accept header.
1005///
1006/// This is a simpler alternative to `ContentNegotiator` for common cases.
1007///
1008/// # Example
1009///
1010/// ```ignore
1011/// use armature_core::content_negotiation::respond_with;
1012///
1013/// let data = MyData { name: "John" };
1014/// let response = respond_with(&request, &data)?;
1015/// ```
1016pub fn respond_with<T: Serialize>(request: &HttpRequest, data: &T) -> Result<HttpResponse, Error> {
1017    let accept = request.accept();
1018
1019    let mut response = HttpResponse::ok();
1020
1021    if accept.prefers_html() {
1022        // For HTML, serialize as JSON in a pre tag (basic fallback)
1023        let json =
1024            serde_json::to_string_pretty(data).map_err(|e| Error::Serialization(e.to_string()))?;
1025        let html = format!(
1026            "<!DOCTYPE html><html><body><pre>{}</pre></body></html>",
1027            html_escape(&json)
1028        );
1029        response.body = Bytes::from(html.into_bytes());
1030        response.headers.insert(
1031            "Content-Type".to_string(),
1032            "text/html; charset=utf-8".to_string(),
1033        );
1034    } else {
1035        // Default to JSON
1036        response.body =
1037            Bytes::from(serde_json::to_vec(data).map_err(|e| Error::Serialization(e.to_string()))?);
1038        response
1039            .headers
1040            .insert("Content-Type".to_string(), "application/json".to_string());
1041    }
1042
1043    response
1044        .headers
1045        .insert("Vary".to_string(), "Accept".to_string());
1046
1047    Ok(response)
1048}
1049
1050/// Simple HTML escaping for content.
1051fn html_escape(s: &str) -> String {
1052    s.replace('&', "&amp;")
1053        .replace('<', "&lt;")
1054        .replace('>', "&gt;")
1055        .replace('"', "&quot;")
1056        .replace('\'', "&#x27;")
1057}
1058
1059// ============================================================================
1060// Tests
1061// ============================================================================
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066
1067    #[test]
1068    fn test_media_type_parse() {
1069        let mt = MediaType::parse("application/json").unwrap();
1070        assert_eq!(mt.type_, "application");
1071        assert_eq!(mt.subtype, "json");
1072    }
1073
1074    #[test]
1075    fn test_media_type_with_params() {
1076        let mt = MediaType::parse("text/html; charset=utf-8").unwrap();
1077        assert_eq!(mt.type_, "text");
1078        assert_eq!(mt.subtype, "html");
1079        assert_eq!(mt.params.get("charset").map(String::as_str), Some("utf-8"));
1080    }
1081
1082    #[test]
1083    fn test_media_type_matches() {
1084        let json = MediaType::json();
1085        let any = MediaType::any();
1086        let html = MediaType::html();
1087
1088        assert!(any.matches(&json));
1089        assert!(json.matches(&any));
1090        assert!(!json.matches(&html));
1091    }
1092
1093    #[test]
1094    fn test_accept_parse() {
1095        let accept = Accept::parse("application/json, text/html;q=0.9, */*;q=0.1");
1096        assert_eq!(accept.media_types.len(), 3);
1097
1098        // JSON should be first (q=1.0)
1099        assert_eq!(accept.media_types[0].0.subtype, "json");
1100        assert_eq!(accept.media_types[0].1, 1.0);
1101
1102        // HTML should be second (q=0.9)
1103        assert_eq!(accept.media_types[1].0.subtype, "html");
1104        assert_eq!(accept.media_types[1].1, 0.9);
1105    }
1106
1107    #[test]
1108    fn test_accept_quality_for() {
1109        let accept = Accept::parse("application/json, text/html;q=0.9");
1110
1111        assert_eq!(accept.quality_for(&MediaType::json()), 1.0);
1112        assert_eq!(accept.quality_for(&MediaType::html()), 0.9);
1113        assert_eq!(accept.quality_for(&MediaType::xml()), 0.0);
1114    }
1115
1116    #[test]
1117    fn test_extract_quality_case_insensitive() {
1118        // ";Q=" must be recognized just like ";q="
1119        let accept = Accept::parse("text/html;Q=0.8");
1120        assert_eq!(accept.quality_for(&MediaType::html()), 0.8);
1121    }
1122
1123    #[test]
1124    fn test_extract_quality_non_ascii_no_panic() {
1125        // U+212A (KELVIN SIGN) is 3 bytes but lowercases to 1-byte 'k'.
1126        // Searching a lowercased copy used to yield an offset that sliced
1127        // the original string mid-character and panicked.
1128        let accept = Accept::parse("application/json\u{212A}\u{212A};q=0.5");
1129        assert_eq!(accept.media_types.len(), 1);
1130        assert_eq!(accept.media_types[0].1, 0.5);
1131
1132        let accept_lang = AcceptLanguage::parse("en\u{212A}\u{212A};q=0.5");
1133        assert_eq!(accept_lang.languages[0].1, 0.5);
1134
1135        let accept_charset = AcceptCharset::parse("utf\u{212A}\u{212A};q=0.5");
1136        assert_eq!(accept_charset.charsets[0].1, 0.5);
1137
1138        // Unknown encoding name, but extract_quality must not panic on it.
1139        let _ = AcceptEncoding::parse("gzip\u{212A}\u{212A};q=0.5");
1140    }
1141
1142    #[test]
1143    fn test_accept_prefers_json() {
1144        let accept = Accept::parse("application/json, text/html;q=0.9");
1145        assert!(accept.prefers_json());
1146        assert!(!accept.prefers_html());
1147    }
1148
1149    #[test]
1150    fn test_accept_prefers_html() {
1151        let accept = Accept::parse("text/html, application/json;q=0.9");
1152        assert!(accept.prefers_html());
1153        assert!(!accept.prefers_json());
1154    }
1155
1156    #[test]
1157    fn test_negotiate_media_type() {
1158        let accept = Accept::parse("application/json, text/html;q=0.9");
1159        let available = vec![MediaType::html(), MediaType::json()];
1160
1161        let best = negotiate_media_type(&accept, &available);
1162        assert_eq!(best, Some(&MediaType::json()));
1163    }
1164
1165    #[test]
1166    fn test_language_tag_parse() {
1167        let tag = LanguageTag::parse("en-US").unwrap();
1168        assert_eq!(tag.primary, "en");
1169        assert_eq!(tag.subtag, Some("US".to_string()));
1170    }
1171
1172    #[test]
1173    fn test_language_tag_matches() {
1174        let en = LanguageTag::new("en");
1175        let en_us = LanguageTag::with_subtag("en", "US");
1176        let fr = LanguageTag::new("fr");
1177
1178        assert!(en.matches(&en_us)); // "en" matches "en-US"
1179        assert!(!en_us.matches(&en)); // "en-US" doesn't match just "en"
1180        assert!(!en.matches(&fr));
1181    }
1182
1183    #[test]
1184    fn test_accept_language_parse() {
1185        let accept = AcceptLanguage::parse("en-US, en;q=0.9, fr;q=0.8");
1186        assert_eq!(accept.languages.len(), 3);
1187        assert_eq!(accept.languages[0].0.primary, "en");
1188    }
1189
1190    #[test]
1191    fn test_encoding_parse() {
1192        assert_eq!(Encoding::parse("gzip"), Some(Encoding::Gzip));
1193        assert_eq!(Encoding::parse("br"), Some(Encoding::Brotli));
1194        assert_eq!(Encoding::parse("deflate"), Some(Encoding::Deflate));
1195    }
1196
1197    #[test]
1198    fn test_accept_encoding_parse() {
1199        let accept = AcceptEncoding::parse("gzip, deflate, br;q=0.9");
1200        assert_eq!(accept.encodings.len(), 3);
1201    }
1202
1203    #[test]
1204    fn test_accept_charset_parse() {
1205        let accept = AcceptCharset::parse("utf-8, iso-8859-1;q=0.8");
1206        assert_eq!(accept.charsets.len(), 2);
1207        assert_eq!(accept.quality_for("utf-8"), 1.0);
1208    }
1209
1210    #[test]
1211    fn test_http_request_accept() {
1212        let mut request = HttpRequest::new("GET", "/".to_string());
1213        request
1214            .headers
1215            .insert("Accept", "application/json".to_string());
1216
1217        let accept = request.accept();
1218        assert!(accept.accepts(&MediaType::json()));
1219    }
1220
1221    #[test]
1222    fn test_http_request_prefers_json() {
1223        let mut request = HttpRequest::new("GET", "/".to_string());
1224        request
1225            .headers
1226            .insert("Accept", "application/json, text/html;q=0.9".to_string());
1227
1228        assert!(request.prefers_json());
1229        assert!(!request.prefers_html());
1230    }
1231}