Skip to content
Closed
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
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
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,20 @@ Already using Waffle in your org? I'd love to learn more, let's [have a chat](ht

## Installation

Add the latest stable release to your `mix.exs` file, along with the
required dependencies for `ExAws` if appropriate:
Add the latest stable release to your `mix.exs` file:

```elixir
defp deps do
[
{:waffle, "~> 1.1"},

# HTTP client — required for downloading remote files (choose one):
{:hackney, "~> 1.9"}, # default
# {:finch, "~> 0.18"}, # alternative

# If using S3:
{:ex_aws, "~> 2.1.2"},
{:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"},
{:ex_aws, "~> 2.1"},
{:ex_aws_s3, "~> 2.1"},
{:sweet_xml, "~> 0.6"}
]
end
Expand Down Expand Up @@ -80,6 +82,42 @@ config :ex_aws,
# any configurations provided by https://github.com/ex-aws/ex_aws
```

### Setup an HTTP client

Waffle uses an HTTP client to download remote files. Two built-in implementations are provided:

**Hackney** (default):

```elixir
# mix.exs
{:hackney, "~> 1.9"}
```

```elixir
# config.exs
config :waffle, :http_client, Waffle.HTTPClient.Hackney
```

**Finch**:

```elixir
# mix.exs
{:finch, "~> 0.18"}
```

```elixir
# application.ex
children = [
{Finch, name: MyApp.Finch}
]

# config.exs
config :waffle, :http_client, Waffle.HTTPClient.Finch
config :waffle, Waffle.HTTPClient.Finch, pool_name: MyApp.Finch
```

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

### Define a definition module

Waffle requires a **definition module** which contains the relevant
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
87 changes: 87 additions & 0 deletions lib/waffle/behaviors/http_client_behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
defmodule Waffle.HTTPClient do
@moduledoc """
Behaviour for pluggable HTTP clients used when downloading remote files.

## Built-in implementations

- `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

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

## Writing a custom client

Implement the `c:get/3` callback and configure Waffle to use your module:

config :waffle, :http_client, MyApp.HTTPClient

### Options

Waffle passes the following options (all values come from application config):

| Option | Type | Default | Description |
|--------------------|-------------------------|------------|------------------------------------------|
| `:recv_timeout` | `non_neg_integer()` | `5_000` | Timeout for receiving a response (ms) |
| `:connect_timeout` | `non_neg_integer()` | `10_000` | Timeout for establishing a connection (ms) |
| `:max_body_length` | `non_neg_integer() \| :infinity` | `:infinity` | Maximum allowed response body size (bytes) |
| `:follow_redirect` | `boolean()` | `true` | Whether to follow HTTP redirects |
| `:max_retries` | `non_neg_integer()` | `3` | Number of retries on timeout or 503 |
| `:backoff_factor` | `non_neg_integer()` | `1_000` | Base backoff delay between retries (ms) |
| `:backoff_max` | `non_neg_integer()` | `30_000` | Maximum backoff delay between retries (ms) |

### Return values

| Pattern | Meaning |
|----------------------------------------|--------------------------------------------------|
| `{:ok, body}` | Successful response, no filename in headers |
| `{:ok, body, filename}` | Successful response with `content-disposition` filename |
| `{:error, :timeout}` | Request timed out — Waffle will retry |
| `{:error, :service_unavailable}` | Server returned 503 — Waffle will retry |
| `{:error, {:http_error, reason}}` | Non-retryable error |
"""

@type body :: binary()
@type filename :: String.t()
@type option ::
{:recv_timeout, non_neg_integer()}
| {:connect_timeout, non_neg_integer()}
| {:max_body_length, non_neg_integer() | :infinity}
| {:follow_redirect, boolean()}

@callback get(url :: String.t(), headers :: list(), options :: [option()]) ::
{:ok, body()}
| {:ok, body(), filename()}
| {:error, :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
Comment thread
klacointe marked this conversation as resolved.
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
80 changes: 15 additions & 65 deletions lib/waffle/file.ex
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,14 @@ defmodule Waffle.File do
end
end

# hackney :connect_timeout - timeout used when establishing a connection, in milliseconds
# hackney :recv_timeout - timeout used when receiving from a connection, in milliseconds
# hackney :max_body_length - maximum size of the file to download, in bytes. Defaults to :infinity
# :backoff_max - maximum backoff time, in milliseconds
# :backoff_factor - a backoff factor to apply between attempts, in milliseconds
defp get_remote_path(remote_path, definition) do
headers = definition.remote_file_headers(remote_path)

options = [
follow_redirect: true,
recv_timeout: Application.get_env(:waffle, :recv_timeout, 5_000),
connect_timeout: Application.get_env(:waffle, :connect_timeout, 10_000),
max_body_length: Application.get_env(:waffle, :max_body_length, :infinity),
max_retries: Application.get_env(:waffle, :max_retries, 3),
backoff_factor: Application.get_env(:waffle, :backoff_factor, 1000),
backoff_max: Application.get_env(:waffle, :backoff_max, 30_000)
Expand All @@ -206,74 +202,28 @@ defmodule Waffle.File do
end

defp request(remote_path, headers, options, tries \\ 0) do
with {:ok, 200, response_headers, client_ref} <-
:hackney.get(URI.to_string(remote_path), headers, "", options),
res when elem(res, 0) == :ok <- body(client_ref, response_headers) do
res
else
{:error, %{reason: :timeout}} ->
case retry(tries, options) do
{:ok, :retry} -> request(remote_path, headers, options, tries + 1)
{:error, :out_of_tries} -> {:error, :timeout}
end

{:error, :timeout} ->
case retry(tries, options) do
{:ok, :retry} -> request(remote_path, headers, options, tries + 1)
{:error, :out_of_tries} -> {:error, :recv_timeout}
end

{:ok, 503, _headers, client_ref} = response ->
case retry(tries, options) do
{:ok, :retry} ->
request(remote_path, headers, options, tries + 1)

{:error, :out_of_tries} ->
:hackney.close(client_ref)
{:error, {:waffle_hackney_error, response}}
end

{:ok, _, _, client_ref} = response ->
:hackney.close(client_ref)
{:error, {:waffle_hackney_error, response}}

_err ->
{:error, :waffle_hackney_error}
end
end

defp body(client_ref, response_headers) do
max_body_length = Application.get_env(:waffle, :max_body_length, :infinity)
http_client = Application.get_env(:waffle, :http_client, Waffle.HTTPClient.Hackney)
url = URI.to_string(remote_path)

case :hackney.body(client_ref, max_body_length) do
case http_client.get(url, headers, options) do
{:ok, body} ->
response_headers = :hackney_headers.new(response_headers)
filename = content_disposition(response_headers)
{:ok, body}

if is_nil(filename) do
{:ok, body}
else
{:ok, body, filename}
end
{:ok, body, filename} ->
{:ok, body, filename}

err ->
{:error, reason} when reason in [:timeout, :recv_timeout, :service_unavailable] ->
retry_or_error(remote_path, headers, options, tries, reason)

{:error, _} = err ->
err
end
end

defp content_disposition(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
defp retry_or_error(remote_path, headers, options, tries, reason) do
case retry(tries, options) do
{:ok, :retry} -> request(remote_path, headers, options, tries + 1)
{:error, :out_of_tries} -> {:error, reason}
end
end

Expand Down
Loading
Loading