Skip to main content

rustapi_toon/
negotiate.rs

1//! Content Negotiation for TOON/JSON responses
2//!
3//! This module provides `Negotiate<T>` - a response wrapper that automatically
4//! chooses between JSON and TOON format based on the client's `Accept` header.
5
6use crate::{TOON_CONTENT_TYPE, TOON_CONTENT_TYPE_TEXT};
7use http::{header, StatusCode};
8use rustapi_core::{ApiError, FromRequestParts, IntoResponse, Request, Response};
9use rustapi_openapi::{
10    MediaType, Operation, OperationModifier, ResponseModifier, ResponseSpec, SchemaRef,
11};
12use serde::Serialize;
13use std::collections::BTreeMap;
14
15/// JSON Content-Type
16pub const JSON_CONTENT_TYPE: &str = "application/json";
17
18/// Supported output formats
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum OutputFormat {
21    /// JSON format (default)
22    #[default]
23    Json,
24    /// TOON format (token-optimized)
25    Toon,
26}
27
28impl OutputFormat {
29    /// Get the content-type string for this format
30    pub fn content_type(&self) -> &'static str {
31        match self {
32            OutputFormat::Json => JSON_CONTENT_TYPE,
33            OutputFormat::Toon => TOON_CONTENT_TYPE,
34        }
35    }
36}
37
38/// Parsed Accept header with quality values
39///
40/// Parses `Accept` headers like:
41/// - `application/json`
42/// - `application/toon`
43/// - `application/json, application/toon;q=0.9`
44/// - `*/*`
45#[derive(Debug, Clone)]
46pub struct AcceptHeader {
47    /// Preferred format based on Accept header parsing
48    pub preferred: OutputFormat,
49    /// Raw media types with quality values (sorted by quality, descending)
50    pub media_types: Vec<MediaTypeEntry>,
51}
52
53/// A single media type entry from Accept header
54#[derive(Debug, Clone)]
55pub struct MediaTypeEntry {
56    /// Media type (e.g., "application/json")
57    pub media_type: String,
58    /// Quality value (0.0 - 1.0), default is 1.0
59    pub quality: f32,
60}
61
62impl Default for AcceptHeader {
63    fn default() -> Self {
64        Self {
65            preferred: OutputFormat::Json,
66            media_types: vec![MediaTypeEntry {
67                media_type: JSON_CONTENT_TYPE.to_string(),
68                quality: 1.0,
69            }],
70        }
71    }
72}
73
74impl AcceptHeader {
75    /// Parse an Accept header value
76    pub fn parse(header_value: &str) -> Self {
77        let mut entries: Vec<MediaTypeEntry> = header_value
78            .split(',')
79            .filter_map(|part| {
80                let part = part.trim();
81                if part.is_empty() {
82                    return None;
83                }
84
85                let (media_type, quality) = if let Some(q_pos) = part.find(";q=") {
86                    let (mt, q_part) = part.split_at(q_pos);
87                    let q_str = q_part.trim_start_matches(";q=").trim();
88                    let quality = q_str.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
89                    (mt.trim().to_string(), quality)
90                } else if let Some(semi_pos) = part.find(';') {
91                    // Handle other parameters, ignore them
92                    (part[..semi_pos].trim().to_string(), 1.0)
93                } else {
94                    (part.to_string(), 1.0)
95                };
96
97                Some(MediaTypeEntry {
98                    media_type,
99                    quality,
100                })
101            })
102            .collect();
103
104        // Sort by quality (descending)
105        entries.sort_by(|a, b| {
106            b.quality
107                .partial_cmp(&a.quality)
108                .unwrap_or(std::cmp::Ordering::Equal)
109        });
110
111        // Determine preferred format
112        let preferred = Self::determine_format(&entries);
113
114        Self {
115            preferred,
116            media_types: entries,
117        }
118    }
119
120    /// Determine the output format based on media type entries
121    fn determine_format(entries: &[MediaTypeEntry]) -> OutputFormat {
122        for entry in entries {
123            let mt = entry.media_type.to_lowercase();
124
125            // Check for TOON
126            if mt == TOON_CONTENT_TYPE || mt == TOON_CONTENT_TYPE_TEXT {
127                return OutputFormat::Toon;
128            }
129
130            // Check for JSON
131            if mt == JSON_CONTENT_TYPE || mt == "application/json" || mt == "text/json" {
132                return OutputFormat::Json;
133            }
134
135            // Wildcard accepts anything, default to JSON
136            if mt == "*/*" || mt == "application/*" || mt == "text/*" {
137                return OutputFormat::Json;
138            }
139        }
140
141        // Default to JSON
142        OutputFormat::Json
143    }
144
145    /// Check if TOON format is acceptable
146    pub fn accepts_toon(&self) -> bool {
147        self.media_types.iter().any(|e| {
148            let mt = e.media_type.to_lowercase();
149            mt == TOON_CONTENT_TYPE
150                || mt == TOON_CONTENT_TYPE_TEXT
151                || mt == "*/*"
152                || mt == "application/*"
153        })
154    }
155
156    /// Check if JSON format is acceptable
157    pub fn accepts_json(&self) -> bool {
158        self.media_types.iter().any(|e| {
159            let mt = e.media_type.to_lowercase();
160            mt == JSON_CONTENT_TYPE || mt == "text/json" || mt == "*/*" || mt == "application/*"
161        })
162    }
163}
164
165impl FromRequestParts for AcceptHeader {
166    fn from_request_parts(req: &Request) -> rustapi_core::Result<Self> {
167        let accept = req
168            .headers()
169            .get(header::ACCEPT)
170            .and_then(|v| v.to_str().ok())
171            .map(AcceptHeader::parse)
172            .unwrap_or_default();
173
174        Ok(accept)
175    }
176}
177
178/// Content-negotiated response wrapper
179///
180/// Automatically serializes to JSON or TOON based on the client's `Accept` header.
181/// If the client prefers TOON (`Accept: application/toon`), returns TOON format.
182/// Otherwise, defaults to JSON.
183///
184/// # Example
185///
186/// ```rust,ignore
187/// use rustapi_rs::prelude::*;
188/// use rustapi_rs::toon::{Negotiate, AcceptHeader};
189///
190/// #[derive(Serialize)]
191/// struct User {
192///     id: u64,
193///     name: String,
194/// }
195///
196/// // Automatic negotiation via extractor
197/// async fn get_user(accept: AcceptHeader) -> Negotiate<User> {
198///     Negotiate::new(
199///         User { id: 1, name: "Alice".to_string() },
200///         accept.preferred,
201///     )
202/// }
203///
204/// // Or explicitly choose format
205/// async fn get_user_toon() -> Negotiate<User> {
206///     Negotiate::toon(User { id: 1, name: "Alice".to_string() })
207/// }
208/// ```
209#[derive(Debug, Clone)]
210pub struct Negotiate<T> {
211    /// The data to serialize
212    pub data: T,
213    /// The output format to use
214    pub format: OutputFormat,
215}
216
217impl<T> Negotiate<T> {
218    /// Create a new negotiated response with the specified format
219    pub fn new(data: T, format: OutputFormat) -> Self {
220        Self { data, format }
221    }
222
223    /// Create a JSON response
224    pub fn json(data: T) -> Self {
225        Self {
226            data,
227            format: OutputFormat::Json,
228        }
229    }
230
231    /// Create a TOON response
232    pub fn toon(data: T) -> Self {
233        Self {
234            data,
235            format: OutputFormat::Toon,
236        }
237    }
238
239    /// Get the output format
240    pub fn format(&self) -> OutputFormat {
241        self.format
242    }
243}
244
245impl<T: Serialize> IntoResponse for Negotiate<T> {
246    fn into_response(self) -> Response {
247        match self.format {
248            OutputFormat::Json => match serde_json::to_vec(&self.data) {
249                Ok(body) => http::Response::builder()
250                    .status(StatusCode::OK)
251                    .header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
252                    .body(rustapi_core::ResponseBody::from(body))
253                    .unwrap(),
254                Err(err) => {
255                    let error = ApiError::internal(format!("JSON serialization error: {}", err));
256                    error.into_response()
257                }
258            },
259            OutputFormat::Toon => match toon_format::encode_default(&self.data) {
260                Ok(body) => http::Response::builder()
261                    .status(StatusCode::OK)
262                    .header(header::CONTENT_TYPE, TOON_CONTENT_TYPE)
263                    .body(rustapi_core::ResponseBody::from(body))
264                    .unwrap(),
265                Err(err) => {
266                    let error = ApiError::internal(format!("TOON serialization error: {}", err));
267                    error.into_response()
268                }
269            },
270        }
271    }
272}
273
274// OpenAPI support
275impl<T: Send> OperationModifier for Negotiate<T> {
276    fn update_operation(_op: &mut Operation) {
277        // Negotiate is a response type, no request body modification needed
278    }
279}
280
281impl<T: Serialize> ResponseModifier for Negotiate<T> {
282    fn update_response(op: &mut Operation) {
283        let mut content = BTreeMap::new();
284
285        // JSON response
286        content.insert(
287            JSON_CONTENT_TYPE.to_string(),
288            MediaType {
289                schema: Some(SchemaRef::Inline(serde_json::json!({
290                    "type": "object",
291                    "description": "JSON formatted response"
292                }))),
293                example: None,
294            },
295        );
296
297        // TOON response
298        content.insert(
299            TOON_CONTENT_TYPE.to_string(),
300            MediaType {
301                schema: Some(SchemaRef::Inline(serde_json::json!({
302                    "type": "string",
303                    "description": "TOON (Token-Oriented Object Notation) formatted response"
304                }))),
305                example: None,
306            },
307        );
308
309        let response = ResponseSpec {
310            description: "Content-negotiated response (JSON or TOON based on Accept header)"
311                .to_string(),
312            content,
313            headers: BTreeMap::new(),
314        };
315        op.responses.insert("200".to_string(), response);
316    }
317}
318
319// Also implement for AcceptHeader extractor
320impl OperationModifier for AcceptHeader {
321    fn update_operation(_op: &mut Operation) {
322        // Accept header parsing doesn't modify operation
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_accept_header_parse_json() {
332        let accept = AcceptHeader::parse("application/json");
333        assert_eq!(accept.preferred, OutputFormat::Json);
334        assert!(accept.accepts_json());
335    }
336
337    #[test]
338    fn test_accept_header_parse_toon() {
339        let accept = AcceptHeader::parse("application/toon");
340        assert_eq!(accept.preferred, OutputFormat::Toon);
341        assert!(accept.accepts_toon());
342    }
343
344    #[test]
345    fn test_accept_header_parse_with_quality() {
346        let accept = AcceptHeader::parse("application/json;q=0.5, application/toon;q=0.9");
347        assert_eq!(accept.preferred, OutputFormat::Toon);
348        assert_eq!(accept.media_types.len(), 2);
349        // First should be toon (higher quality)
350        assert_eq!(accept.media_types[0].media_type, "application/toon");
351        assert_eq!(accept.media_types[0].quality, 0.9);
352    }
353
354    #[test]
355    fn test_accept_header_parse_wildcard() {
356        let accept = AcceptHeader::parse("*/*");
357        assert_eq!(accept.preferred, OutputFormat::Json);
358        assert!(accept.accepts_json());
359        assert!(accept.accepts_toon());
360    }
361
362    #[test]
363    fn test_accept_header_parse_multiple() {
364        let accept = AcceptHeader::parse("text/html, application/json, application/toon;q=0.8");
365        // JSON comes before TOON (both have default q=1.0, but JSON is checked first)
366        assert_eq!(accept.preferred, OutputFormat::Json);
367    }
368
369    #[test]
370    fn test_accept_header_default() {
371        let accept = AcceptHeader::default();
372        assert_eq!(accept.preferred, OutputFormat::Json);
373    }
374
375    #[test]
376    fn test_output_format_content_type() {
377        assert_eq!(OutputFormat::Json.content_type(), "application/json");
378        assert_eq!(OutputFormat::Toon.content_type(), "application/toon");
379    }
380}