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(";") }