Skip to content

Commit cf6db61

Browse files
committed
Version 0.4.0 - HTTP.FetchOptions and HTTP.FormData
## Added - **HTTP.FetchOptions module** - New dedicated module for processing fetch options with full httpc support - **HTTP.FormData module** - New dedicated module for handling form data and multipart/form-data encoding - **File upload support** - Support for file uploads with streaming via File.Stream.t() - **Enhanced option handling** - Support for all :httpc.request options including timeout, SSL, streaming, etc. - **Multiple input formats** - Accept keyword lists, maps, or HTTP.FetchOptions struct - **Type-safe configuration** - Structured approach to HTTP request configuration ## Changed - **HTTP.fetch refactored** - Now uses HTTP.FetchOptions for consistent option processing - **HTTP.Request body parameter** - Now accepts HTTP.FormData.t() for form submissions - **Unified configuration API** - All configuration goes through HTTP.FetchOptions ## Technical Details - **Complete httpc integration** - Proper separation of HttpOptions and Options for :httpc.request - **Streaming support** via File.Stream.t() for memory-efficient file uploads - **Backward compatibility** maintained for existing string/charlist body usage
1 parent 6ca2d2c commit cf6db61

File tree

7 files changed

+433
-11
lines changed

7 files changed

+433
-11
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10-
## [0.3.1] - 2025-07-30
10+
## [0.4.0] - 2025-07-30
1111

1212
### Added
13+
- **HTTP.FetchOptions module** - New dedicated module for processing fetch options with full httpc support
14+
- **Enhanced option handling** - Support for all :httpc.request options including timeout, SSL, streaming, etc.
15+
- **Multiple input formats** - Accept keyword lists, maps, or HTTP.FetchOptions struct
16+
- **Complete httpc integration** - Proper separation of HttpOptions and Options for :httpc.request
17+
- **Type-safe configuration** - Structured approach to HTTP request configuration
18+
- **Comprehensive option coverage** - All documented :httpc.request options supported
1319
- **HTTP.FormData module** - New dedicated module for handling form data and multipart/form-data encoding
1420
- **File upload support** - Support for file uploads with streaming via `File.Stream.t()`
1521
- **Automatic content type detection** - Automatically chooses between `application/x-www-form-urlencoded` and `multipart/form-data`
1622
- **Streaming file uploads** - Efficient large file uploads using Elixir streams
1723
- **Form data builder API** - Fluent interface with `new/0`, `append_field/3`, `append_file/4-5`
1824
- **Multipart boundary generation** - Automatic random boundary generation for multipart requests
19-
- **Comprehensive test coverage** - Full test suite for form data functionality
25+
- **Comprehensive test coverage** - Full test suite for form data and fetch options functionality
2026

2127
### Changed
28+
- **HTTP.fetch refactored** - Now uses HTTP.FetchOptions for consistent option processing
2229
- **HTTP.Request body parameter** - Now accepts `HTTP.FormData.t()` for form submissions
2330
- **Enhanced content type handling** - Automatic content-type detection and header setting
2431
- **Improved multipart encoding** - Proper multipart/form-data format with boundaries
32+
- **Unified configuration API** - All configuration goes through HTTP.FetchOptions
2533

2634
### Technical Details
35+
- **HTTP.FetchOptions struct** with comprehensive field support for all httpc options
2736
- **FormData struct** with parts array for form fields and file uploads
2837
- **Streaming support** via `File.Stream.t()` for memory-efficient file uploads
2938
- **URL encoding fallback** for simple form data without file uploads

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ A modern HTTP client library for Elixir that provides a fetch API similar to web
66

77
- **Browser-like API**: Familiar fetch interface with promises and async/await patterns
88
- **Full HTTP support**: GET, POST, PUT, DELETE, PATCH, HEAD methods
9+
- **Complete httpc integration**: Support for all :httpc.request options
910
- **Form data support**: HTTP.FormData for multipart/form-data and file uploads
1011
- **Streaming file uploads**: Efficient large file uploads using streams
12+
- **Type-safe configuration**: HTTP.FetchOptions for structured request configuration
1113
- **Promise-based**: Async operations with chaining support
1214
- **Request cancellation**: AbortController support for cancelling requests
1315
- **Automatic JSON parsing**: Built-in JSON response handling

lib/http.ex

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ defmodule HTTP do
3333
3434
Returns:
3535
- `%HTTP.Promise{}`: A Promise struct. The caller should `HTTP.Promise.await(promise_struct)` to get the final
36-
`{:ok, %HTTP.Response{}}` or `{:error, reason}`. If the request cannot be initiated
36+
`%HTTP.Response{}` or `{:error, reason}`. If the request cannot be initiated
3737
(e.g., invalid URL, bad arguments), the Promise will contain an error result
3838
when awaited.
3939
@@ -42,7 +42,7 @@ defmodule HTTP do
4242
# GET request and awaiting JSON
4343
promise_json = HTTP.fetch("https://jsonplaceholder.typicode.com/todos/1")
4444
case HTTP.Promise.await(promise_json) do
45-
{:ok, %HTTP.Response{} = response} ->
45+
%HTTP.Response{} = response ->
4646
case HTTP.Response.json(response) do
4747
{:ok, json_body} ->
4848
IO.puts "GET JSON successful! Title: \#{json_body["title"]}"
@@ -56,7 +56,7 @@ defmodule HTTP do
5656
# GET request and awaiting text
5757
promise_text = HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
5858
case HTTP.Promise.await(promise_text) do
59-
{:ok, %HTTP.Response{} = response} ->
59+
%HTTP.Response{} = response ->
6060
text_body = HTTP.Response.text(response)
6161
IO.puts "GET Text successful! First 50 chars: \#{String.slice(text_body, 0, 50)}..."
6262
{:error, reason} ->
@@ -132,7 +132,7 @@ defmodule HTTP do
132132
# Request with custom :httpc options (e.g., longer timeout for request options)
133133
delayed_promise = HTTP.fetch("https://httpbin.org/delay/5", options: [timeout: 10_000])
134134
case HTTP.Promise.await(delayed_promise) do
135-
{:ok, %HTTP.Response{status: status}} ->
135+
%HTTP.Response{status: status} ->
136136
IO.puts "Delayed request successful! Status: \#{status}"
137137
{:error, reason} ->
138138
IO.inspect reason, label: "Delayed Request Result"
@@ -152,7 +152,7 @@ defmodule HTTP do
152152
153153
# Await the result of the abortable promise
154154
case HTTP.Promise.await(abortable_promise) do
155-
{:ok, %HTTP.Response{status: status}} ->
155+
%HTTP.Response{status: status} ->
156156
IO.puts "Abortable request completed successfully! Status: \#{status}"
157157
{:error, reason} ->
158158
IO.inspect reason, label: "Abortable Request Result"
@@ -215,8 +215,7 @@ defmodule HTTP do
215215
defp handle_response(request_id, url) do
216216
receive do
217217
{:http, {^request_id, response_from_httpc}} ->
218-
response = handle_httpc_response(response_from_httpc, url)
219-
{:ok, response}
218+
handle_httpc_response(response_from_httpc, url)
220219

221220
_ ->
222221
throw(:request_interrupted_or_unexpected_message)
@@ -232,7 +231,7 @@ defmodule HTTP do
232231
Request.t(),
233232
pid(),
234233
pid() | nil
235-
) :: {:ok, Response.t()} | {:error, term()}
234+
) :: Response.t() | {:error, term()}
236235
def handle_async_request(request, _calling_pid, abort_controller_pid) do
237236
# Use a try/catch block to convert `throw` from handle_httpc_response into an {:error, reason} tuple
238237
try do

lib/http/fetch_options.ex

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
defmodule HTTP.FetchOptions do
2+
@moduledoc """
3+
Processes fetch options for HTTP requests, supporting flat map, keyword list,
4+
and HTTP.FetchOptions struct formats. Handles conversion to :httpc.request arguments.
5+
"""
6+
7+
defstruct method: :get,
8+
headers: %HTTP.Headers{},
9+
content_type: nil,
10+
body: nil,
11+
options: [],
12+
opts: [sync: false],
13+
signal: nil,
14+
timeout: nil,
15+
connect_timeout: nil,
16+
ssl: nil,
17+
autoredirect: nil,
18+
proxy_auth: nil,
19+
version: nil,
20+
relaxed: nil,
21+
stream: nil,
22+
body_format: nil,
23+
full_result: nil,
24+
headers_as_is: nil,
25+
socket_opts: nil,
26+
receiver: nil,
27+
ipv6_host_with_brackets: nil
28+
29+
@type t :: %__MODULE__{
30+
method: atom(),
31+
headers: HTTP.Headers.t(),
32+
content_type: String.t() | nil,
33+
body: any(),
34+
options: keyword(),
35+
opts: keyword(),
36+
signal: any() | nil,
37+
timeout: integer() | nil,
38+
connect_timeout: integer() | nil,
39+
ssl: list() | nil,
40+
autoredirect: boolean() | nil,
41+
proxy_auth: tuple() | nil,
42+
version: String.t() | nil,
43+
relaxed: boolean() | nil,
44+
stream: atom() | tuple() | nil,
45+
body_format: atom() | nil,
46+
full_result: boolean() | nil,
47+
headers_as_is: boolean() | nil,
48+
socket_opts: list() | nil,
49+
receiver: pid() | function() | tuple() | nil,
50+
ipv6_host_with_brackets: boolean() | nil
51+
}
52+
53+
@doc """
54+
Creates a new FetchOptions struct from various input formats.
55+
Supports flat map, keyword list, or existing FetchOptions struct.
56+
"""
57+
@spec new(map() | keyword() | t()) :: t()
58+
def new(options) when is_map(options) do
59+
options
60+
|> Map.to_list()
61+
|> new()
62+
end
63+
64+
def new(options) when is_list(options) do
65+
%__MODULE__{}
66+
|> merge_options(options)
67+
|> normalize_options()
68+
end
69+
70+
def new(%__MODULE__{} = options) do
71+
options
72+
|> normalize_options()
73+
end
74+
75+
@doc """
76+
Converts FetchOptions to HTTP options for :httpc.request 3rd argument.
77+
Returns keyword list of HttpOptions.
78+
"""
79+
@spec to_http_options(t()) :: keyword()
80+
def to_http_options(%__MODULE__{} = options) do
81+
[]
82+
|> maybe_add(:timeout, options.timeout)
83+
|> maybe_add(:connect_timeout, options.connect_timeout)
84+
|> maybe_add(:ssl, options.ssl)
85+
|> maybe_add(:autoredirect, options.autoredirect)
86+
|> maybe_add(:proxy_auth, options.proxy_auth)
87+
|> maybe_add(:version, options.version)
88+
|> maybe_add(:relaxed, options.relaxed)
89+
end
90+
91+
@doc """
92+
Converts FetchOptions to options for :httpc.request 4th argument.
93+
Returns keyword list of Options.
94+
"""
95+
@spec to_options(t()) :: keyword()
96+
def to_options(%__MODULE__{} = options) do
97+
options.opts
98+
|> Keyword.put_new(:sync, false)
99+
|> maybe_add(:stream, options.stream)
100+
|> maybe_add(:body_format, options.body_format)
101+
|> maybe_add(:full_result, options.full_result)
102+
|> maybe_add(:headers_as_is, options.headers_as_is)
103+
|> maybe_add(:socket_opts, options.socket_opts)
104+
|> maybe_add(:receiver, options.receiver)
105+
|> maybe_add(:ipv6_host_with_brackets, options.ipv6_host_with_brackets)
106+
end
107+
108+
@doc """
109+
Extracts the HTTP method from options.
110+
"""
111+
@spec get_method(t()) :: atom()
112+
def get_method(%__MODULE__{method: method}), do: method
113+
114+
@doc """
115+
Extracts headers from options.
116+
"""
117+
@spec get_headers(t()) :: HTTP.Headers.t()
118+
def get_headers(%__MODULE__{headers: headers}), do: headers
119+
120+
@doc """
121+
Extracts body from options.
122+
"""
123+
@spec get_body(t()) :: any()
124+
def get_body(%__MODULE__{body: body}), do: body
125+
126+
@doc """
127+
Extracts content type from options.
128+
"""
129+
@spec get_content_type(t()) :: String.t() | nil
130+
def get_content_type(%__MODULE__{content_type: content_type}), do: content_type
131+
132+
defp merge_options(%__MODULE__{} = struct, options) do
133+
Enum.reduce(options, struct, fn
134+
{:method, method}, acc ->
135+
%{acc | method: normalize_method(method)}
136+
137+
{:headers, headers}, acc ->
138+
%{acc | headers: normalize_headers(headers)}
139+
140+
{:content_type, content_type}, acc ->
141+
%{acc | content_type: content_type}
142+
143+
{:body, body}, acc ->
144+
%{acc | body: body}
145+
146+
{:options, options}, acc ->
147+
%{acc | options: Keyword.merge(acc.options, List.wrap(options))}
148+
149+
{:opts, opts}, acc ->
150+
%{acc | opts: Keyword.merge(acc.opts, List.wrap(opts))}
151+
152+
{:signal, signal}, acc ->
153+
%{acc | signal: signal}
154+
155+
{:timeout, timeout}, acc ->
156+
%{acc | timeout: timeout}
157+
158+
{:connect_timeout, connect_timeout}, acc ->
159+
%{acc | connect_timeout: connect_timeout}
160+
161+
{:ssl, ssl}, acc ->
162+
%{acc | ssl: ssl}
163+
164+
{:autoredirect, autoredirect}, acc ->
165+
%{acc | autoredirect: autoredirect}
166+
167+
{:proxy_auth, proxy_auth}, acc ->
168+
%{acc | proxy_auth: proxy_auth}
169+
170+
{:version, version}, acc ->
171+
%{acc | version: version}
172+
173+
{:relaxed, relaxed}, acc ->
174+
%{acc | relaxed: relaxed}
175+
176+
{:stream, stream}, acc ->
177+
%{acc | stream: stream}
178+
179+
{:body_format, body_format}, acc ->
180+
%{acc | body_format: body_format}
181+
182+
{:full_result, full_result}, acc ->
183+
%{acc | full_result: full_result}
184+
185+
{:headers_as_is, headers_as_is}, acc ->
186+
%{acc | headers_as_is: headers_as_is}
187+
188+
{:socket_opts, socket_opts}, acc ->
189+
%{acc | socket_opts: socket_opts}
190+
191+
{:receiver, receiver}, acc ->
192+
%{acc | receiver: receiver}
193+
194+
{:ipv6_host_with_brackets, ipv6_host_with_brackets}, acc ->
195+
%{acc | ipv6_host_with_brackets: ipv6_host_with_brackets}
196+
197+
{key, value}, acc ->
198+
handle_unknown_option(key, value, acc)
199+
end)
200+
end
201+
202+
defp normalize_headers(%HTTP.Headers{} = headers), do: headers
203+
defp normalize_headers(headers) when is_list(headers), do: HTTP.Headers.new(headers)
204+
defp normalize_headers(headers) when is_map(headers), do: HTTP.Headers.from_map(headers)
205+
defp normalize_headers(_), do: HTTP.Headers.new()
206+
207+
defp normalize_options(%__MODULE__{} = options) do
208+
%{options | method: normalize_method(options.method)}
209+
end
210+
211+
defp normalize_method(method) when is_binary(method) do
212+
method |> String.downcase() |> String.to_atom()
213+
end
214+
215+
defp normalize_method(method) when is_atom(method) do
216+
method
217+
end
218+
219+
defp handle_unknown_option(key, value, acc) do
220+
if Keyword.keyword?(acc.options) do
221+
%{acc | options: Keyword.put(acc.options, key, value)}
222+
else
223+
acc
224+
end
225+
end
226+
227+
defp maybe_add(list, _key, nil), do: list
228+
defp maybe_add(list, key, value), do: Keyword.put(list, key, value)
229+
end

0 commit comments

Comments
 (0)