Skip to content

Commit eb06f59

Browse files
committed
Release version 0.2.0 with HTTP.Headers module
## [0.2.0] - 2025-07-30 ### Added - HTTP.Headers module - New dedicated module for HTTP header processing - Structured headers - HTTP.Request and HTTP.Response now use HTTP.Headers.t() struct - Header manipulation utilities - new/1, get/2, set/3, merge/2, delete/2, etc. - Header parsing - Content-Type parsing with media type and parameters extraction - Case-insensitive header access via HTTP.Headers.get/2 and HTTP.Response.get_header/2 - Backward compatibility - HTTP.fetch still accepts list/map formats with auto-conversion ### Changed - Refactored header storage from plain lists to HTTP.Headers struct - Enhanced type safety with proper struct types throughout the codebase - Updated Response API - Added get_header/2 and content_type/1 helper methods ### Technical Details - New HTTP.Headers struct with comprehensive header manipulation capabilities - Immutable operations - All header operations return new struct instances - Automatic conversion - Input formats (list/map) auto-converted to struct - Enhanced documentation with examples for new header functionality - Maintained backward compatibility - Existing code continues to work
1 parent ea18b1b commit eb06f59

File tree

8 files changed

+569
-34
lines changed

8 files changed

+569
-34
lines changed

CHANGELOG.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0] - 2025-07-30
11+
1012
### Added
11-
- Initial implementation of HTTP fetch API for Elixir
12-
- Promise-based asynchronous interface using Elixir Tasks
13-
- Request cancellation support via AbortController
14-
- JSON and text response parsing helpers
15-
- Basic HTTP request/response structs
16-
- Support for custom headers and request options
17-
- Content-Type handling for requests with bodies
18-
- Default timeout of 120 seconds for requests
13+
- **HTTP.Headers module** - New dedicated module for HTTP header processing
14+
- **Structured headers** - HTTP.Request and HTTP.Response now use `HTTP.Headers.t()` struct
15+
- **Header manipulation utilities** - `new/1`, `get/2`, `set/3`, `merge/2`, `delete/2`, etc.
16+
- **Header parsing** - Content-Type parsing with media type and parameters extraction
17+
- **Case-insensitive header access** via `HTTP.Headers.get/2` and `HTTP.Response.get_header/2`
18+
- **Backward compatibility** - HTTP.fetch still accepts list/map formats with auto-conversion
19+
20+
### Changed
21+
- **Refactored header storage** from plain lists to `HTTP.Headers` struct
22+
- **Enhanced type safety** with proper struct types throughout the codebase
23+
- **Updated Response API** - Added `get_header/2` and `content_type/1` helper methods
1924

2025
### Technical Details
21-
- Uses Erlang's built-in `:httpc` module for HTTP operations
22-
- Requires Elixir 1.18+ for built-in JSON support
23-
- Depends on Erlang standard library modules `:inets` and `:httpc`
24-
- Provides both sync and async operation modes (default: async)
25-
- Includes comprehensive test suite covering core functionality
26+
- **New HTTP.Headers struct** with comprehensive header manipulation capabilities
27+
- **Immutable operations** - All header operations return new struct instances
28+
- **Automatic conversion** - Input formats (list/map) auto-converted to struct
29+
- **Enhanced documentation** with examples for new header functionality
30+
- **Maintained backward compatibility** - Existing code continues to work
2631

2732
## [0.1.0] - 2025-07-30
2833

lib/http.ex

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ defmodule HTTP do
1919
Supported options:
2020
- `:method`: The HTTP method (e.g., "GET", "POST"). Defaults to "GET".
2121
Can be a string or an atom (e.g., "GET" or :get).
22-
- `:headers`: A map of request headers (e.g., %{"Content-Type" => "application/json"}).
23-
These will be converted to a list of `{key, value}` tuples.
22+
- `:headers`: A list of request headers as `{name, value}` tuples (e.g., [{"Content-Type", "application/json"}])
23+
or a map that will be converted to the tuple format.
2424
- `:body`: The request body (should be a binary or a string that can be coerced to binary).
2525
- `:content_type`: The Content-Type header value. If not provided for methods with body,
2626
defaults to "application/octet-stream" in `Request.to_httpc_args`.
@@ -118,7 +118,7 @@ defmodule HTTP do
118118
# If you're using Elixir 1.18+, JSON.encode! is built-in. Otherwise, you'd need a library like Poison.
119119
# promise_post = HTTP.fetch("https://jsonplaceholder.typicode.com/posts",
120120
# method: "POST",
121-
# headers: %{"Accept" => "application/json"},
121+
# headers: [{"Accept", "application/json"}],
122122
# content_type: "application/json",
123123
# body: JSON.encode!(%{title: "foo", body: "bar", userId: 1})
124124
# )
@@ -168,17 +168,23 @@ defmodule HTTP do
168168
erlang_method =
169169
if is_atom(method), do: method, else: String.to_existing_atom(String.downcase(method))
170170

171-
headers = Keyword.get(init, :headers, %{})
172-
# Convert headers map to list of tuples as expected by Request.headers
173-
formatted_headers = Enum.into(headers, [])
171+
headers = Keyword.get(init, :headers, [])
172+
# Ensure headers are converted to HTTP.Headers struct
173+
headers_struct =
174+
case headers do
175+
%HTTP.Headers{} = headers -> headers
176+
headers when is_list(headers) -> HTTP.Headers.new(headers)
177+
headers when is_map(headers) -> HTTP.Headers.from_map(headers)
178+
_ -> HTTP.Headers.new()
179+
end
174180

175181
# Extract AbortController PID if provided
176182
abort_controller_pid = Keyword.get(init, :signal)
177183

178184
request = %Request{
179185
url: url,
180186
method: erlang_method,
181-
headers: formatted_headers,
187+
headers: headers_struct,
182188
body: Keyword.get(init, :body),
183189
content_type: Keyword.get(init, :content_type),
184190
# Maps to Request.options (3rd arg for :httpc.request)
@@ -261,11 +267,11 @@ defmodule HTTP do
261267
# Success case: returns %Response{} directly
262268
@spec handle_httpc_response(httpc_response_tuple(), String.t() | nil) :: Response.t()
263269
defp handle_httpc_response({{_version, status, _reason_phrase}, httpc_headers, body}, url) do
264-
# Convert :httpc's header list to a map for HTTP.Response
270+
# Convert :httpc's header list to HTTP.Headers struct
265271
response_headers =
266272
httpc_headers
267273
|> Enum.map(fn {key, val} -> {to_string(key), to_string(val)} end)
268-
|> Enum.into(%{})
274+
|> HTTP.Headers.new()
269275

270276
# Convert body from charlist (iodata) to binary if it's not already
271277
binary_body =

lib/http/headers.ex

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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

Comments
 (0)