Skip to main content

api_bones/
content_type.rs

1//! Media type / Content-Type representation.
2//!
3//! [`ContentType`] models a structured `Content-Type` header value consisting
4//! of a `type/subtype` pair and optional parameters (e.g. `charset=utf-8`).
5//!
6//! Pre-built constants cover the most common media types.
7//!
8//! # Example
9//!
10//! ```rust
11//! use api_bones::content_type::ContentType;
12//!
13//! let ct = ContentType::application_json();
14//! assert_eq!(ct.to_string(), "application/json");
15//!
16//! let with_charset = ContentType::text_plain_utf8();
17//! assert_eq!(with_charset.to_string(), "text/plain; charset=utf-8");
18//!
19//! let parsed: ContentType = "application/json".parse().unwrap();
20//! assert_eq!(parsed, ContentType::application_json());
21//! ```
22
23#[cfg(all(not(feature = "std"), feature = "alloc"))]
24use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec};
25use core::{fmt, str::FromStr};
26#[cfg(feature = "serde")]
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// ContentType
31// ---------------------------------------------------------------------------
32
33/// A structured `Content-Type` / media type value.
34///
35/// Stores the `type/subtype` pair plus an optional list of `name=value`
36/// parameters.  The [`Display`](fmt::Display) implementation produces the
37/// canonical wire format, e.g. `application/json` or
38/// `text/plain; charset=utf-8`.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
41#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
42pub struct ContentType {
43    /// The primary type (e.g. `"application"`).
44    pub type_: String,
45    /// The subtype (e.g. `"json"`).
46    pub subtype: String,
47    /// Optional parameters such as `charset` or `boundary`.
48    pub params: Vec<(String, String)>,
49}
50
51#[cfg(feature = "serde")]
52impl Serialize for ContentType {
53    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
54        use core::fmt::Write;
55        let mut s = String::new();
56        let _ = write!(s, "{self}");
57        serializer.serialize_str(&s)
58    }
59}
60
61#[cfg(feature = "serde")]
62impl<'de> Deserialize<'de> for ContentType {
63    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
64        let s = String::deserialize(deserializer)?;
65        s.parse().map_err(serde::de::Error::custom)
66    }
67}
68
69impl ContentType {
70    /// Construct a `ContentType` with no parameters.
71    #[must_use]
72    #[inline(never)]
73    pub fn new(type_: String, subtype: String) -> Self {
74        Self {
75            type_,
76            subtype,
77            params: Vec::new(),
78        }
79    }
80
81    /// Construct a `ContentType` with parameters.
82    #[must_use]
83    #[inline(never)]
84    pub fn with_params(type_: String, subtype: String, params: Vec<(String, String)>) -> Self {
85        Self {
86            type_,
87            subtype,
88            params,
89        }
90    }
91
92    /// Return the `type/subtype` string without parameters.
93    ///
94    /// ```
95    /// use api_bones::content_type::ContentType;
96    ///
97    /// let ct = ContentType::text_plain_utf8();
98    /// assert_eq!(ct.essence(), "text/plain");
99    /// ```
100    #[must_use]
101    pub fn essence(&self) -> String {
102        format!("{}/{}", self.type_, self.subtype)
103    }
104
105    /// Return the value of the named parameter, if present.
106    ///
107    /// ```
108    /// use api_bones::content_type::ContentType;
109    ///
110    /// let ct = ContentType::text_plain_utf8();
111    /// assert_eq!(ct.param("charset"), Some("utf-8"));
112    /// ```
113    #[must_use]
114    pub fn param(&self, name: &str) -> Option<&str> {
115        self.params
116            .iter()
117            .find(|(k, _)| k.eq_ignore_ascii_case(name))
118            .map(|(_, v)| v.as_str())
119    }
120
121    // -----------------------------------------------------------------------
122    // Pre-built constructors for common media types
123    // -----------------------------------------------------------------------
124
125    /// Returns `application/json`.
126    #[must_use]
127    pub fn application_json() -> Self {
128        Self::new("application".into(), "json".into())
129    }
130
131    /// Returns `application/problem+json` (RFC 9457).
132    #[must_use]
133    pub fn application_problem_json() -> Self {
134        Self::new("application".into(), "problem+json".into())
135    }
136
137    /// Returns `application/octet-stream`.
138    #[must_use]
139    pub fn application_octet_stream() -> Self {
140        Self::new("application".into(), "octet-stream".into())
141    }
142
143    /// Returns `multipart/form-data` with the given boundary parameter.
144    #[must_use]
145    pub fn multipart_form_data(boundary: impl Into<String>) -> Self {
146        Self::with_params(
147            "multipart".into(),
148            "form-data".into(),
149            vec![("boundary".to_owned(), boundary.into())],
150        )
151    }
152
153    /// Returns `text/plain`.
154    #[must_use]
155    pub fn text_plain() -> Self {
156        Self::new("text".into(), "plain".into())
157    }
158
159    /// Returns `text/plain; charset=utf-8`.
160    #[must_use]
161    pub fn text_plain_utf8() -> Self {
162        Self::with_params(
163            "text".into(),
164            "plain".into(),
165            vec![("charset".to_owned(), "utf-8".to_owned())],
166        )
167    }
168
169    /// Returns `text/html`.
170    #[must_use]
171    pub fn text_html() -> Self {
172        Self::new("text".into(), "html".into())
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Display
178// ---------------------------------------------------------------------------
179
180impl fmt::Display for ContentType {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{}/{}", self.type_, self.subtype)?;
183        for (k, v) in &self.params {
184            write!(f, "; {k}={v}")?;
185        }
186        Ok(())
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Parsing
192// ---------------------------------------------------------------------------
193
194/// Error returned when parsing a [`ContentType`] fails.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct ParseContentTypeError;
197
198impl fmt::Display for ParseContentTypeError {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        f.write_str("invalid Content-Type value")
201    }
202}
203
204#[cfg(feature = "std")]
205impl std::error::Error for ParseContentTypeError {}
206
207impl FromStr for ContentType {
208    type Err = ParseContentTypeError;
209
210    /// Parse a `Content-Type` header value.
211    ///
212    /// Accepts `type/subtype` with an optional `; name=value` parameter list.
213    /// Parameter names are lowercased; values are kept as-is.
214    fn from_str(s: &str) -> Result<Self, Self::Err> {
215        let s = s.trim();
216        let mut parts = s.splitn(2, ';');
217        let essence = parts.next().unwrap_or("").trim();
218        let mut type_sub = essence.splitn(2, '/');
219        let type_ = type_sub.next().unwrap_or("").trim();
220        let subtype = type_sub.next().unwrap_or("").trim();
221        if type_.is_empty() || subtype.is_empty() {
222            return Err(ParseContentTypeError);
223        }
224
225        let mut params = Vec::new();
226        if let Some(param_str) = parts.next() {
227            for param in param_str.split(';') {
228                let param = param.trim();
229                if param.is_empty() {
230                    continue;
231                }
232                let mut kv = param.splitn(2, '=');
233                let k = kv.next().unwrap_or("").trim().to_ascii_lowercase();
234                let v = kv.next().unwrap_or("").trim().to_owned();
235                if k.is_empty() {
236                    return Err(ParseContentTypeError);
237                }
238                params.push((k, v));
239            }
240        }
241
242        Ok(Self {
243            type_: type_.to_ascii_lowercase(),
244            subtype: subtype.to_ascii_lowercase(),
245            params,
246        })
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn display_no_params() {
260        assert_eq!(
261            ContentType::application_json().to_string(),
262            "application/json"
263        );
264        assert_eq!(
265            ContentType::application_problem_json().to_string(),
266            "application/problem+json"
267        );
268        assert_eq!(
269            ContentType::application_octet_stream().to_string(),
270            "application/octet-stream"
271        );
272    }
273
274    #[test]
275    fn display_with_params() {
276        let ct = ContentType::text_plain_utf8();
277        assert_eq!(ct.to_string(), "text/plain; charset=utf-8");
278    }
279
280    #[test]
281    fn display_multipart() {
282        let ct = ContentType::multipart_form_data("abc123");
283        assert_eq!(ct.to_string(), "multipart/form-data; boundary=abc123");
284    }
285
286    #[test]
287    fn essence_strips_params() {
288        let ct = ContentType::text_plain_utf8();
289        assert_eq!(ct.essence(), "text/plain");
290    }
291
292    #[test]
293    fn param_lookup() {
294        let ct = ContentType::text_plain_utf8();
295        assert_eq!(ct.param("charset"), Some("utf-8"));
296        assert_eq!(ct.param("boundary"), None);
297    }
298
299    #[test]
300    fn parse_simple() {
301        let ct: ContentType = "application/json".parse().unwrap();
302        assert_eq!(ct.type_, "application");
303        assert_eq!(ct.subtype, "json");
304        assert!(ct.params.is_empty());
305    }
306
307    #[test]
308    fn parse_with_charset() {
309        let ct: ContentType = "text/plain; charset=utf-8".parse().unwrap();
310        assert_eq!(ct.type_, "text");
311        assert_eq!(ct.subtype, "plain");
312        assert_eq!(ct.param("charset"), Some("utf-8"));
313    }
314
315    #[test]
316    fn parse_case_insensitive_type() {
317        let ct: ContentType = "Application/JSON".parse().unwrap();
318        assert_eq!(ct.type_, "application");
319        assert_eq!(ct.subtype, "json");
320    }
321
322    #[test]
323    fn parse_invalid_no_slash() {
324        assert_eq!(
325            "application".parse::<ContentType>(),
326            Err(ParseContentTypeError)
327        );
328    }
329
330    #[test]
331    fn parse_invalid_empty() {
332        assert_eq!("".parse::<ContentType>(), Err(ParseContentTypeError));
333    }
334
335    #[test]
336    fn round_trip() {
337        let ct = ContentType::text_plain_utf8();
338        let s = ct.to_string();
339        let back: ContentType = s.parse().unwrap();
340        assert_eq!(back, ct);
341    }
342
343    #[test]
344    fn new_constructor() {
345        let ct = ContentType::new(String::from("image"), String::from("png"));
346        assert_eq!(ct.type_, "image");
347        assert_eq!(ct.subtype, "png");
348        assert!(ct.params.is_empty());
349    }
350
351    #[test]
352    fn with_params_constructor() {
353        let ct = ContentType::with_params(
354            String::from("application"),
355            String::from("xml"),
356            vec![("charset".into(), "utf-8".into())],
357        );
358        assert_eq!(ct.param("charset"), Some("utf-8"));
359        assert_eq!(ct.essence(), "application/xml");
360    }
361
362    #[test]
363    fn text_html_constructor() {
364        let ct = ContentType::text_html();
365        assert_eq!(ct.to_string(), "text/html");
366    }
367
368    #[test]
369    fn text_plain_constructor() {
370        let ct = ContentType::text_plain();
371        assert_eq!(ct.to_string(), "text/plain");
372    }
373
374    #[test]
375    fn parse_error_display() {
376        let err = ParseContentTypeError;
377        assert!(!err.to_string().is_empty());
378    }
379
380    #[test]
381    fn param_case_insensitive() {
382        // Tests closure inside param() with case-insensitive match
383        let ct: ContentType = "text/plain; Charset=UTF-8".parse().unwrap();
384        assert_eq!(ct.param("charset"), Some("UTF-8"));
385        assert_eq!(ct.param("CHARSET"), Some("UTF-8"));
386        assert_eq!(ct.param("missing"), None);
387    }
388
389    #[cfg(feature = "serde")]
390    #[test]
391    fn serde_round_trip() {
392        let ct = ContentType::application_problem_json();
393        let json = serde_json::to_string(&ct).unwrap();
394        assert_eq!(json, r#""application/problem+json""#);
395        let back: ContentType = serde_json::from_str(&json).unwrap();
396        assert_eq!(back, ct);
397    }
398}