Skip to main content

media_typer/
lib.rs

1//! # media-typer — RFC 6838 media type parsing and formatting
2//!
3//! Parse and format media types — `type/subtype+suffix` — per the
4//! [RFC 6838](https://www.rfc-editor.org/rfc/rfc6838) grammar, splitting out the `type`,
5//! `subtype`, and structured-syntax `suffix`. A faithful Rust port of the widely-used
6//! [`media-typer`](https://www.npmjs.com/package/media-typer) npm package (v2), used
7//! throughout the Node HTTP stack (`type-is`, `body-parser`, …).
8//!
9//! This is the strict, lower-level media-type grammar — it does **not** parse parameters
10//! (`; charset=utf-8`); for full `Content-Type` header handling see the `content-type`
11//! crate. **Zero dependencies** and `#![no_std]`.
12//!
13//! ```
14//! use media_typer::{parse, format, test, MediaType};
15//!
16//! let mt = parse("application/vnd.api+json").unwrap();
17//! assert_eq!(mt.type_, "application");
18//! assert_eq!(mt.subtype, "vnd.api");
19//! assert_eq!(mt.suffix.as_deref(), Some("json"));
20//!
21//! // The type, subtype, and suffix are lower-cased on parse.
22//! assert_eq!(parse("IMAGE/SVG+XML").unwrap(), MediaType::new("image", "svg", Some("xml")));
23//!
24//! // `format` re-assembles and validates.
25//! assert_eq!(format(&MediaType::new("text", "html", None::<&str>)).unwrap(), "text/html");
26//!
27//! // `test` reports whether a string is a valid (parameter-free) media type.
28//! assert!(test("application/json"));
29//! assert!(!test("application/json; charset=utf-8"));
30//! ```
31
32#![no_std]
33#![forbid(unsafe_code)]
34#![doc(html_root_url = "https://docs.rs/media-typer/0.1.0")]
35
36extern crate alloc;
37
38use alloc::string::{String, ToString};
39
40// Compile-test the README's examples as part of `cargo test`.
41#[cfg(doctest)]
42#[doc = include_str!("../README.md")]
43struct ReadmeDoctests;
44
45/// A parsed media type: `type/subtype` with an optional structured-syntax `suffix`.
46///
47/// The `suffix` is `None` when the subtype has no `+`, and `Some(_)` otherwise — note it
48/// can be `Some("")` for a degenerate subtype like `x++` (matching the reference), which
49/// is why round-tripping such a value back through [`format`] fails.
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51pub struct MediaType {
52    /// The top-level type, e.g. `application` (lower-cased by [`parse`]).
53    pub type_: String,
54    /// The subtype, e.g. `vnd.api` (lower-cased by [`parse`], suffix removed).
55    pub subtype: String,
56    /// The structured-syntax suffix after the last `+`, e.g. `json`.
57    pub suffix: Option<String>,
58}
59
60impl MediaType {
61    /// Construct a [`MediaType`] from its parts (without validation — use [`format`] to
62    /// validate and render).
63    pub fn new(
64        type_: impl Into<String>,
65        subtype: impl Into<String>,
66        suffix: Option<impl Into<String>>,
67    ) -> Self {
68        Self {
69            type_: type_.into(),
70            subtype: subtype.into(),
71            suffix: suffix.map(Into::into),
72        }
73    }
74
75    /// Validate and render this media type to a string. See [`format`].
76    ///
77    /// # Errors
78    /// Returns an error if the `type_`, `subtype`, or `suffix` is not a valid RFC 6838 name.
79    pub fn format(&self) -> Result<String, Error> {
80        format(self)
81    }
82}
83
84/// The error type for invalid media types.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum Error {
87    /// A string passed to [`parse`] is not a valid media type.
88    InvalidMediaType(String),
89    /// The `type` part is not a valid RFC 6838 name.
90    InvalidType(String),
91    /// The `subtype` part is not a valid RFC 6838 name.
92    InvalidSubtype(String),
93    /// The `suffix` part is not a valid RFC 6838 name.
94    InvalidSuffix(String),
95}
96
97impl core::fmt::Display for Error {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        match self {
100            Error::InvalidMediaType(s) => write!(f, "invalid media type: {s}"),
101            Error::InvalidType(s) => write!(f, "invalid type: {s}"),
102            Error::InvalidSubtype(s) => write!(f, "invalid subtype: {s}"),
103            Error::InvalidSuffix(s) => write!(f, "invalid suffix: {s}"),
104        }
105    }
106}
107
108impl core::error::Error for Error {}
109
110/// Parse a media type string into its [`MediaType`] parts.
111///
112/// The `type`, `subtype`, and `suffix` are lower-cased; the `suffix` is taken from after
113/// the **last** `+` in the subtype. Parameters (`; key=value`) are **not** accepted.
114///
115/// # Errors
116/// Returns [`Error::InvalidMediaType`] if `media_type` is not a valid RFC 6838 media type.
117///
118/// ```
119/// # use media_typer::parse;
120/// assert_eq!(parse("text/plain").unwrap().subtype, "plain");
121/// assert!(parse("text/html; charset=utf-8").is_err());
122/// ```
123pub fn parse(media_type: &str) -> Result<MediaType, Error> {
124    let (type_part, subtype_part) = match media_type.split_once('/') {
125        Some((t, sub)) if is_type_name(t) && is_subtype_name(sub) => (t, sub),
126        _ => return Err(Error::InvalidMediaType(media_type.to_string())),
127    };
128    let type_ = type_part.to_ascii_lowercase();
129    let mut subtype = subtype_part.to_ascii_lowercase();
130    let suffix = subtype.rfind('+').map(|idx| {
131        let suffix = subtype[idx + 1..].to_string();
132        subtype.truncate(idx);
133        suffix
134    });
135    Ok(MediaType {
136        type_,
137        subtype,
138        suffix,
139    })
140}
141
142/// Validate the parts of a [`MediaType`] and render it as `type/subtype[+suffix]`.
143///
144/// # Errors
145/// Returns [`Error::InvalidType`], [`Error::InvalidSubtype`], or [`Error::InvalidSuffix`]
146/// if the corresponding part is not a valid RFC 6838 name. Note a `Some("")` suffix is
147/// invalid.
148///
149/// ```
150/// # use media_typer::{format, MediaType};
151/// assert_eq!(
152///     format(&MediaType::new("application", "vnd.api", Some("json"))).unwrap(),
153///     "application/vnd.api+json"
154/// );
155/// ```
156pub fn format(media_type: &MediaType) -> Result<String, Error> {
157    if !is_type_name(&media_type.type_) {
158        return Err(Error::InvalidType(media_type.type_.clone()));
159    }
160    if !is_subtype_name(&media_type.subtype) {
161        return Err(Error::InvalidSubtype(media_type.subtype.clone()));
162    }
163    let mut out = String::with_capacity(media_type.type_.len() + media_type.subtype.len() + 8);
164    out.push_str(&media_type.type_);
165    out.push('/');
166    out.push_str(&media_type.subtype);
167    if let Some(suffix) = &media_type.suffix {
168        if !is_type_name(suffix) {
169            return Err(Error::InvalidSuffix(suffix.clone()));
170        }
171        out.push('+');
172        out.push_str(suffix);
173    }
174    Ok(out)
175}
176
177/// Report whether `media_type` is a valid (parameter-free) RFC 6838 media type.
178///
179/// ```
180/// # use media_typer::test;
181/// assert!(test("image/png"));
182/// assert!(!test("image png"));
183/// ```
184#[must_use]
185pub fn test(media_type: &str) -> bool {
186    match media_type.split_once('/') {
187        Some((type_, subtype)) => is_type_name(type_) && is_subtype_name(subtype),
188        None => false,
189    }
190}
191
192/// `restricted-name` for a type/suffix name: `^[A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126}$`.
193fn is_type_name(s: &str) -> bool {
194    let bytes = s.as_bytes();
195    match bytes.split_first() {
196        Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
197            rest.iter().all(|&b| is_type_char(b))
198        }
199        _ => false,
200    }
201}
202
203/// `restricted-name` for a subtype name (also allows `.` and `+`):
204/// `^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$`.
205fn is_subtype_name(s: &str) -> bool {
206    let bytes = s.as_bytes();
207    match bytes.split_first() {
208        Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
209            rest.iter().all(|&b| is_subtype_char(b))
210        }
211        _ => false,
212    }
213}
214
215fn is_type_char(b: u8) -> bool {
216    b.is_ascii_alphanumeric() || matches!(b, b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'-')
217}
218
219fn is_subtype_char(b: u8) -> bool {
220    is_type_char(b) || b == b'.' || b == b'+'
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parse_basic() {
229        assert_eq!(
230            parse("application/json").unwrap(),
231            MediaType::new("application", "json", None::<&str>)
232        );
233        assert_eq!(
234            parse("application/vnd.api+json").unwrap(),
235            MediaType::new("application", "vnd.api", Some("json"))
236        );
237    }
238
239    #[test]
240    fn parse_lowercases() {
241        assert_eq!(
242            parse("IMAGE/SVG+XML").unwrap(),
243            MediaType::new("image", "svg", Some("xml"))
244        );
245    }
246
247    #[test]
248    fn parse_suffix_at_last_plus() {
249        let mt = parse("application/a+b+c").unwrap();
250        assert_eq!(mt.subtype, "a+b");
251        assert_eq!(mt.suffix.as_deref(), Some("c"));
252    }
253
254    #[test]
255    fn parse_degenerate_empty_suffix() {
256        let mt = parse("application/x++").unwrap();
257        assert_eq!(mt.subtype, "x+");
258        assert_eq!(mt.suffix.as_deref(), Some(""));
259        // ...and it cannot be re-formatted, like the reference.
260        assert_eq!(mt.format(), Err(Error::InvalidSuffix(String::new())));
261    }
262
263    #[test]
264    fn parse_rejects_invalid() {
265        for bad in [
266            "bogus",
267            "a/b/c",
268            "",
269            ".a/b",
270            "a/.b",
271            "text/html; q=1",
272            "a /b",
273            "/",
274        ] {
275            assert!(parse(bad).is_err(), "{bad:?} should be invalid");
276        }
277    }
278
279    #[test]
280    fn test_function() {
281        assert!(test("text/html"));
282        assert!(test("application/vnd.api+json"));
283        assert!(!test("text/html;x=1"));
284        assert!(!test("text/html "));
285        assert!(!test("text"));
286    }
287
288    #[test]
289    fn format_basic_and_errors() {
290        assert_eq!(
291            format(&MediaType::new("application", "json", None::<&str>)).unwrap(),
292            "application/json"
293        );
294        assert_eq!(
295            format(&MediaType::new("application", "vnd.api", Some("xml"))).unwrap(),
296            "application/vnd.api+xml"
297        );
298        assert_eq!(
299            format(&MediaType::new("a/b", "c", None::<&str>)),
300            Err(Error::InvalidType("a/b".to_string()))
301        );
302        assert_eq!(
303            format(&MediaType::new("a", "b", Some(""))),
304            Err(Error::InvalidSuffix(String::new()))
305        );
306    }
307
308    #[test]
309    fn length_limits() {
310        let ok = alloc::format!("a/{}", "b".repeat(127)); // subtype 127 chars (max)
311        assert!(test(&ok));
312        let too_long = alloc::format!("a/{}", "b".repeat(128)); // subtype 128 chars
313        assert!(!test(&too_long));
314    }
315}