use crate::{TOON_CONTENT_TYPE, TOON_CONTENT_TYPE_TEXT};
use http::{header, StatusCode};
use rustapi_core::{ApiError, FromRequestParts, IntoResponse, Request, Response};
use rustapi_openapi::{
MediaType, Operation, OperationModifier, ResponseModifier, ResponseSpec, SchemaRef,
};
use serde::Serialize;
use std::collections::BTreeMap;
pub const JSON_CONTENT_TYPE: &str = "application/json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Json,
Toon,
}
impl OutputFormat {
pub fn content_type(&self) -> &'static str {
match self {
OutputFormat::Json => JSON_CONTENT_TYPE,
OutputFormat::Toon => TOON_CONTENT_TYPE,
}
}
}
#[derive(Debug, Clone)]
pub struct AcceptHeader {
pub preferred: OutputFormat,
pub media_types: Vec<MediaTypeEntry>,
}
#[derive(Debug, Clone)]
pub struct MediaTypeEntry {
pub media_type: String,
pub quality: f32,
}
impl Default for AcceptHeader {
fn default() -> Self {
Self {
preferred: OutputFormat::Json,
media_types: vec![MediaTypeEntry {
media_type: JSON_CONTENT_TYPE.to_string(),
quality: 1.0,
}],
}
}
}
impl AcceptHeader {
pub fn parse(header_value: &str) -> Self {
let mut entries: Vec<MediaTypeEntry> = header_value
.split(',')
.filter_map(|part| {
let part = part.trim();
if part.is_empty() {
return None;
}
let (media_type, quality) = if let Some(q_pos) = part.find(";q=") {
let (mt, q_part) = part.split_at(q_pos);
let q_str = q_part.trim_start_matches(";q=").trim();
let quality = q_str.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
(mt.trim().to_string(), quality)
} else if let Some(semi_pos) = part.find(';') {
(part[..semi_pos].trim().to_string(), 1.0)
} else {
(part.to_string(), 1.0)
};
Some(MediaTypeEntry {
media_type,
quality,
})
})
.collect();
entries.sort_by(|a, b| {
b.quality
.partial_cmp(&a.quality)
.unwrap_or(std::cmp::Ordering::Equal)
});
let preferred = Self::determine_format(&entries);
Self {
preferred,
media_types: entries,
}
}
fn determine_format(entries: &[MediaTypeEntry]) -> OutputFormat {
for entry in entries {
let mt = entry.media_type.to_lowercase();
if mt == TOON_CONTENT_TYPE || mt == TOON_CONTENT_TYPE_TEXT {
return OutputFormat::Toon;
}
if mt == JSON_CONTENT_TYPE || mt == "application/json" || mt == "text/json" {
return OutputFormat::Json;
}
if mt == "*/*" || mt == "application/*" || mt == "text/*" {
return OutputFormat::Json;
}
}
OutputFormat::Json
}
pub fn accepts_toon(&self) -> bool {
self.media_types.iter().any(|e| {
let mt = e.media_type.to_lowercase();
mt == TOON_CONTENT_TYPE
|| mt == TOON_CONTENT_TYPE_TEXT
|| mt == "*/*"
|| mt == "application/*"
})
}
pub fn accepts_json(&self) -> bool {
self.media_types.iter().any(|e| {
let mt = e.media_type.to_lowercase();
mt == JSON_CONTENT_TYPE || mt == "text/json" || mt == "*/*" || mt == "application/*"
})
}
}
impl FromRequestParts for AcceptHeader {
fn from_request_parts(req: &Request) -> rustapi_core::Result<Self> {
let accept = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(AcceptHeader::parse)
.unwrap_or_default();
Ok(accept)
}
}
#[derive(Debug, Clone)]
pub struct Negotiate<T> {
pub data: T,
pub format: OutputFormat,
}
impl<T> Negotiate<T> {
pub fn new(data: T, format: OutputFormat) -> Self {
Self { data, format }
}
pub fn json(data: T) -> Self {
Self {
data,
format: OutputFormat::Json,
}
}
pub fn toon(data: T) -> Self {
Self {
data,
format: OutputFormat::Toon,
}
}
pub fn format(&self) -> OutputFormat {
self.format
}
}
impl<T: Serialize> IntoResponse for Negotiate<T> {
fn into_response(self) -> Response {
match self.format {
OutputFormat::Json => match serde_json::to_vec(&self.data) {
Ok(body) => http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(rustapi_core::ResponseBody::from(body))
.unwrap(),
Err(err) => {
let error = ApiError::internal(format!("JSON serialization error: {}", err));
error.into_response()
}
},
OutputFormat::Toon => match toon_format::encode_default(&self.data) {
Ok(body) => http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, TOON_CONTENT_TYPE)
.body(rustapi_core::ResponseBody::from(body))
.unwrap(),
Err(err) => {
let error = ApiError::internal(format!("TOON serialization error: {}", err));
error.into_response()
}
},
}
}
}
impl<T: Send> OperationModifier for Negotiate<T> {
fn update_operation(_op: &mut Operation) {
}
}
impl<T: Serialize> ResponseModifier for Negotiate<T> {
fn update_response(op: &mut Operation) {
let mut content = BTreeMap::new();
content.insert(
JSON_CONTENT_TYPE.to_string(),
MediaType {
schema: Some(SchemaRef::Inline(serde_json::json!({
"type": "object",
"description": "JSON formatted response"
}))),
example: None,
},
);
content.insert(
TOON_CONTENT_TYPE.to_string(),
MediaType {
schema: Some(SchemaRef::Inline(serde_json::json!({
"type": "string",
"description": "TOON (Token-Oriented Object Notation) formatted response"
}))),
example: None,
},
);
let response = ResponseSpec {
description: "Content-negotiated response (JSON or TOON based on Accept header)"
.to_string(),
content,
headers: BTreeMap::new(),
};
op.responses.insert("200".to_string(), response);
}
}
impl OperationModifier for AcceptHeader {
fn update_operation(_op: &mut Operation) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accept_header_parse_json() {
let accept = AcceptHeader::parse("application/json");
assert_eq!(accept.preferred, OutputFormat::Json);
assert!(accept.accepts_json());
}
#[test]
fn test_accept_header_parse_toon() {
let accept = AcceptHeader::parse("application/toon");
assert_eq!(accept.preferred, OutputFormat::Toon);
assert!(accept.accepts_toon());
}
#[test]
fn test_accept_header_parse_with_quality() {
let accept = AcceptHeader::parse("application/json;q=0.5, application/toon;q=0.9");
assert_eq!(accept.preferred, OutputFormat::Toon);
assert_eq!(accept.media_types.len(), 2);
assert_eq!(accept.media_types[0].media_type, "application/toon");
assert_eq!(accept.media_types[0].quality, 0.9);
}
#[test]
fn test_accept_header_parse_wildcard() {
let accept = AcceptHeader::parse("*/*");
assert_eq!(accept.preferred, OutputFormat::Json);
assert!(accept.accepts_json());
assert!(accept.accepts_toon());
}
#[test]
fn test_accept_header_parse_multiple() {
let accept = AcceptHeader::parse("text/html, application/json, application/toon;q=0.8");
assert_eq!(accept.preferred, OutputFormat::Json);
}
#[test]
fn test_accept_header_default() {
let accept = AcceptHeader::default();
assert_eq!(accept.preferred, OutputFormat::Json);
}
#[test]
fn test_output_format_content_type() {
assert_eq!(OutputFormat::Json.content_type(), "application/json");
assert_eq!(OutputFormat::Toon.content_type(), "application/toon");
}
}