HTTP (http_fetch v0.5.0)
A module simulating the web browser's Fetch API in Elixir, using :httpc as the foundation. Provides HTTP.Request, HTTP.Response, HTTP.Promise and a global-like fetch function with asynchronous capabilities and an AbortController for request cancellation.
Summary
Functions
Performs an HTTP request, similar to global.fetch
in web browsers.
Uses Erlang's built-in :httpc
module asynchronously (sync: false
).
Types
Functions
Performs an HTTP request, similar to global.fetch
in web browsers.
Uses Erlang's built-in :httpc
module asynchronously (sync: false
).
Arguments:
url
: The URL to fetch (string or URI struct).init
: An optional keyword list or map of options for the request.Supported options: - `:method`: The HTTP method (e.g., "GET", "POST"). Defaults to "GET". Can be a string or an atom (e.g., "GET" or :get). - `:headers`: A list of request headers as `{name, value}` tuples (e.g., [{"Content-Type", "application/json"}]) or a map that will be converted to the tuple format. - `:body`: The request body (should be a binary or a string that can be coerced to binary). - `:content_type`: The Content-Type header value. If not provided for methods with body, defaults to "application/octet-stream" in `Request.to_httpc_args`. - `:options`: A keyword list of options directly passed as the 3rd argument to `:httpc.request` (e.g., `timeout: 10_000`, `connect_timeout: 5_000`). - `:client_opts`: A keyword list of options directly passed as the 4th argument to `:httpc.request` (e.g., `sync: false`, `body_format: :binary`). Overrides `Request` defaults. - `:signal`: An `HTTP.AbortController` PID. If provided, the request can be aborted via this controller.
Returns:
%HTTP.Promise{}
: A Promise struct. The caller shouldHTTP.Promise.await(promise_struct)
to get the final`%HTTP.Response{}` or `{:error, reason}`. If the request cannot be initiated (e.g., invalid URL, bad arguments), the Promise will contain an error result when awaited.
Example Usage:
# GET request and awaiting JSON
promise_json = HTTP.fetch("https://jsonplaceholder.typicode.com/todos/1")
case HTTP.Promise.await(promise_json) do
%HTTP.Response{} = response ->
case HTTP.Response.json(response) do
{:ok, json_body} ->
IO.puts "GET JSON successful! Title: #{json_body["title"]}"
{:error, reason} ->
IO.inspect reason, label: "JSON Parse Error"
end
{:error, reason} ->
IO.inspect reason, label: "GET Error for JSON"
end
# GET request and awaiting text
promise_text = HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
case HTTP.Promise.await(promise_text) do
%HTTP.Response{} = response ->
text_body = HTTP.Response.text(response)
IO.puts "GET Text successful! First 50 chars: #{String.slice(text_body, 0, 50)}..."
{:error, reason} ->
IO.inspect reason, label: "GET Error for Text"
end
# Simple Promise chaining: fetch -> parse JSON -> print title
HTTP.fetch("https://jsonplaceholder.typicode.com/todos/2")
|> HTTP.Promise.then(fn %HTTP.Response{} = response ->
HTTP.Response.json(response)
end)
|> HTTP.Promise.then(fn {:ok, json_body} ->
IO.puts "Chained JSON successful! Title: #{json_body["title"]}"
end)
|> HTTP.Promise.await() # Await the final chained promise to ensure execution
# Promise chaining with error handling: fetch -> parse JSON -> handle success or error
HTTP.fetch("https://jsonplaceholder.typicode.com/nonexistent") # This URL will cause an error
|> HTTP.Promise.then(
fn %HTTP.Response{} = response ->
IO.puts "This success branch should not be called for a 404!"
HTTP.Response.json(response)
end,
fn reason ->
IO.inspect reason, label: "Chained Error Handler Caught"
{:error, :handled_error} # Return an error tuple to propagate rejection
end
)
|> HTTP.Promise.await() # Await the final chained promise
# Chaining where a callback returns another promise
HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
|> HTTP.Promise.then(fn %HTTP.Response{} = response ->
# Simulate fetching comments for the post
post_id = case HTTP.Response.json(response) do
{:ok, %{"id" => id}} -> id
_ -> nil
end
if post_id do
IO.puts "Fetched post #{post_id}. Now fetching comments..."
HTTP.fetch("https://jsonplaceholder.typicode.com/posts/#{post_id}/comments")
else
# If post_id is nil, we want to reject this branch of the promise chain
throw {:error, :post_id_not_found}
end
end)
|> HTTP.Promise.then(fn comments_response ->
case HTTP.Response.json(comments_response) do
{:ok, comments} ->
IO.puts "Successfully fetched #{length(comments)} comments for the post."
{:error, reason} ->
IO.inspect reason, label: "Failed to parse comments JSON"
end
end)
|> HTTP.Promise.await()
# POST request with JSON body and headers
# If you're using Elixir 1.18+, JSON.encode! is built-in. Otherwise, you'd need a library like Poison.
# promise_post = HTTP.fetch("https://jsonplaceholder.typicode.com/posts",
# method: "POST",
# headers: [{"Accept", "application/json"}],
# content_type: "application/json",
# body: JSON.encode!(%{title: "foo", body: "bar", userId: 1})
# )
# case HTTP.Promise.await(promise_post) do
# {:ok, %HTTP.Response{status: 201, body: body}} ->
# IO.puts "POST successful! Body: #{body}"
# {:error, reason} ->
# IO.inspect reason, label: "POST Error"
# end
# Request with custom :httpc options (e.g., longer timeout for request options)
delayed_promise = HTTP.fetch("https://httpbin.org/delay/5", options: [timeout: 10_000])
case HTTP.Promise.await(delayed_promise) do
%HTTP.Response{status: status} ->
IO.puts "Delayed request successful! Status: #{status}"
{:error, reason} ->
IO.inspect reason, label: "Delayed Request Result"
end
# Abortable request example
controller = HTTP.AbortController.new() # Create a new controller
IO.puts "Fetching a long request that will be aborted..."
abortable_promise = HTTP.fetch("https://httpbin.org/delay/10", signal: controller, options: [timeout: 20_000])
# Simulate some work, then abort after a short delay
Task.start_link(fn ->
:timer.sleep(2000) # Wait 2 seconds
IO.puts "Attempting to abort the request after 2 seconds..."
HTTP.AbortController.abort(controller)
end)
# Await the result of the abortable promise
case HTTP.Promise.await(abortable_promise) do
%HTTP.Response{status: status} ->
IO.puts "Abortable request completed successfully! Status: #{status}"
{:error, reason} ->
IO.inspect reason, label: "Abortable Request Result"
if reason == :econnrefused or reason == :nxdomain do # Common errors for aborted :httpc requests
IO.puts "Request was likely aborted. Reason: #{inspect(reason)}"
end
end