|
| 1 | +defmodule HTTP.Headers do |
| 2 | + @moduledoc """ |
| 3 | + Module for processing HTTP headers with utilities for parsing, normalizing, and manipulating headers. |
| 4 | + """ |
| 5 | + |
| 6 | + defstruct headers: [] |
| 7 | + |
| 8 | + @type header :: {String.t(), String.t()} |
| 9 | + @type headers_list :: list(header) |
| 10 | + @type t :: %__MODULE__{headers: headers_list} |
| 11 | + |
| 12 | + @doc """ |
| 13 | + Creates a new HTTP.Headers struct. |
| 14 | +
|
| 15 | + ## Examples |
| 16 | + iex> HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 17 | + %HTTP.Headers{headers: [{"Content-Type", "application/json"}]} |
| 18 | + |
| 19 | + iex> HTTP.Headers.new() |
| 20 | + %HTTP.Headers{headers: []} |
| 21 | + """ |
| 22 | + @spec new(headers_list) :: t() |
| 23 | + def new(headers \\ []) when is_list(headers) do |
| 24 | + %__MODULE__{headers: headers} |
| 25 | + end |
| 26 | + |
| 27 | + @doc """ |
| 28 | + Normalizes header names to title case (e.g., "content-type" becomes "Content-Type"). |
| 29 | +
|
| 30 | + ## Examples |
| 31 | + iex> HTTP.Headers.normalize_name("content-type") |
| 32 | + "Content-Type" |
| 33 | + |
| 34 | + iex> HTTP.Headers.normalize_name("AUTHORIZATION") |
| 35 | + "Authorization" |
| 36 | + """ |
| 37 | + @spec normalize_name(String.t()) :: String.t() |
| 38 | + def normalize_name(name) when is_binary(name) do |
| 39 | + name |
| 40 | + |> String.downcase() |
| 41 | + |> String.split("-") |
| 42 | + |> Enum.map(&String.capitalize/1) |
| 43 | + |> Enum.join("-") |
| 44 | + end |
| 45 | + |
| 46 | + @doc """ |
| 47 | + Parses a header string into a {name, value} tuple. |
| 48 | +
|
| 49 | + ## Examples |
| 50 | + iex> HTTP.Headers.parse("Content-Type: application/json") |
| 51 | + {"Content-Type", "application/json"} |
| 52 | + |
| 53 | + iex> HTTP.Headers.parse("Authorization: Bearer token123") |
| 54 | + {"Authorization", "Bearer token123"} |
| 55 | + """ |
| 56 | + @spec parse(String.t()) :: header |
| 57 | + def parse(header_str) when is_binary(header_str) do |
| 58 | + case String.split(header_str, ":", parts: 2) do |
| 59 | + [name, value] -> |
| 60 | + {normalize_name(String.trim(name)), String.trim(value)} |
| 61 | + |
| 62 | + [name] -> |
| 63 | + {normalize_name(String.trim(name)), ""} |
| 64 | + end |
| 65 | + end |
| 66 | + |
| 67 | + @doc """ |
| 68 | + Converts a HTTP.Headers struct to a map for easy lookup. |
| 69 | +
|
| 70 | + ## Examples |
| 71 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}]) |
| 72 | + iex> HTTP.Headers.to_map(headers) |
| 73 | + %{"content-type" => "application/json", "authorization" => "Bearer token"} |
| 74 | + """ |
| 75 | + @spec to_map(t()) :: map() |
| 76 | + def to_map(%__MODULE__{headers: headers}) do |
| 77 | + Enum.reduce(headers, %{}, fn {name, value}, acc -> |
| 78 | + Map.put(acc, String.downcase(name), value) |
| 79 | + end) |
| 80 | + end |
| 81 | + |
| 82 | + @doc """ |
| 83 | + Converts a map of headers to a HTTP.Headers struct. |
| 84 | +
|
| 85 | + ## Examples |
| 86 | + iex> headers = HTTP.Headers.from_map(%{"content-type" => "application/json", "authorization" => "Bearer token"}) |
| 87 | + iex> {"Content-Type", "application/json"} in headers.headers |
| 88 | + true |
| 89 | + iex> {"Authorization", "Bearer token"} in headers.headers |
| 90 | + true |
| 91 | + """ |
| 92 | + @spec from_map(map()) :: t() |
| 93 | + def from_map(map) when is_map(map) do |
| 94 | + headers = |
| 95 | + map |
| 96 | + |> Enum.map(fn {name, value} -> |
| 97 | + {normalize_name(to_string(name)), to_string(value)} |
| 98 | + end) |
| 99 | + |
| 100 | + %__MODULE__{headers: headers} |
| 101 | + end |
| 102 | + |
| 103 | + @doc """ |
| 104 | + Gets a header value by name (case-insensitive). |
| 105 | +
|
| 106 | + ## Examples |
| 107 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 108 | + iex> HTTP.Headers.get(headers, "content-type") |
| 109 | + "application/json" |
| 110 | + |
| 111 | + iex> headers = HTTP.Headers.new([{"Authorization", "Bearer token"}]) |
| 112 | + iex> HTTP.Headers.get(headers, "missing") |
| 113 | + nil |
| 114 | + """ |
| 115 | + @spec get(t(), String.t()) :: String.t() | nil |
| 116 | + def get(%__MODULE__{headers: headers}, name) when is_binary(name) do |
| 117 | + normalized_name = String.downcase(name) |
| 118 | + |
| 119 | + Enum.find_value(headers, fn {header_name, value} -> |
| 120 | + if String.downcase(header_name) == normalized_name, do: value |
| 121 | + end) |
| 122 | + end |
| 123 | + |
| 124 | + @doc """ |
| 125 | + Sets a header value, replacing any existing header with the same name. |
| 126 | +
|
| 127 | + ## Examples |
| 128 | + iex> headers = HTTP.Headers.new([{"Content-Type", "text/plain"}]) |
| 129 | + iex> updated = HTTP.Headers.set(headers, "Content-Type", "application/json") |
| 130 | + iex> HTTP.Headers.get(updated, "Content-Type") |
| 131 | + "application/json" |
| 132 | + |
| 133 | + iex> headers = HTTP.Headers.new() |
| 134 | + iex> updated = HTTP.Headers.set(headers, "Authorization", "Bearer token") |
| 135 | + iex> HTTP.Headers.get(updated, "Authorization") |
| 136 | + "Bearer token" |
| 137 | + """ |
| 138 | + @spec set(t(), String.t(), String.t()) :: t() |
| 139 | + def set(%__MODULE__{headers: headers} = headers_struct, name, value) |
| 140 | + when is_binary(name) and is_binary(value) do |
| 141 | + normalized_name = normalize_name(name) |
| 142 | + |
| 143 | + updated_headers = |
| 144 | + headers |
| 145 | + |> Enum.reject(fn {header_name, _} -> |
| 146 | + String.downcase(header_name) == String.downcase(normalized_name) |
| 147 | + end) |
| 148 | + |> Kernel.++([{normalized_name, value}]) |
| 149 | + |
| 150 | + %{headers_struct | headers: updated_headers} |
| 151 | + end |
| 152 | + |
| 153 | + @doc """ |
| 154 | + Merges two HTTP.Headers structs, with the second taking precedence. |
| 155 | +
|
| 156 | + ## Examples |
| 157 | + iex> headers1 = HTTP.Headers.new([{"Content-Type", "text/plain"}]) |
| 158 | + iex> headers2 = HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 159 | + iex> merged = HTTP.Headers.merge(headers1, headers2) |
| 160 | + iex> HTTP.Headers.get(merged, "Content-Type") |
| 161 | + "application/json" |
| 162 | + |
| 163 | + iex> headers1 = HTTP.Headers.new([{"A", "1"}]) |
| 164 | + iex> headers2 = HTTP.Headers.new([{"B", "2"}]) |
| 165 | + iex> merged = HTTP.Headers.merge(headers1, headers2) |
| 166 | + iex> HTTP.Headers.get(merged, "A") |
| 167 | + "1" |
| 168 | + iex> HTTP.Headers.get(merged, "B") |
| 169 | + "2" |
| 170 | + """ |
| 171 | + @spec merge(t(), t()) :: t() |
| 172 | + def merge(%__MODULE__{headers: headers1}, %__MODULE__{headers: headers2}) do |
| 173 | + map1 = to_map(%__MODULE__{headers: headers1}) |
| 174 | + map2 = to_map(%__MODULE__{headers: headers2}) |
| 175 | + merged = Map.merge(map1, map2) |
| 176 | + from_map(merged) |
| 177 | + end |
| 178 | + |
| 179 | + @doc """ |
| 180 | + Checks if a header exists (case-insensitive). |
| 181 | +
|
| 182 | + ## Examples |
| 183 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 184 | + iex> HTTP.Headers.has?(headers, "content-type") |
| 185 | + true |
| 186 | + |
| 187 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 188 | + iex> HTTP.Headers.has?(headers, "missing") |
| 189 | + false |
| 190 | + """ |
| 191 | + @spec has?(t(), String.t()) :: boolean() |
| 192 | + def has?(%__MODULE__{headers: headers}, name) when is_binary(name) do |
| 193 | + normalized_name = String.downcase(name) |
| 194 | + |
| 195 | + Enum.any?(headers, fn {header_name, _} -> |
| 196 | + String.downcase(header_name) == normalized_name |
| 197 | + end) |
| 198 | + end |
| 199 | + |
| 200 | + @doc """ |
| 201 | + Removes a header by name (case-insensitive). |
| 202 | +
|
| 203 | + ## Examples |
| 204 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}]) |
| 205 | + iex> updated = HTTP.Headers.delete(headers, "content-type") |
| 206 | + iex> HTTP.Headers.has?(updated, "content-type") |
| 207 | + false |
| 208 | + iex> HTTP.Headers.has?(updated, "Authorization") |
| 209 | + true |
| 210 | + """ |
| 211 | + @spec delete(t(), String.t()) :: t() |
| 212 | + def delete(%__MODULE__{headers: headers} = headers_struct, name) when is_binary(name) do |
| 213 | + normalized_name = String.downcase(name) |
| 214 | + |
| 215 | + updated_headers = |
| 216 | + Enum.reject(headers, fn {header_name, _} -> |
| 217 | + String.downcase(header_name) == normalized_name |
| 218 | + end) |
| 219 | + |
| 220 | + %{headers_struct | headers: updated_headers} |
| 221 | + end |
| 222 | + |
| 223 | + @doc """ |
| 224 | + Parses a Content-Type header to extract the media type and parameters. |
| 225 | +
|
| 226 | + ## Examples |
| 227 | + iex> HTTP.Headers.parse_content_type("application/json; charset=utf-8") |
| 228 | + {"application/json", %{"charset" => "utf-8"}} |
| 229 | + |
| 230 | + iex> HTTP.Headers.parse_content_type("text/plain") |
| 231 | + {"text/plain", %{}} |
| 232 | + """ |
| 233 | + @spec parse_content_type(String.t()) :: {String.t(), map()} |
| 234 | + def parse_content_type(content_type) when is_binary(content_type) do |
| 235 | + parts = String.split(content_type, ";") |
| 236 | + media_type = parts |> hd() |> String.trim() |
| 237 | + |
| 238 | + params = |
| 239 | + parts |
| 240 | + |> tl() |
| 241 | + |> Enum.reduce(%{}, fn param, acc -> |
| 242 | + case String.split(param, "=", parts: 2) do |
| 243 | + [key, value] -> Map.put(acc, String.trim(key), String.trim(value)) |
| 244 | + _ -> acc |
| 245 | + end |
| 246 | + end) |
| 247 | + |
| 248 | + {media_type, params} |
| 249 | + end |
| 250 | + |
| 251 | + @doc """ |
| 252 | + Formats headers for display (useful for debugging). |
| 253 | +
|
| 254 | + ## Examples |
| 255 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}, {"Authorization", "Bearer token"}]) |
| 256 | + iex> HTTP.Headers.format(headers) |
| 257 | + "Content-Type: application/json\nAuthorization: Bearer token" |
| 258 | + """ |
| 259 | + @spec format(t()) :: String.t() |
| 260 | + def format(%__MODULE__{headers: headers}) do |
| 261 | + headers |
| 262 | + |> Enum.map(fn {name, value} -> "#{name}: #{value}" end) |
| 263 | + |> Enum.join("\n") |
| 264 | + end |
| 265 | + |
| 266 | + @doc """ |
| 267 | + Returns the underlying list of headers. |
| 268 | +
|
| 269 | + ## Examples |
| 270 | + iex> headers = HTTP.Headers.new([{"Content-Type", "application/json"}]) |
| 271 | + iex> HTTP.Headers.to_list(headers) |
| 272 | + [{"Content-Type", "application/json"}] |
| 273 | + """ |
| 274 | + @spec to_list(t()) :: headers_list |
| 275 | + def to_list(%__MODULE__{headers: headers}) do |
| 276 | + headers |
| 277 | + end |
| 278 | +end |
0 commit comments