ImagePipe.URL (image_pipe_url v0.1.0)

Copy Markdown View Source

Builds processing plans and generates signed ImagePipe URLs.

config = ImagePipe.URL.config(keys: [signing_key], base_url: "https://cdn.example.com/images")

builder =
  ImagePipe.URL.new(config)
  |> ImagePipe.URL.group(resize: [width: 400, height: 300, fit: :cover], anchor: :smart)
  |> ImagePipe.URL.output(format: :webp, quality: 82)

{:ok, []} = ImagePipe.URL.validate(builder)
url = ImagePipe.URL.url!(builder, "images/cat.jpg")
# "https://cdn.example.com/images/sig=…/w=400/h=300/fit=cover/anchor=smart/format=webp/q=82/src/images%2Fcat.jpg"

A builder is an immutable value that holds a plan and a URL configuration. Every call returns a new builder, so a builder can be shared and extended with ordinary functions. Building and generating URLs reads no source, image, or cache.

An app that serves its own URLs passes the same base_url, keys, and source encryption options to ImagePipe.config/1. Preset definitions belong to the server, and URLs carry only preset names. An app that builds URLs for an image service running elsewhere must match the settings listed in Shared URL settings.

URL option names

Builder options are the URL options with longer names. The URL syntax and each option's effect are documented in Requesting images and the processing pages it links to, with an Elixir example for each option.

A URL option with a hyphen uses an underscore in the builder, as in extend-at and extend_at:. These names differ:

URLBuilder
w, h, min-w, min-hresize: [width:, height:, min_width:, min_height:]
fit, enlarge, zoomresize: [fit:, enlarge:, zoom:]
padpadding:
bgbackground:
presetpresets:
wm, wm-src64watermark:, watermark_source:
wm-opacity and other wm- optionswatermark_opacity: and other watermark_ options
cbcachebuster: in new/1
outputterminal:
q, format-qquality:, format_qualities:
metametadata:
profilecolor_profile:
progressive in jpeg-optionsinterlace:
subsample in jpeg-options and avif-optionssubsample_mode:

Named values are atoms, with an underscore for a hyphen: anchor=top-left is anchor: :top_left. These values differ:

URLBuilder
flip=h, v, hvflip: :horizontal, :vertical, :both
trim-symmetry=h, v, hvtrim_symmetry: :horizontal, :vertical, :both
profile=preservecolor_profile: :preserve_source
profile=srgb, display-p3, adobe-rgbcolor_profile: {:convert, :srgb}, {:convert, :display_p3}, {:convert, :adobe_rgb}
hdr=tonemaphdr: :tone_map
output=info,blurhashterminal: {:info, [:blurhash]}
wm-src64=<base64>watermark_source: "logos/mark.png", the plain source

An option with several comma-separated values in the URL, such as gradient, takes a keyword list, as in gradient: [opacity: 0.8, color: "black", direction: :left]. With encrypt_source: true in the configuration, watermark_source: is written as wm-enc. Lengths, percentages, and colors are described under option values.

Mistakes in options

new/1, new/2, group/2, and output/2 check each option as it's given, and record a mistake in the plan instead of raising. validate/1, url/3, url!/3, and url_with_issues/3 report it as an ImagePipe.Plan.Spec.Issue:

builder = ImagePipe.URL.new() |> ImagePipe.URL.group(resize: [width: 300, fit: :fill])

ImagePipe.URL.validate(builder)
# {:error,
#  [%ImagePipe.Plan.Spec.Issue{reason: :invalid_value,
#     locations: [{:group, 0, :fit}],
#     detail: "invalid value for :fit option: expected one of [:contain, :cover, :stretch, :auto], got: :fill",
#     severity: :error}]}

ImagePipe.URL.url!(builder, "photo.jpg")
# ** (ArgumentError) cannot build URL: invalid_value at {:group, 0, :fit}

An unknown option name or a malformed value is an error. The builder repairs a mistake with one clear meaning, and reports it as a warning:

  • An option given twice in one call, or a key repeated inside a list value such as gradient:, keeps its last value.
  • A group/2 call with no options, or only an empty presets: or resize: list, adds no group.
  • In format_qualities: and the encoder options such as jpeg_options:, [:unset] alone is written as :unset, and a repeated leading :unset is written once.

Options that aren't a keyword list raise ArgumentError.

url_with_issues/3 returns a URL despite errors, which the server rejects, so a page can show a broken image instead of failing.

Summary

Functions

Builds a URL configuration for new/1.

Encrypts a UTF-8 source with the configured source encryption keys.

Appends a processing group to the plan.

Starts a builder with an empty plan.

Like new/1, with a configuration and request controls.

Sets output and encoding options for the whole request.

Signs an existing mount-relative path with the first configured signing key.

Generates the URL for a builder's plan and a source.

Like url/3, but returns the URL or raises ArgumentError.

Like url/3, but always returns a URL, with the issues validate/1 reports and any problem with the source or the number of options.

Checks the plan as the server would, without generating a URL.

Types

t()

@type t() :: %ImagePipe.URL{
  config: ImagePipe.URL.Config.t(),
  plan: ImagePipe.Plan.t()
}

Functions

config(options \\ [])

@spec config(keyword()) :: ImagePipe.URL.Config.t()

Builds a URL configuration for new/1.

config =
  ImagePipe.URL.config(
    base_url: "https://cdn.example.com/images",
    keys: [System.fetch_env!("IMAGE_PIPE_SIGNING_KEY")]
  )

An app that serves its own URLs passes the same options, except :validate_against, to ImagePipe.config/1, so the server verifies signatures and decrypts sources with the same keys. Invalid options raise ArgumentError, and the message never includes a key.

Options

  • :base_url (String.t/0) - Prefix for generated URLs: an HTTP(S) URL such as "https://cdn.example.com/images", a root-relative path such as "/images", or a relative path such as "images". A trailing / is removed. Path segments use unescaped ASCII letters, digits, -, ., _, or ~. A base URL with credentials, a query string, a fragment, an empty segment, or a . or .. segment raises. With the default, URLs start with /, such as /w=400/src/cat.jpg. The default value is "".

  • :keys (list of hex-encoded String.t/0) - Signing keys, each at least 64 hex digits (32 bytes), such as the output of openssl rand -hex 32. A shorter key raises ArgumentError. The first key signs generated URLs. The server accepts a signature from any key in the list, so keep an old key while URLs signed with it are in use. Without keys, URLs are unsigned and the server rejects signed ones. The default value is [].

  • :encrypt_source (boolean/0) - Encrypts the source, and any watermark source, in generated URLs, which then use enc/<token> instead of src/<path>. Requires :source_encryption_keys. A server with encryption keys decrypts enc/ sources regardless of this option. The default value is false.

  • :source_encryption_keys (list of hex-encoded String.t/0) - Keys that encrypt and decrypt sources, each 32 bytes written as 64 hex digits in either case, such as the output of openssl rand -hex 32. The first key encrypts. The server decrypts with any key in the list. The keys must differ from the signing keys, and setting them requires :keys. The default value is [].

  • :iv_mode (:deterministic or :random) - How ImagePipe.URL.url/3 picks the initialization vector for an encrypted source. :deterministic derives it from the source with a secret key, so one source always gives the same URL. :random gives a new URL on every call. The default value is :deterministic.

  • :validate_against (keyword/0) - What the server has configured, so ImagePipe.URL.validate/1 and ImagePipe.URL.url/3 check plans as the server does. It never changes a generated URL, but a copy that differs from the server gives wrong validation results. In an app that serves its own URLs, ImagePipe.url_config/2 returns the URL configuration with this filled in, and ImagePipe.config/1 doesn't accept it.

    • :presets (map/0) - The server's presets: preset names mapped to option fragments such as "w=400/h=300/fit=cover", or to ImagePipe.URL builders. The default value is %{}.

    • :request_defaults (String.t/0 or ImagePipe.URL.t/0) - The server's request defaults, as a fragment or builder.

    • :preset_lookup (boolean/0) - Set to true when the server has a preset lookup. For a plan that names a preset missing from :presets, the groups that name it and the request-wide options are not checked, because only the server can resolve the name. The other groups are still checked. The default value is false.

    • :watermarks (list of atom/0) - The server's watermark names, such as [:logo]. A watermark: name missing from the list fails the check. Without this option, watermark names aren't checked. The check covers presets too, so list the server's :presets as well.

    • :request_watermarks (boolean/0) - The server's request_watermarks setting: whether it accepts watermark sources that a request names. With false, a watermark_source: fails the check. Without this option, watermark sources aren't checked. The check covers presets too, so list the server's :presets as well.

encrypt_source(source, config, encryption_options \\ [])

@spec encrypt_source(String.t(), ImagePipe.URL.Config.t(), keyword()) ::
  {:ok, String.t()} | {:error, atom()}

Encrypts a UTF-8 source with the configured source encryption keys.

Returns {:ok, token}. Place the token after the enc/ source marker and sign the complete path with sign_path/2. url/3 does both.

:iv overrides the configured :iv_mode with :deterministic, :random, or an explicit 16-byte binary, as in url/3.

Returns {:error, :source_encryption_disabled} without :source_encryption_keys, {:error, :invalid_source} for an empty or non-UTF-8 source, and {:error, :invalid_encryption_options} for any other option or a malformed :iv.

group(builder, options)

@spec group(t(), keyword()) :: t()

Appends a processing group to the plan.

ImagePipe.URL.new()
|> ImagePipe.URL.group(resize: [width: 400, height: 300, fit: :cover], anchor: :smart)
|> ImagePipe.URL.group(padding: 12, background: "white")
# "/w=400/h=300/fit=cover/anchor=smart/-/pad=12,12,12,12/bg=ffffff/src/…"

The options of one call form one group, like the options between two - segments in a URL. They run in the fixed processing order, whatever order they're given in. A second call starts a new group that processes the previous group's result, with fresh settings: a dpr: 2 in the first group doesn't apply to the second.

Resize options go in a resize: keyword list. presets: names presets to apply to this group, and the group's own options override them. Option names and values are listed under URL option names.

Any option except presets: and resize: itself, including each resize: option, accepts :unset to remove a value set by the group's presets or the server's request defaults.

Each value is checked here, and a mistake is recorded in the plan, as described under Mistakes in options. Checks that involve several options, such as extend with extend_ratio, happen in validate/1.

new(options \\ [])

@spec new(ImagePipe.URL.Config.t() | keyword()) :: t()

Starts a builder with an empty plan.

ImagePipe.URL.new()
ImagePipe.URL.new(config)
ImagePipe.URL.new(config, filename: "cat", expires: 1_767_225_600)
ImagePipe.URL.new(filename: "cat")

config comes from config/1. Without it, the builder uses config([]): no base URL, signing, or encryption. The options are request controls, which apply once to the whole request. An empty plan is valid. Its URL, such as /src/cat.jpg, serves the original in a negotiated format.

Options

Each option also accepts :unset, which removes a value set by a preset or the server's request defaults. A mistake in an option is recorded in the plan, as described under Mistakes in options.

  • :orient (:auto or :none) - :auto applies the original's EXIF orientation, and :none ignores it. The URL option is orient.

  • :page (non_neg_integer/0) - The zero-based page or frame of the original to use. The URL option is page.

  • :filename (String.t/0) - The file name without extension, of ASCII letters, digits, ., _, and -. The response's extension is appended. The URL option is filename.

  • :attachment (boolean/0) - Makes browsers download the response. The URL option is attachment.

  • :cachebuster (String.t/0) - A token that makes the server store a copy separate from the one for the same URL without it: ASCII letters, digits, ., _, and -. The URL option is cb.

  • :expires (pos_integer/0) - A Unix time in seconds. The URL stops working after that second. The URL option is expires.

  • :debug (boolean/0) - Adds debug response headers when the server allows them with allow_debug_headers. The URL option is debug.

new(config, options)

@spec new(ImagePipe.URL.Config.t(), keyword()) :: t()

Like new/1, with a configuration and request controls.

output(builder, options)

@spec output(t(), keyword()) :: t()

Sets output and encoding options for the whole request.

builder
|> ImagePipe.URL.output(format: :webp, quality: 82)
|> ImagePipe.URL.output(quality: 60)
# "/…/format=webp/q=60/src/…"

The options are the output and encoding options of the URL, under the names listed under URL option names. Each call merges its options into the plan. An option given again replaces its whole previous value, including every entry of jpeg_options: or format_qualities:, and any mistake recorded for it. Options not given keep their value.

An option the plan doesn't set takes the server's configured default when the URL is served. :unset removes a value set by a preset or the request defaults, so the configured default applies. format_qualities: and the encoder options such as jpeg_options: need at least one entry. Clear them with :unset. When the URL is served, their entries combine with a preset's and the request defaults' entry by entry. A leading :unset, as in jpeg_options: [:unset, interlace: true], drops those inherited entries first. A mistake in an option is recorded in the plan, as described under Mistakes in options.

sign_path(path, config)

@spec sign_path(String.t(), ImagePipe.URL.Config.t()) :: String.t()

Signs an existing mount-relative path with the first configured signing key.

Returns "/sig=<signature>" <> path, preserving the exact path bytes without parsing, escaping, or normalizing them. Pass a config from config/1. The path must start with / and exclude any signature prefix, query string, or fragment. Source query parameters must already be escaped in the path. Raises ArgumentError for an invalid path shape or missing signing keys.

Prepend the mount prefix or hostname to the result yourself; :base_url is not applied. This function performs no source or cache I/O and does not validate processing options or encrypt sources.

url(url, source, options \\ [])

@spec url(t(), String.t(), keyword()) ::
  {:ok, String.t()}
  | {:error, atom() | {:invalid_request, [ImagePipe.Plan.Spec.Issue.t()]}}

Generates the URL for a builder's plan and a source.

{:ok, url} = ImagePipe.URL.url(builder, "photos/cat.jpg")
url
#=> "/images/w=400/src/photos%2Fcat.jpg"

The source is the identifier the image service resolves: a path, an HTTP(S) URL, an S3 identifier, or a custom scheme. Pass it as a UTF-8 string, unescaped and with any query parameters, such as "https://assets.example.com/cat.jpg?v=2". url/3 escapes it once. A leading / is removed unless the source starts with // or the rest is a URL such as /https://…. The sources . and .. are written with the src64/ marker, because browsers resolve them as path segments. The {:file, path} and {:binary, bytes} inputs of ImagePipe.run/4 have no URL.

The URL is the configuration's :base_url, then /sig=<signature> when the configuration has :keys, then the options and the source. The signature covers everything after it, so a URL keeps a valid signature when served under another :base_url. The same plan, source, and configuration always give the same URL, whatever order the options were given in. Preset names stay in the URL as names, and the server resolves their current definitions.

An :expires set with new/1 is part of the URL. Computing a new expiry on every call gives a new URL every time. url/3 doesn't compare the expiry with the current time.

Options

  • :iv - with encrypt_source: true, overrides the configured :iv_mode for this URL. :deterministic and :random work as the :iv_mode values. A 16-byte binary is used as the IV of the main source, and each watermark source derives its own IV from it. An explicit IV must be unpredictable, or derived from the complete source with a secret key, and must never be used for two different sources under the same key.

Return values

  • {:ok, url}.
  • {:error, {:invalid_request, issues}} - validate/1 returns an error: a mistake the builder recorded, or, when the configuration has :validate_against, options that conflict. Warnings don't stop the URL. Without :validate_against, the server checks how the options combine when it serves the URL.
  • {:error, :invalid_source} - the source is empty, not valid UTF-8, or not a string.
  • {:error, :too_many_options} - the plan has more than 64 option and - segments, the most the server accepts.
  • {:error, :source_encryption_disabled} - a valid :iv was given and the configuration doesn't have encrypt_source: true.
  • {:error, :invalid_encryption_options} - an option other than :iv, or a malformed :iv.

url!(builder, source, options \\ [])

@spec url!(t(), String.t(), keyword()) :: String.t()

Like url/3, but returns the URL or raises ArgumentError.

The message names each error's reason and location, as in cannot build URL: invalid_value at {:group, 0, :fit}. It never includes the source, a key, or an option's value.

url_with_issues(url, source, options \\ [])

@spec url_with_issues(t(), term(), keyword()) ::
  {String.t(), [ImagePipe.Plan.Spec.Issue.t()]}

Like url/3, but always returns a URL, with the issues validate/1 reports and any problem with the source or the number of options.

ImagePipe.URL.new()
|> ImagePipe.URL.group(resize: [width: 300, fit: :fill])
|> ImagePipe.URL.url_with_issues("cat.jpg")
# {"/w=300/fit=!fill/src/cat.jpg",
#  [%ImagePipe.Plan.Spec.Issue{reason: :invalid_value,
#     locations: [{:group, 0, :fit}], severity: :error, …}]}

Where url/3 would return an error, this returns a URL that the server rejects with 400, so a page shows a broken image instead of failing. The URL is signed as url/3 signs it, and includes everything url/3 would reject:

  • An option the builder rejected is written under its name, with ! before its value, as in fit=!fill. No option accepts a value that starts with !, so the server rejects it.
  • Options that validate/1 reports as errors, such as options that conflict, are written as given.
  • An invalid source is written after src/. A source that isn't a string, such as nil, is written as an empty source. The issue's reason is :invalid_source.
  • A plan with more than 64 option and - segments is written in full. The issue's reason is :too_many_options.

With encrypt_source: true, an invalid source and a rejected watermark_source: are written empty, so no source appears in plain text.

The issues list the errors first. Invalid options, as listed under url/3, raise ArgumentError.

validate(url)

@spec validate(t()) ::
  {:ok, [ImagePipe.Plan.Spec.Issue.t()]}
  | {:error, [ImagePipe.Plan.Spec.Issue.t()]}

Checks the plan as the server would, without generating a URL.

ImagePipe.URL.new(config)
|> ImagePipe.URL.group(resize: [fit: :cover])
|> ImagePipe.URL.validate()
# {:ok,
#  [%ImagePipe.Plan.Spec.Issue{reason: :inert_option,
#     locations: [{:group, 0, :fit}], detail: {:requires, :resize},
#     severity: :warning}]}

Returns {:ok, warnings} or {:error, issues}, lists of ImagePipe.Plan.Spec.Issue structs. The check applies the server's request defaults and the presets the plan names, then checks how the options combine. Options that conflict are errors. Options that have no effect, such as fit: :cover without a width or height, are warnings: the server ignores them, and {:error, issues} lists them after the errors. When :validate_against lists :watermarks, it also checks watermark: names against them. It reads no source or cache, so a plan that passes can still fail on a particular image.

The result also lists the mistakes the builder recorded. When one of them is an error, validate/1 returns those mistakes and skips the other checks.

The other checks need :validate_against in the URL configuration. Without it, validate/1 returns only the builder's mistakes. With preset_lookup: true, only the server can resolve a preset missing from :presets, and that preset can set any option of its group and of the request. For a plan that names one, the check skips the groups that name it and the request-wide options, and checks the other groups. In an app that serves its own URLs, ImagePipe.validate/2 runs the full check, including the lookup.