Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Add `Waffle.HTTPClient.Finch` as an alternative HTTP client and make `:hackney` an
optional dependency — at least one of `:hackney` or `:finch` is now required for
downloading remote files, with `:hackney` used by default
- Add pluggable HTTP client behaviour (`Waffle.HTTPClient`) with `Waffle.HTTPClient.Hackney`
as the default implementation (#150)
- `:timeout` and `:recv_timeout` error atoms are **unchanged** from previous behaviour
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM elixir:1.13-alpine
FROM elixir:1.17-alpine

# add `convert` utility from imagemagick for image transformations
RUN apk add --no-cache imagemagick
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,21 @@ config :ex_aws,

### Setup an HTTP client

Waffle uses `:hackney` by default to download remote files. You can configure it explicitly:
Waffle downloads remote files through a pluggable `Waffle.HTTPClient`. Two clients are
built in, and both their dependencies are optional — add whichever one you use:

```elixir
# Hackney (default) — add {:hackney, "~> 1.9"} to your deps
config :waffle, :http_client, Waffle.HTTPClient.Hackney

# Finch — add {:finch, "~> 0.18"} to your deps and start a Finch pool
config :waffle, :http_client, Waffle.HTTPClient.Finch
config :waffle, Waffle.HTTPClient.Finch, pool_name: MyApp.Finch
```

At least one of `:hackney` or `:finch` is required if you download remote files;
`Waffle.HTTPClient.Hackney` is used when `:http_client` is left unconfigured.

You can also implement your own client by adopting the `Waffle.HTTPClient` behaviour.

### Define a definition module
Expand Down
2 changes: 2 additions & 0 deletions lib/mix/tasks/g.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule Mix.Tasks.Waffle do
@moduledoc false

defmodule G do
use Mix.Task
import Mix.Generator
Expand Down
30 changes: 30 additions & 0 deletions lib/waffle/behaviors/http_client_behaviour.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ defmodule Waffle.HTTPClient do

- `Waffle.HTTPClient.Hackney` — default, uses `:hackney`. Add `{:hackney, "~> 1.9"}` to
your deps.
- `Waffle.HTTPClient.Finch` — uses `Finch`. Add `{:finch, "~> 0.18"}` to your deps,
start a `Finch` pool in your application supervision tree, and configure
`config :waffle, Waffle.HTTPClient.Finch, pool_name: MyApp.Finch`.

At least one HTTP client dependency is required when downloading remote files.

## Configuration

Expand Down Expand Up @@ -52,4 +57,29 @@ defmodule Waffle.HTTPClient do
{:ok, body()}
| {:ok, body(), filename()}
| {:error, :timeout | :recv_timeout | :service_unavailable | {:http_error, any()}}

@doc """
Parses the `filename` parameter from a `content-disposition` header value.

Handles quoted filenames (`filename="foo.png"`), unquoted filenames
(`filename=foo.png`), and multi-parameter values.

Returns `nil` when no filename is present.
"""
@spec parse_content_disposition(String.t()) :: String.t() | nil
def parse_content_disposition(value) do
value
|> String.split(";")
|> Enum.find_value(&extract_filename_param/1)
end

defp extract_filename_param(part) do
with [key, raw] <- String.split(String.trim(part), "=", parts: 2),
true <- String.downcase(String.trim(key)) == "filename",
name when name != "" <- raw |> String.trim() |> String.trim("\"") do
name
else
_ -> nil
end
end
end
137 changes: 137 additions & 0 deletions lib/waffle/http_client/finch.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
if Code.ensure_loaded?(Finch) do
defmodule Waffle.HTTPClient.Finch do
@moduledoc """
HTTP client implementation using `Finch`.

## Setup

Add `finch` to your dependencies:

{:finch, "~> 0.18"}

Start a `Finch` pool in your application supervision tree:

children = [
{Finch, name: MyApp.Finch}
]

Then configure Waffle to use this client and point it at your pool:

config :waffle, :http_client, Waffle.HTTPClient.Finch
config :waffle, Waffle.HTTPClient.Finch, pool_name: MyApp.Finch

## Options

| Option | Default | Description |
|--------------------|--------------|--------------------------------------------------------|
| `:recv_timeout` | `5_000` | Timeout for receiving a response, in milliseconds |
| `:max_body_length` | `:infinity` | Maximum response body size, in bytes |
| `:max_redirects` | `5` | Maximum number of redirects to follow (`0` disables) |

> #### Unsupported options {: .info}
>
> - `:connect_timeout` — not supported; configure connection settings (e.g. timeouts, TLS) at pool startup via `Finch.start_link/1`
> - `:follow_redirect` — not supported as a boolean; use `:max_redirects` to control redirect behaviour (set to `0` to disable)
"""

@behaviour Waffle.HTTPClient

@impl Waffle.HTTPClient
def get(url, headers, options) do
finch_config = Application.get_env(:waffle, Waffle.HTTPClient.Finch, [])
finch_name = Keyword.get(finch_config, :pool_name, Waffle.Finch)
recv_timeout = Keyword.get(options, :recv_timeout, 5_000)
max_body_length = Keyword.get(options, :max_body_length, :infinity)

finch_options = [receive_timeout: recv_timeout]

request = Finch.build(:get, url, headers)
acc = %{status: nil, headers: [], body: [], bytes: 0}

try do
request
|> Finch.stream_while(finch_name, acc, stream_fun(max_body_length), finch_options)
|> handle_result(url, headers, options)
rescue
exception -> {:error, {:http_error, exception}}
end
end

defp stream_fun(max_body_length) do
fn
{:status, s}, acc -> {:cont, %{acc | status: s}}
{:headers, h}, acc -> {:cont, %{acc | headers: h}}
{:trailers, _}, acc -> {:cont, acc}
{:data, chunk}, acc -> accumulate_chunk(chunk, acc, max_body_length)
end
end

defp accumulate_chunk(chunk, acc, max_body_length) do
new_bytes = acc.bytes + byte_size(chunk)

if max_body_length != :infinity and new_bytes > max_body_length,
do: {:halt, %{acc | body: :over_limit}},
else: {:cont, %{acc | body: [chunk | acc.body], bytes: new_bytes}}
end

defp handle_result({:ok, %{status: 200, body: :over_limit}}, _url, _headers, _options) do
{:error, {:http_error, :body_too_large}}
end

defp handle_result(
{:ok, %{status: 200, headers: resp_headers, body: parts}},
_url,
_headers,
_options
) do
body = parts |> Enum.reverse() |> IO.iodata_to_binary()
filename = find_content_disposition_filename(resp_headers)
if filename, do: {:ok, body, filename}, else: {:ok, body}
end

defp handle_result({:ok, %{status: 503}}, _url, _headers, _options) do
{:error, :service_unavailable}
end

defp handle_result({:ok, %{status: status, headers: resp_headers}}, url, headers, options)
when status in 300..399 do
max_redirects = Keyword.get(options, :max_redirects, 5)

if max_redirects == 0 do
{:error, {:http_error, :too_many_redirects}}
else
case List.keyfind(resp_headers, "location", 0) do
{_, location} ->
new_url = url |> URI.merge(location) |> URI.to_string()
get(new_url, headers, Keyword.put(options, :max_redirects, max_redirects - 1))

nil ->
{:error, {:http_error, {:unexpected_status, status}}}
end
end
end

defp handle_result({:ok, %{status: status}}, _url, _headers, _options) do
{:error, {:http_error, {:unexpected_status, status}}}
end

defp handle_result({:error, reason, _acc}, _url, _headers, _options) do
classify_error(reason)
end

defp classify_error(%{reason: :timeout}), do: {:error, :timeout}

defp classify_error(exception) when is_exception(exception),
do: {:error, {:http_error, exception}}

defp classify_error(reason), do: {:error, {:http_error, reason}}

defp find_content_disposition_filename(headers) do
Enum.find_value(headers, fn {key, value} ->
if String.downcase(key) == "content-disposition" do
Waffle.HTTPClient.parse_content_disposition(value)
end
end)
end
end
end
140 changes: 78 additions & 62 deletions lib/waffle/http_client/hackney.ex
Original file line number Diff line number Diff line change
@@ -1,85 +1,101 @@
defmodule Waffle.HTTPClient.Hackney do
@moduledoc """
Default HTTP client implementation using `:hackney`.
if Code.ensure_loaded?(:hackney) do
defmodule Waffle.HTTPClient.Hackney do
@moduledoc """
Default HTTP client implementation using `:hackney`.

Add `:hackney` to your dependencies:
Add `:hackney` to your dependencies:

{:hackney, "~> 1.9"}
{:hackney, "~> 1.9"}

## Configuration
## Configuration

config :waffle, :http_client, Waffle.HTTPClient.Hackney
config :waffle, :http_client, Waffle.HTTPClient.Hackney

## Options
## Options

| Option | Default | Description |
|--------------------|--------------|--------------------------------------------------------|
| `:recv_timeout` | `5_000` | Timeout for receiving a response, in milliseconds |
| `:connect_timeout` | `10_000` | Timeout for establishing a connection, in milliseconds |
| `:max_body_length` | `:infinity` | Maximum response body size, in bytes |
| `:follow_redirect` | `true` | Whether to follow HTTP redirects automatically |
"""
| Option | Default | Description |
|--------------------|--------------|--------------------------------------------------------|
| `:recv_timeout` | `5_000` | Timeout for receiving a response, in milliseconds |
| `:connect_timeout` | `10_000` | Timeout for establishing a connection, in milliseconds |
| `:max_body_length` | `:infinity` | Maximum response body size, in bytes |
| `:follow_redirect` | `true` | Whether to follow HTTP redirects automatically |
"""

@behaviour Waffle.HTTPClient
@behaviour Waffle.HTTPClient

@impl Waffle.HTTPClient
def get(url, headers, options) do
hackney_options = [
follow_redirect: Keyword.get(options, :follow_redirect, true),
recv_timeout: Keyword.get(options, :recv_timeout, 5_000),
connect_timeout: Keyword.get(options, :connect_timeout, 10_000)
]
@impl Waffle.HTTPClient
def get(url, headers, options) do
hackney_options = [
follow_redirect: Keyword.get(options, :follow_redirect, true),
recv_timeout: Keyword.get(options, :recv_timeout, 5_000),
connect_timeout: Keyword.get(options, :connect_timeout, 10_000)
]

max_body_length = Keyword.get(options, :max_body_length, :infinity)
max_body_length = Keyword.get(options, :max_body_length, :infinity)

case :hackney.get(url, headers, "", hackney_options) do
{:ok, 200, response_headers, client_ref} ->
read_body(client_ref, response_headers, max_body_length)
case :hackney.get(url, headers, "", hackney_options) do
{:ok, 200, response_headers, client_ref} ->
read_body(client_ref, response_headers, max_body_length)

{:ok, 503, _headers, client_ref} ->
:hackney.close(client_ref)
{:error, :service_unavailable}
{:ok, 503, _headers, client_ref} ->
:hackney.close(client_ref)
{:error, :service_unavailable}

{:ok, status, _headers, client_ref} ->
:hackney.close(client_ref)
{:error, {:http_error, status}}
{:ok, status, _headers, client_ref} ->
:hackney.close(client_ref)
{:error, {:http_error, status}}

{:error, reason} ->
normalize_error(reason)
{:error, reason} ->
normalize_error(reason)
end
end
end

defp read_body(client_ref, response_headers, max_body_length) do
case :hackney.body(client_ref, max_body_length) do
{:ok, body} ->
filename =
:hackney_headers.new(response_headers)
|> get_content_disposition_filename()
defp read_body(client_ref, response_headers, max_body_length) do
case :hackney.body(client_ref, max_body_length) do
{:ok, body} ->
filename = find_content_disposition_filename(response_headers)
if filename, do: {:ok, body, filename}, else: {:ok, body}

{:error, reason} ->
:hackney.close(client_ref)
normalize_error(reason)
end
end

if filename, do: {:ok, body, filename}, else: {:ok, body}
# connect timeout: hackney returns %{reason: :timeout}, not a bare atom
defp normalize_error(%{reason: :timeout}), do: {:error, :timeout}
# recv timeout: hackney returns a bare :timeout atom
defp normalize_error(:timeout), do: {:error, :recv_timeout}
defp normalize_error(reason), do: {:error, {:http_error, reason}}

{:error, reason} ->
:hackney.close(client_ref)
normalize_error(reason)
defp find_content_disposition_filename(headers) do
Enum.find_value(headers, fn {key, value} ->
if String.downcase(key) == "content-disposition" do
Waffle.HTTPClient.parse_content_disposition(value)
end
end)
end
end
else
defmodule Waffle.HTTPClient.Hackney do
@moduledoc false

# connect timeout: hackney returns %{reason: :timeout}, not a bare atom
defp normalize_error(%{reason: :timeout}), do: {:error, :timeout}
# recv timeout: hackney returns a bare :timeout atom
defp normalize_error(:timeout), do: {:error, :recv_timeout}
defp normalize_error(reason), do: {:error, {:http_error, reason}}

defp get_content_disposition_filename(headers) do
case :hackney_headers.get_value("content-disposition", headers) do
:undefined ->
nil

value ->
case :hackney_headers.content_disposition(value) do
{_, [{"filename", filename} | _]} -> filename
_ -> nil
end
@behaviour Waffle.HTTPClient

@impl Waffle.HTTPClient
def get(_url, _headers, _options) do
raise """
Waffle.HTTPClient.Hackney is configured but the :hackney dependency is not \
loaded.

Add it to your deps:

{:hackney, "~> 1.9"}

Or configure a different HTTP client, e.g.:

config :waffle, :http_client, Waffle.HTTPClient.Finch
"""
end
end
end
Loading
Loading