HttpEtag (HttpEtag v0.1.0)

Copy Markdown View Source

RFC 9110 entity tags and If-Match / If-None-Match.

Parses entity tags and evaluates preconditions. The library reports whether a precondition is satisfied; the caller maps that to 304 or 412. It does not send those statuses or set Cache-Control.

Behaviour

  • Build tags with new/2 (opaque octets or an integer such as lock_version), parse/1 (a quoted ETag field), or from_content/2 (hash of canonical iodata you already have — not Jason.encode!/1).
  • new("1") is opaque 1. parse(~S("1")) is the wire field "1".
  • If-Match uses strong comparison; If-None-Match uses weak comparison. Do not use == on the struct.
  • A missing header is nil and skips the precondition (:ok). "" is an empty list, not a missing header.
  • Functions return :ok or {:error, exception}. Programmer mistakes (wrong current or header type) raise ArgumentError.
  • Hand-built structs are not validated. A " in opaque is not a valid entity-tag.

Examples

iex> {:ok, tag} = HttpEtag.parse(~S("abc"))
iex> HttpEtag.to_header(tag)
~S("abc")

iex> HttpEtag.if_match(HttpEtag.parse!(~S("abc")), ~S("abc"))
:ok

See HttpEtag.Conn, HttpEtag.Error, and RFC 9110 §8.8.3 and §13.1.

Summary

Types

Keyword options for from_content/2.

t()

A parsed entity-tag.

A parsed If-Match or If-None-Match field: a tag list, or :any for *.

Functions

Builds an entity-tag from representation octets.

Evaluates If-Match (RFC 9110 §13.1.1).

Same as if_match/2 but raises HttpEtag.Error on failure.

Evaluates If-None-Match (RFC 9110 §13.1.2).

Builds an entity-tag from opaque octets.

Same as new/2 but raises HttpEtag.Error on failure.

Parses a single entity-tag.

Same as parse/1 but raises HttpEtag.Error on failure.

Parses an If-Match or If-None-Match field.

Same as parse_list/1 but raises HttpEtag.Error on failure.

Strong comparison (RFC 9110 §8.8.3.2).

Formats an entity-tag as an ETag field value.

Weak comparison (RFC 9110 §8.8.3.2).

Types

from_content_opt()

@type from_content_opt() :: {:algorithm, atom()} | {:weak, boolean()}

Option for from_content/2.

from_content_opts()

@type from_content_opts() :: [from_content_opt()]

Keyword options for from_content/2.

t()

@type t() :: %HttpEtag{opaque: binary(), weak: boolean()}

A parsed entity-tag.

opaque is the octets inside the quotes. weak is true for a weak tag (W/"…").

tag_list()

@type tag_list() :: [t()] | :any

A parsed If-Match or If-None-Match field: a tag list, or :any for *.

Functions

from_content(content, opts \\ [])

@spec from_content(iodata(), from_content_opts()) :: t()

Builds an entity-tag from representation octets.

Hashes content (iodata) with :sha256 by default and encodes the digest as lowercase hex, which is always valid etagc. The result is a strong tag unless weak: true.

Pass canonical bytes you already control (a file, a digest input). Do not hash Jason.encode!/1 of a struct: key order and omitted nils are unstable. Prefer new/2 with lock_version (or another unique validator) for Ecto rows. updated_at at second precision can collide.

Unknown option keys raise ArgumentError. A non-boolean :weak or non-atom :algorithm raises ArgumentError. An atom that is not a :crypto.hash_algorithm() raises from :crypto.hash/2.

Options

  • :algorithm - a :crypto.hash_algorithm(), default :sha256
  • :weak - when true, mark the tag weak

Examples

iex> tag = HttpEtag.from_content("abc")
iex> tag.weak
false
iex> tag.opaque
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"

iex> HttpEtag.from_content(["ab", "c"]) == HttpEtag.from_content("abc")
true

if_match(current, header)

@spec if_match(t() | nil, String.t() | nil) :: :ok | {:error, HttpEtag.Error.t()}

Evaluates If-Match (RFC 9110 §13.1.1).

Uses strong comparison. header nil means the field is absent (no precondition) and returns :ok. current nil means the resource has no representation. If-Match: * succeeds only when current is present.

current must be a %HttpEtag{} or nil. header must be a binary or nil. Any other term raises ArgumentError.

A weak current never strongly matches a tag list. Prefer strong tags for lost-update protection.

Returns :ok or {:error, exception} with :precondition_failed or :invalid_header. Does not choose 412; the caller does.

Examples

iex> HttpEtag.if_match(HttpEtag.parse!(~S("abc")), ~S("abc"))
:ok

iex> HttpEtag.if_match(nil, "*")
{:error, %HttpEtag.Error{reason: :precondition_failed}}

if_match!(current, header)

@spec if_match!(t() | nil, String.t() | nil) :: :ok

Same as if_match/2 but raises HttpEtag.Error on failure.

Examples

iex> HttpEtag.if_match!(HttpEtag.parse!(~S("abc")), ~S("abc"))
:ok

if_none_match(current, header)

@spec if_none_match(t() | nil, String.t() | nil) :: :ok | {:error, HttpEtag.Error.t()}

Evaluates If-None-Match (RFC 9110 §13.1.2).

Uses weak comparison. header nil returns :ok. If-None-Match: * succeeds only when current is nil. A tag list fails when any listed tag weakly matches current. If current is nil, a tag list succeeds.

current must be a %HttpEtag{} or nil. header must be a binary or nil. Any other term raises ArgumentError.

Returns :ok or {:error, exception} with :precondition_failed or :invalid_header. The caller maps a failed GET/HEAD to 304 and other methods to 412.

Examples

iex> HttpEtag.if_none_match(nil, "*")
:ok

iex> HttpEtag.if_none_match(HttpEtag.parse!(~S("abc")), ~S("abc"))
{:error, %HttpEtag.Error{reason: :precondition_failed}}

if_none_match!(current, header)

@spec if_none_match!(t() | nil, String.t() | nil) :: :ok

Same as if_none_match/2 but raises HttpEtag.Error on failure.

Examples

iex> HttpEtag.if_none_match!(nil, "*")
:ok

new(opaque, weak \\ false)

@spec new(term(), term()) :: {:ok, t()} | {:error, HttpEtag.Error.t()}

Builds an entity-tag from opaque octets.

weak defaults to false (a strong tag). Opaque octets must match RFC 9110 etagc (! / %x23-7E / obs-text). Double quotes and spaces are rejected.

An integer is formatted with Integer.to_string/1 (use user.lock_version, not parse/1 of the version string).

Examples

iex> HttpEtag.new("abc")
{:ok, %HttpEtag{opaque: "abc", weak: false}}

iex> HttpEtag.new(1)
{:ok, %HttpEtag{opaque: "1", weak: false}}

iex> HttpEtag.new("abc", true)
{:ok, %HttpEtag{opaque: "abc", weak: true}}

iex> HttpEtag.new("a b")
{:error, %HttpEtag.Error{reason: :invalid_etag}}

new!(opaque, weak \\ false)

@spec new!(term(), term()) :: t()

Same as new/2 but raises HttpEtag.Error on failure.

Examples

iex> HttpEtag.new!("abc")
%HttpEtag{opaque: "abc", weak: false}

iex> HttpEtag.new!(42)
%HttpEtag{opaque: "42", weak: false}

parse(value)

@spec parse(term()) :: {:ok, t()} | {:error, HttpEtag.Error.t()}

Parses a single entity-tag.

Surrounding optional whitespace (SP / HTAB) is ignored. The whole value after that must be one entity-tag. "W/" is case-sensitive. There is no backslash unescaping.

Examples

iex> HttpEtag.parse(~S("abc"))
{:ok, %HttpEtag{opaque: "abc", weak: false}}

iex> HttpEtag.parse(~S(W/"abc"))
{:ok, %HttpEtag{opaque: "abc", weak: true}}

iex> HttpEtag.parse(~S(""))
{:ok, %HttpEtag{opaque: "", weak: false}}

parse!(value)

@spec parse!(term()) :: t()

Same as parse/1 but raises HttpEtag.Error on failure.

Examples

iex> HttpEtag.parse!(~S("abc"))
%HttpEtag{opaque: "abc", weak: false}

parse_list(value)

@spec parse_list(term()) :: {:ok, tag_list()} | {:error, HttpEtag.Error.t()}

Parses an If-Match or If-None-Match field.

Returns {:ok, :any} for *, {:ok, tags} for #entity-tag (empty list elements are ignored), or {:error, exception} when the field is invalid. * mixed with tags is invalid.

Recipients ignore empty list elements. More than 256 comma-separated segments is :invalid_header (RFC 9110 §5.6.1.2).

Examples

iex> HttpEtag.parse_list("*")
{:ok, :any}

iex> HttpEtag.parse_list(~S("a", W/"b"))
{:ok, [%HttpEtag{opaque: "a", weak: false}, %HttpEtag{opaque: "b", weak: true}]}

parse_list!(value)

@spec parse_list!(term()) :: tag_list()

Same as parse_list/1 but raises HttpEtag.Error on failure.

Examples

iex> HttpEtag.parse_list!("*")
:any

strong_match?(http_etag1, http_etag2)

@spec strong_match?(t(), t()) :: boolean()

Strong comparison (RFC 9110 §8.8.3.2).

Both tags must be strong and their opaque octets equal. A weak tag never matches. Use this for If-Match, not == on the struct.

Comparison uses == and is not constant-time. Entity tags are validators, not secrets.

Examples

iex> HttpEtag.strong_match?(HttpEtag.parse!(~S("1")), HttpEtag.parse!(~S("1")))
true

iex> HttpEtag.strong_match?(HttpEtag.parse!(~S(W/"1")), HttpEtag.parse!(~S("1")))
false

to_header(http_etag)

@spec to_header(t()) :: String.t()

Formats an entity-tag as an ETag field value.

Build the struct with new/2, parse/1, or from_content/2. A hand-built struct whose opaque contains " is not a valid entity-tag.

Examples

iex> HttpEtag.to_header(%HttpEtag{opaque: "abc", weak: false})
~S("abc")

iex> HttpEtag.to_header(%HttpEtag{opaque: "abc", weak: true})
~S(W/"abc")

weak_match?(http_etag1, http_etag2)

@spec weak_match?(t(), t()) :: boolean()

Weak comparison (RFC 9110 §8.8.3.2).

Opaque octets must be equal; weakness is ignored. Use this for If-None-Match.

Comparison uses == and is not constant-time. Entity tags are validators, not secrets.

Examples

iex> HttpEtag.weak_match?(HttpEtag.parse!(~S(W/"1")), HttpEtag.parse!(~S("1")))
true