defmodule HTTP.Headers do
@moduledoc """
HTTP headers processing and manipulation utilities.
This module provides a comprehensive API for working with HTTP headers, including
case-insensitive lookups, header normalization, Content-Type parsing, and a
default User-Agent generator.
## Features
- **Case-insensitive operations**: All header name comparisons are case-insensitive
- **Header normalization**: Converts header names to Title-Case format
- **Multiple headers**: Support for multiple values for the same header name
- **Content-Type parsing**: Extract media type and parameters
- **Default User-Agent**: Automatically generated based on runtime environment
## Basic Usage
# Create headers
headers = HTTP.Headers.new([
{"Content-Type", "application/json"},
{"Authorization", "Bearer token"}
])
# Get header value (case-insensitive)
content_type = HTTP.Headers.get(headers, "content-type")
# => "application/json"
# Set/update header
headers = HTTP.Headers.set(headers, "Accept", "application/json")
# Set only if not present
headers = HTTP.Headers.set_default(headers, "User-Agent", "CustomAgent/1.0")
# Add multiple values for same header
headers = HTTP.Headers.add(headers, "Accept", "text/html")
values = HTTP.Headers.get_all(headers, "Accept")
# => ["application/json", "text/html"]
## Content-Type Parsing
{media_type, params} =
HTTP.Headers.parse_content_type("application/json; charset=utf-8")
# => {"application/json", %{"charset" => "utf-8"}}
## User-Agent
The library provides a default User-Agent string that includes:
- Operating system information
- System architecture
- OTP version
- BEAM version
- Elixir version
- Library version
Example User-Agent:
"Mozilla/5.0 (macOS; x86_64-apple-darwin24.6.0) OTP/27 BEAM/15.1 Elixir/1.18.3 http_core/0.5.0"
Access the default User-Agent:
user_agent = HTTP.Headers.user_agent()
"""
defstruct headers: []
@type header :: {String.t(), String.t()}
@type headers_list :: list(header)
@type t :: %__MODULE__{headers: headers_list}
@doc """
Creates a new HTTP.Headers struct.
## Examples
iex> HTTP.Headers.new([{"Content-Type", "application/json"}])
%HTTP.Headers{headers: [{"Content-Type", "application/json"}]}
iex> HTTP.Headers.new()
%HTTP.Headers{headers: []}
"""
@spec new(headers_list) :: t()
def new(headers \\ []) when is_list(headers) do
%__MODULE__{headers: headers}
end
@doc """
Normalizes header names to title case (e.g., "content-type" becomes "Content-Type").
## Examples
iex> HTTP.Headers.normalize_name("content-type")
"Content-Type"
iex> HTTP.Headers.normalize_name("AUTHORIZATION")
"Authorization"
"""
@spec normalize_name(String.t()) :: String.t()
def normalize_name(name) when is_binary(name) do
name
|> String.downcase()
|> String.split("-")
|> Enum.map_join("-", &String.capitalize/1)
end
@doc """
Parses a header string into a {name, value} tuple.
## Examples
iex> HTTP.Headers.parse("Content-Type: application/json")
{"Content-Type", "application/json"}
iex> HTTP.Headers.parse("Authorization: Bearer token123")
{"Authorization", "Bearer token123"}
"""
@spec parse(String.t()) :: header
def parse(header_str) when is_binary(header_str) do
case String.split(header_str, ":", parts: 2) do
[name, value] ->
{normalize_name(String.trim(name)), String.trim(value)}
[name] ->
{normalize_name(String.trim(name)), ""}
end
end
@doc """
Converts a HTTP.Headers struct to a map for easy lookup.
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
iex> HTTP.Headers.to_map(headers)
%{"content-type" => "application/json", "authorization" => "Bearer token"}
"""
@spec to_map(t()) :: map()
def to_map(%__MODULE__{headers: headers}) do
Enum.reduce(headers, %{}, fn {name, value}, acc ->
Map.put(acc, String.downcase(name), value)
end)
end
@doc """
Converts a map of headers to a HTTP.Headers struct.
## Examples
iex> headers = HTTP.Headers.from_map(%{"content-type" => "application/json", "authorization" => "Bearer token"})
iex> {"Content-Type", "application/json"} in headers.headers
true
iex> {"Authorization", "Bearer token"} in headers.headers
true
"""
@spec from_map(map()) :: t()
def from_map(map) when is_map(map) do
headers =
map
|> Enum.map(fn {name, value} ->
{normalize_name(to_string(name)), to_string(value)}
end)
%__MODULE__{headers: headers}
end
@doc """
Gets a header value by name (case-insensitive).
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> HTTP.Headers.get(headers, "content-type")
"application/json"
iex> headers = HTTP.Headers.new([{"Authorization", "Bearer token"}])
iex> HTTP.Headers.get(headers, "missing")
nil
"""
@spec get(t(), String.t()) :: String.t() | nil
def get(%__MODULE__{headers: headers}, name) when is_binary(name) do
normalized_name = String.downcase(name)
Enum.find_value(headers, fn {header_name, value} ->
if String.downcase(header_name) == normalized_name, do: value
end)
end
@doc """
Gets all header values for a given header name (case-insensitive).
## Examples
iex> headers = HTTP.Headers.new([{"Accept", "text/html"}, {"Accept", "application/json"}])
iex> HTTP.Headers.get_all(headers, "accept")
["text/html", "application/json"]
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
iex> HTTP.Headers.get_all(headers, "content-type")
["application/json"]
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> HTTP.Headers.get_all(headers, "missing")
[]
"""
@spec get_all(t(), String.t()) :: list(String.t())
def get_all(%__MODULE__{headers: headers}, name) when is_binary(name) do
normalized_name = String.downcase(name)
headers
|> Enum.filter(fn {header_name, _value} ->
String.downcase(header_name) == normalized_name
end)
|> Enum.map(fn {_header_name, value} -> value end)
end
@doc """
Sets a header value, replacing any existing header with the same name.
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
iex> updated = HTTP.Headers.set(headers, "Content-Type", "application/json")
iex> HTTP.Headers.get(updated, "Content-Type")
"application/json"
iex> headers = HTTP.Headers.new()
iex> updated = HTTP.Headers.set(headers, "Authorization", "Bearer token")
iex> HTTP.Headers.get(updated, "Authorization")
"Bearer token"
"""
@spec set(t(), String.t(), String.t()) :: t()
def set(%__MODULE__{headers: headers} = headers_struct, name, value)
when is_binary(name) and is_binary(value) do
normalized_name = normalize_name(name)
updated_headers =
headers
|> Enum.reject(fn {header_name, _} ->
String.downcase(header_name) == String.downcase(normalized_name)
end)
|> Kernel.++([{normalized_name, value}])
%{headers_struct | headers: updated_headers}
end
@doc """
Adds a new header value without removing existing headers with the same name.
## Examples
iex> headers = HTTP.Headers.new([{"Accept", "text/html"}])
iex> updated = HTTP.Headers.add(headers, "Accept", "application/json")
iex> HTTP.Headers.get_all(updated, "Accept")
["text/html", "application/json"]
iex> headers = HTTP.Headers.new()
iex> updated = HTTP.Headers.add(headers, "Authorization", "Bearer token")
iex> HTTP.Headers.get(updated, "Authorization")
"Bearer token"
iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
iex> updated = HTTP.Headers.add(headers, "Authorization", "Bearer token")
iex> HTTP.Headers.get(updated, "Authorization")
"Bearer token"
iex> HTTP.Headers.get(updated, "Content-Type")
"text/plain"
"""
@spec add(t(), String.t(), String.t()) :: t()
def add(%__MODULE__{headers: headers} = headers_struct, name, value)
when is_binary(name) and is_binary(value) do
normalized_name = normalize_name(name)
%{headers_struct | headers: headers ++ [{normalized_name, value}]}
end
@doc """
Merges two HTTP.Headers structs, with the second taking precedence.
## Examples
iex> headers1 = HTTP.Headers.new([{"Content-Type", "text/plain"}])
iex> headers2 = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> merged = HTTP.Headers.merge(headers1, headers2)
iex> HTTP.Headers.get(merged, "Content-Type")
"application/json"
iex> headers1 = HTTP.Headers.new([{"A", "1"}])
iex> headers2 = HTTP.Headers.new([{"B", "2"}])
iex> merged = HTTP.Headers.merge(headers1, headers2)
iex> HTTP.Headers.get(merged, "A")
"1"
iex> HTTP.Headers.get(merged, "B")
"2"
"""
@spec merge(t(), t()) :: t()
def merge(%__MODULE__{headers: headers1}, %__MODULE__{headers: headers2}) do
map1 = to_map(%__MODULE__{headers: headers1})
map2 = to_map(%__MODULE__{headers: headers2})
merged = Map.merge(map1, map2)
from_map(merged)
end
@doc """
Checks if a header exists (case-insensitive).
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> HTTP.Headers.has?(headers, "content-type")
true
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> HTTP.Headers.has?(headers, "missing")
false
"""
@spec has?(t(), String.t()) :: boolean()
def has?(%__MODULE__{headers: headers}, name) when is_binary(name) do
normalized_name = String.downcase(name)
Enum.any?(headers, fn {header_name, _} ->
String.downcase(header_name) == normalized_name
end)
end
@doc """
Removes a header by name (case-insensitive).
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
iex> updated = HTTP.Headers.delete(headers, "content-type")
iex> HTTP.Headers.has?(updated, "content-type")
false
iex> HTTP.Headers.has?(updated, "Authorization")
true
"""
@spec delete(t(), String.t()) :: t()
def delete(%__MODULE__{headers: headers} = headers_struct, name) when is_binary(name) do
normalized_name = String.downcase(name)
updated_headers =
Enum.reject(headers, fn {header_name, _} ->
String.downcase(header_name) == normalized_name
end)
%{headers_struct | headers: updated_headers}
end
@doc """
Parses a Content-Type header to extract the media type and parameters.
## Examples
iex> HTTP.Headers.parse_content_type("application/json; charset=utf-8")
{"application/json", %{"charset" => "utf-8"}}
iex> HTTP.Headers.parse_content_type("text/plain")
{"text/plain", %{}}
"""
@spec parse_content_type(String.t()) :: {String.t(), map()}
def parse_content_type(content_type) when is_binary(content_type) do
parts = String.split(content_type, ";")
media_type = parts |> hd() |> String.trim()
params =
parts
|> tl()
|> Enum.reduce(%{}, fn param, acc ->
case String.split(param, "=", parts: 2) do
[key, value] -> Map.put(acc, String.trim(key), String.trim(value))
_ -> acc
end
end)
{media_type, params}
end
@doc """
Formats headers for display (useful for debugging).
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}])
iex> HTTP.Headers.format(headers)
"Content-Type: application/json\nAuthorization: Bearer token"
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{headers: headers}) do
headers
|> Enum.map_join("\n", fn {name, value} -> "#{name}: #{value}" end)
end
@doc """
Returns the underlying list of headers.
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}])
iex> HTTP.Headers.to_list(headers)
[{"Content-Type", "application/json"}]
"""
@spec to_list(t()) :: headers_list
def to_list(%__MODULE__{headers: headers}) do
headers
end
@doc """
Sets a header only if it doesn't already exist (case-insensitive).
## Examples
iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}])
iex> updated = HTTP.Headers.set_default(headers, "Content-Type", "application/json")
iex> HTTP.Headers.get(updated, "Content-Type")
"text/plain"
iex> headers = HTTP.Headers.new([{"Accept", "text/html"}])
iex> updated = HTTP.Headers.set_default(headers, "User-Agent", "CustomAgent/1.0")
iex> HTTP.Headers.get(updated, "User-Agent")
"CustomAgent/1.0"
iex> headers = HTTP.Headers.new()
iex> updated = HTTP.Headers.set_default(headers, "Authorization", "Bearer token")
iex> HTTP.Headers.get(updated, "Authorization")
"Bearer token"
"""
@spec set_default(t(), String.t(), String.t()) :: t()
def set_default(%__MODULE__{headers: headers} = headers_struct, name, value)
when is_binary(name) and is_binary(value) do
normalized_name = String.downcase(name)
# Check if header already exists
header_exists =
Enum.any?(headers, fn {header_name, _} ->
String.downcase(header_name) == normalized_name
end)
if header_exists do
headers_struct
else
normalized_header_name = normalize_name(name)
%{headers_struct | headers: [{normalized_header_name, value} | headers]}
end
end
@doc """
Returns the default User-Agent string used by the library.
## Examples
iex> user_agent = HTTP.Headers.user_agent()
iex> user_agent =~ "Mozilla/5.0"
true
iex> user_agent =~ "http_core/"
true
"""
@spec user_agent(atom()) :: String.t()
def user_agent(app \\ :http_core) when is_atom(app) do
os_info = get_os_info()
arch_info = get_arch_info()
otp_version = System.otp_release()
elixir_version = System.version()
beam_version = :erlang.system_info(:version)
app_version = app_version(app)
"Mozilla/5.0 (#{os_info}; #{arch_info}) OTP/#{otp_version} BEAM/#{beam_version} Elixir/#{elixir_version} #{app}/#{app_version}"
end
defp app_version(app) do
app
|> Application.spec(:vsn)
|> case do
nil -> "unknown"
version -> to_string(version)
end
end
@spec get_os_info() :: String.t()
defp get_os_info do
case :os.type() do
{:unix, :darwin} ->
"macOS"
{:unix, :linux} ->
"Linux"
{:win32, _} ->
"Windows"
{_, os} ->
Atom.to_string(os)
end
end
@spec get_arch_info() :: String.t()
defp get_arch_info do
to_string(:erlang.system_info(:system_architecture))
end
end