#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/media-typer/0.1.0")]
extern crate alloc;
use alloc::string::{String, ToString};
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MediaType {
pub type_: String,
pub subtype: String,
pub suffix: Option<String>,
}
impl MediaType {
pub fn new(
type_: impl Into<String>,
subtype: impl Into<String>,
suffix: Option<impl Into<String>>,
) -> Self {
Self {
type_: type_.into(),
subtype: subtype.into(),
suffix: suffix.map(Into::into),
}
}
pub fn format(&self) -> Result<String, Error> {
format(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
InvalidMediaType(String),
InvalidType(String),
InvalidSubtype(String),
InvalidSuffix(String),
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Error::InvalidMediaType(s) => write!(f, "invalid media type: {s}"),
Error::InvalidType(s) => write!(f, "invalid type: {s}"),
Error::InvalidSubtype(s) => write!(f, "invalid subtype: {s}"),
Error::InvalidSuffix(s) => write!(f, "invalid suffix: {s}"),
}
}
}
impl core::error::Error for Error {}
pub fn parse(media_type: &str) -> Result<MediaType, Error> {
let (type_part, subtype_part) = match media_type.split_once('/') {
Some((t, sub)) if is_type_name(t) && is_subtype_name(sub) => (t, sub),
_ => return Err(Error::InvalidMediaType(media_type.to_string())),
};
let type_ = type_part.to_ascii_lowercase();
let mut subtype = subtype_part.to_ascii_lowercase();
let suffix = subtype.rfind('+').map(|idx| {
let suffix = subtype[idx + 1..].to_string();
subtype.truncate(idx);
suffix
});
Ok(MediaType {
type_,
subtype,
suffix,
})
}
pub fn format(media_type: &MediaType) -> Result<String, Error> {
if !is_type_name(&media_type.type_) {
return Err(Error::InvalidType(media_type.type_.clone()));
}
if !is_subtype_name(&media_type.subtype) {
return Err(Error::InvalidSubtype(media_type.subtype.clone()));
}
let mut out = String::with_capacity(media_type.type_.len() + media_type.subtype.len() + 8);
out.push_str(&media_type.type_);
out.push('/');
out.push_str(&media_type.subtype);
if let Some(suffix) = &media_type.suffix {
if !is_type_name(suffix) {
return Err(Error::InvalidSuffix(suffix.clone()));
}
out.push('+');
out.push_str(suffix);
}
Ok(out)
}
#[must_use]
pub fn test(media_type: &str) -> bool {
match media_type.split_once('/') {
Some((type_, subtype)) => is_type_name(type_) && is_subtype_name(subtype),
None => false,
}
}
fn is_type_name(s: &str) -> bool {
let bytes = s.as_bytes();
match bytes.split_first() {
Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
rest.iter().all(|&b| is_type_char(b))
}
_ => false,
}
}
fn is_subtype_name(s: &str) -> bool {
let bytes = s.as_bytes();
match bytes.split_first() {
Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
rest.iter().all(|&b| is_subtype_char(b))
}
_ => false,
}
}
fn is_type_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'-')
}
fn is_subtype_char(b: u8) -> bool {
is_type_char(b) || b == b'.' || b == b'+'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic() {
assert_eq!(
parse("application/json").unwrap(),
MediaType::new("application", "json", None::<&str>)
);
assert_eq!(
parse("application/vnd.api+json").unwrap(),
MediaType::new("application", "vnd.api", Some("json"))
);
}
#[test]
fn parse_lowercases() {
assert_eq!(
parse("IMAGE/SVG+XML").unwrap(),
MediaType::new("image", "svg", Some("xml"))
);
}
#[test]
fn parse_suffix_at_last_plus() {
let mt = parse("application/a+b+c").unwrap();
assert_eq!(mt.subtype, "a+b");
assert_eq!(mt.suffix.as_deref(), Some("c"));
}
#[test]
fn parse_degenerate_empty_suffix() {
let mt = parse("application/x++").unwrap();
assert_eq!(mt.subtype, "x+");
assert_eq!(mt.suffix.as_deref(), Some(""));
assert_eq!(mt.format(), Err(Error::InvalidSuffix(String::new())));
}
#[test]
fn parse_rejects_invalid() {
for bad in [
"bogus",
"a/b/c",
"",
".a/b",
"a/.b",
"text/html; q=1",
"a /b",
"/",
] {
assert!(parse(bad).is_err(), "{bad:?} should be invalid");
}
}
#[test]
fn test_function() {
assert!(test("text/html"));
assert!(test("application/vnd.api+json"));
assert!(!test("text/html;x=1"));
assert!(!test("text/html "));
assert!(!test("text"));
}
#[test]
fn format_basic_and_errors() {
assert_eq!(
format(&MediaType::new("application", "json", None::<&str>)).unwrap(),
"application/json"
);
assert_eq!(
format(&MediaType::new("application", "vnd.api", Some("xml"))).unwrap(),
"application/vnd.api+xml"
);
assert_eq!(
format(&MediaType::new("a/b", "c", None::<&str>)),
Err(Error::InvalidType("a/b".to_string()))
);
assert_eq!(
format(&MediaType::new("a", "b", Some(""))),
Err(Error::InvalidSuffix(String::new()))
);
}
#[test]
fn length_limits() {
let ok = alloc::format!("a/{}", "b".repeat(127)); assert!(test(&ok));
let too_long = alloc::format!("a/{}", "b".repeat(128)); assert!(!test(&too_long));
}
}