defmodule Image.Plug.Options do
@moduledoc """
Validated, frozen configuration for `Image.Plug`.
Built once at compile/boot time by `Image.Plug.init/1`. The
request path then only reads struct fields.
Construct with `new!/1`. Direct struct construction is discouraged —
the validator enforces invariants such as "the provider module must
export `parse/2`" that are not visible from the struct shape alone.
"""
@type module_with_options :: {module(), keyword()}
@typedoc """
Signing configuration. `nil` disables signing entirely.
When set, every request URL is verified against the configured
`:keys` list (HMAC-SHA256). When `:required?` is `true`, an
unsigned URL produces `:signature_required`. When `:required?` is
`false`, unsigned URLs pass through but a signed URL with an
invalid signature still 401s — defense in depth.
"""
@type signing_options ::
nil
| %{
required(:keys) => [String.t(), ...],
optional(:required?) => boolean()
}
@type t :: %__MODULE__{
provider: module(),
provider_options: keyword(),
source_resolver: module(),
source_resolver_options: keyword(),
variant_store: module(),
variant_store_options: keyword(),
on_error: Image.Plug.Pipeline.on_error(),
max_pixels: pos_integer(),
request_timeout: pos_integer(),
telemetry_prefix: [atom(), ...],
signing: signing_options()
}
@enforce_keys [:provider, :source_resolver]
defstruct provider: nil,
provider_options: [],
source_resolver: nil,
source_resolver_options: [],
variant_store: Image.Plug.VariantStore.ETS,
variant_store_options: [],
on_error: :auto,
max_pixels: 25_000_000,
request_timeout: 10_000,
telemetry_prefix: [:image_plug],
signing: nil
@doc """
Validates a keyword list and returns an `Image.Plug.Options`
struct.
### Arguments
* `options` is a keyword list. The `:provider` and `:source_resolver`
keys are required. Each must be a `{module, options}` tuple.
### Options
* `:provider` (required) — `{module, options}` for an
`Image.Plug.Provider` implementation.
* `:source_resolver` (required) — `{module, options}` for an
`Image.Plug.SourceResolver` implementation.
* `:variant_store` — `{module, options}` for an
`Image.Plug.VariantStore` implementation. Defaults to
`{Image.Plug.VariantStore.ETS, []}`.
* `:on_error` — error policy atom or `{:status, code}` tuple.
Defaults to `:auto`.
* `:max_pixels` — soft upper bound on the output pixel count.
Defaults to `25_000_000`.
* `:request_timeout` — total per-request budget in milliseconds.
Defaults to `10_000`.
* `:telemetry_prefix` — list of atoms prepended to telemetry event
names. Defaults to `[:image_plug]`.
### Returns
* An `Image.Plug.Options` struct.
### Examples
iex> defmodule TestProv do
...> @behaviour Image.Plug.Provider
...> @impl true
...> def parse(_conn, _options), do: {:error, Image.Plug.Error.new(:not_implemented, "test")}
...> end
iex> defmodule TestRes do
...> @behaviour Image.Plug.SourceResolver
...> @impl true
...> def load(_source, _options), do: {:error, Image.Plug.Error.new(:not_implemented, "test")}
...> end
iex> options = Image.Plug.Options.new!(
...> provider: {TestProv, []},
...> source_resolver: {TestRes, []}
...> )
iex> options.provider
TestProv
iex> options.on_error
:auto
"""
@spec new!(keyword()) :: t()
def new!(options) when is_list(options) do
{provider_mod, provider_opts} = take_module!(options, :provider)
{resolver_mod, resolver_opts} = take_module!(options, :source_resolver)
{store_mod, store_opts} =
Keyword.get(options, :variant_store, {Image.Plug.VariantStore.ETS, []})
|> normalise_module!(:variant_store)
on_error = Keyword.get(options, :on_error, :auto)
validate_on_error!(on_error)
%__MODULE__{
provider: provider_mod,
provider_options: provider_opts,
source_resolver: resolver_mod,
source_resolver_options: resolver_opts,
variant_store: store_mod,
variant_store_options: store_opts,
on_error: on_error,
max_pixels: Keyword.get(options, :max_pixels, 25_000_000),
request_timeout: Keyword.get(options, :request_timeout, 10_000),
telemetry_prefix: Keyword.get(options, :telemetry_prefix, [:image_plug]),
signing: validate_signing!(Keyword.get(options, :signing))
}
end
defp validate_signing!(nil), do: nil
defp validate_signing!(%{keys: [_ | _] = keys} = signing) when is_list(keys) do
if Enum.all?(keys, &is_binary/1) do
Map.put_new(signing, :required?, false)
else
raise ArgumentError,
"Image.Plug: :signing :keys must all be binaries, got: #{inspect(keys)}"
end
end
defp validate_signing!(other) do
raise ArgumentError,
"Image.Plug: :signing must be nil or %{keys: [\"...\"], required?: bool}, got: #{inspect(other)}"
end
defp take_module!(options, key) do
case Keyword.fetch(options, key) do
{:ok, value} -> normalise_module!(value, key)
:error -> raise ArgumentError, "Image.Plug: required option #{inspect(key)} is missing"
end
end
defp normalise_module!({module, opts}, _key) when is_atom(module) and is_list(opts) do
{module, opts}
end
defp normalise_module!(module, _key) when is_atom(module) do
{module, []}
end
defp normalise_module!(other, key) do
raise ArgumentError,
"Image.Plug: option #{inspect(key)} must be a module or " <>
"a {module, keyword} tuple, got: #{inspect(other)}"
end
defp validate_on_error!(value) do
case value do
v when v in [:auto, :render_error_image, :fallback_to_source, :status_text, :raise] ->
:ok
{:status, code} when is_integer(code) and code in 100..599 ->
:ok
other ->
raise ArgumentError,
"Image.Plug: :on_error must be :auto, :render_error_image, " <>
":fallback_to_source, :status_text, :raise, or {:status, code}, got: #{inspect(other)}"
end
end
end