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