Packages

A tiny Gleam library for parsing and inspecting HTTP Content-Type headers.

Current section

Files

Jump to
contenty src contenty.gleam
Raw

src/contenty.gleam

import gleam/dict
import gleam/function
import gleam/list
import gleam/result
import gleam/string
/// A `Content-Type` header has the general form:
///
/// ```text
/// type/subtype; key1=value1; key2=value2
/// ```
///
/// - The part before the first `/` is the **type** (e.g. `"text"`, `"application"`).
/// - The part after the `/` is the **subtype** (e.g. `"html"`, `"json"`).
/// - Optional parameters follow, separated by `;` (e.g. `"charset=utf-8"`).
///
/// # Behavior notes
///
/// - MIME type and subtype are lowercased for consistency
/// - Duplicate parameters: the **last occurrence wins**
/// - Quoted parameter values (e.g. `profile="..."`) are returned verbatim
///
pub opaque type ContentType {
ContentType(
mime_type: String,
mime_subtype: String,
params: dict.Dict(String, String),
)
}
/// Returns the full MIME string, e.g. `"text/html"`.
///
pub fn mime(content_type: ContentType) -> String {
content_type.mime_type <> "/" <> content_type.mime_subtype
}
/// Returns the MIME type (the part before the slash), e.g. `"text"`.
///
pub fn mime_type(content_type: ContentType) -> String {
content_type.mime_type
}
/// Returns the MIME subtype (the part after the slash), e.g. `"html"`.
///
pub fn mime_subtype(content_type: ContentType) -> String {
content_type.mime_subtype
}
/// Returns all parameters from the content type.
///
pub fn params(content_type: ContentType) -> dict.Dict(String, String) {
content_type.params
}
/// Returns the value of a parameter by key (case-insensitive).
///
pub fn param(content_type: ContentType, key: String) -> Result(String, Nil) {
dict.get(content_type.params, string.lowercase(key))
}
pub type ContentTypeError {
InvalidContentType
}
/// Parses an HTTP `Content-Type` header into a structured ContentType value.
///
/// Parameters are:
/// - Lowercased for keys (`charset`, `boundary`, etc.)
/// - Trimmed of extra whitespace
/// - Parsed safely — malformed entries like `"foo"` (without `=`) are ignored
///
/// # Examples
///
/// ```gleam
/// import contenty
///
/// let content_type_1 = contenty.parse("text/html; charset=utf-8")
/// // Ok(ContentType("text", "html", dict.from_list([#("charset", "utf-8")])))
///
/// let content_type_2 = contenty.parse("application/json")
/// // Ok(ContentType("application", "json", dict.new()))
///
/// let content_type_3 = contenty.parse("")
/// // Error(InvalidContentType)
/// ```
///
/// # See also
/// - [RFC 2045 §5.1: Content-Type Header Field](https://www.rfc-editor.org/rfc/rfc2045)
/// - [IANA Media Types Registry](https://www.iana.org/assignments/media-types/media-types.xhtml)
///
pub fn parse(content_type: String) -> Result(ContentType, ContentTypeError) {
let parts =
string.split(content_type, ";")
|> list.map(string.trim)
case parts {
[] -> Error(InvalidContentType)
[mime, ..params] -> {
use #(mime_type, mime_subtype) <- result.map(parse_mime(mime))
ContentType(mime_type:, mime_subtype:, params: parse_params(params))
}
}
}
fn parse_mime(mime: String) -> Result(#(String, String), ContentTypeError) {
case string.split(mime, "/") {
[mime_type, mime_subtype] -> {
let mime_type =
string.trim_start(mime_type) |> string.lowercase |> assert_not_empty
use mime_type <- result.try(mime_type)
let mime_subtype =
string.trim_end(mime_subtype) |> string.lowercase |> assert_not_empty
use mime_subtype <- result.map(mime_subtype)
#(mime_type, mime_subtype)
}
_ -> Error(InvalidContentType)
}
}
fn parse_params(params: List(String)) -> dict.Dict(String, String) {
list.map(params, parse_param)
|> list.filter_map(function.identity)
|> list.filter(fn(s) { !string.is_empty(s.0) })
|> dict.from_list
}
fn parse_param(param: String) -> Result(#(String, String), Nil) {
use #(key, value) <- result.map(string.split_once(param, "="))
#(
string.trim_start(key)
|> string.lowercase,
string.trim_end(value),
)
}
fn assert_not_empty(value: String) -> Result(String, ContentTypeError) {
case value {
"" -> Error(InvalidContentType)
_ -> Ok(value)
}
}
/// Creates a new `ContentType` value from its components.
///
/// This function normalises and validates the input:
/// - `mime_type` and `mime_subtype` are lowercased and trimmed
/// - `params` keys are lowercased and trimmed
/// - `params` values are trimmed (but not lowercased)
///
/// Returns `Error(ContentTypeError)` if either the type or subtype
/// is empty or malformed.
///
/// # Examples
///
/// ```gleam
/// import contenty
/// import gleam/dict
///
/// let assert Ok(content_type) = contenty.new(
/// "Text",
/// "HTML",
/// dict.from_list([#("Charset", "utf-8")]),
/// )
///
/// contenty.mime(content_type)
/// // "text/html"
///
/// contenty.params(content_type)
/// // dict.from_list([#("charset", "utf-8")])
/// ```
///
pub fn new(
mime_type: String,
mime_subtype: String,
params: dict.Dict(String, String),
) -> Result(ContentType, ContentTypeError) {
use #(mime_type, mime_subtype) <- result.map(parse_mime(
mime_type <> "/" <> mime_subtype,
))
let params =
dict.to_list(params)
|> list.map(fn(entry) {
let key = string.trim_start(entry.0) |> string.lowercase
let value = string.trim_end(entry.1)
#(key, value)
})
|> dict.from_list
ContentType(mime_type:, mime_subtype:, params:)
}
/// Converts a `ContentType` back to its string form.
///
/// Produces a canonicalised header string in the form:
///
/// ```text
/// type/subtype; key=value; key2=value2
/// ```
///
/// Parameters are joined with `;` and appear in insertion order.
///
/// # Examples
///
/// ```gleam
/// import contenty
/// import gleam/dict
///
/// let assert Ok(content_type) = contenty.new(
/// "text",
/// "html",
/// dict.from_list([#("charset", "utf-8")]),
/// )
///
/// contenty.to_string(content_type)
/// // "text/html; charset=utf-8"
/// ```
///
pub fn to_string(content_type: ContentType) -> String {
mime(content_type) <> ";" <> params_to_string(content_type.params)
}
fn params_to_string(params: dict.Dict(String, String)) -> String {
dict.to_list(params)
|> list.map(fn(entry) { " " <> entry.0 <> "=" <> entry.1 })
|> string.join(";")
}