Skip to main content

src/codec/livery_codec.erl

-module(livery_codec).
-moduledoc """
Content-coding codec behaviour and registry.

A codec turns response bytes into a single content-coding (the token
sent in `Content-Encoding`, e.g. `gzip`). `livery_compress` negotiates
the client's `Accept-Encoding` against the registered codecs and applies
the chosen one to `{full, _}` and `{chunked, _}` bodies.

The built-in codecs `livery_codec_gzip` and `livery_codec_deflate` are
always available (over OTP `zlib`, no dependency). A separate app (a
future `livery_brotli` or `livery_zstd`) adds its coding by calling
`register/1` at its own application start; it then participates in
negotiation without any change to livery core.

## Callbacks

- `name/0` — the `Content-Encoding` token (lowercase binary).
- `compress/1` — one-shot compression of a whole body (`{full, _}`).
- `stream_init/0` — open a streaming context.
- `stream_update/2` — feed a chunk and FLUSH, returning the bytes
  emittable now (so each producer chunk reaches the client promptly).
- `stream_finish/1` — return the trailing bytes that finalize the
  stream. Does not release the context.
- `stream_close/1` — release the context; always called, including on
  the error path.
""".

-compile({no_auto_import, [registered/0]}).

-export([register/1, registered/0, lookup/1]).

-export_type([codec/0]).

-type codec() :: module().

-callback name() -> binary().
-callback compress(iodata()) -> iodata().
-callback stream_init() -> term().
-callback stream_update(term(), iodata()) -> iodata().
-callback stream_finish(term()) -> iodata().
-callback stream_close(term()) -> ok.

-define(EXTRAS_KEY, {?MODULE, extras}).
-define(BUILTINS, [livery_codec_gzip, livery_codec_deflate]).

-doc """
Register an extra codec module.

Idempotent; built-in codecs are ignored (always present). Intended to be
called once at the registering app's start, not per request.
""".
-spec register(codec()) -> ok.
register(Module) when is_atom(Module) ->
    Extras = persistent_term:get(?EXTRAS_KEY, []),
    case lists:member(Module, Extras) orelse lists:member(Module, ?BUILTINS) of
        true ->
            ok;
        false ->
            persistent_term:put(?EXTRAS_KEY, Extras ++ [Module]),
            ok
    end.

-doc """
All registered codecs in server-preference order.

Always begins with the built-ins (`gzip`, then `deflate`), followed by
any extras in registration order. The built-ins can never be dropped by
registration.
""".
-spec registered() -> [codec()].
registered() ->
    ?BUILTINS ++ persistent_term:get(?EXTRAS_KEY, []).

-doc "Find a registered codec by `Content-Encoding` token (case-insensitive).".
-spec lookup(binary()) -> {ok, codec()} | error.
lookup(Name) when is_binary(Name) ->
    find(normalize(Name), registered()).

-spec find(binary(), [codec()]) -> {ok, codec()} | error.
find(_Lower, []) ->
    error;
find(Lower, [Module | Rest]) ->
    case normalize(Module:name()) =:= Lower of
        true -> {ok, Module};
        false -> find(Lower, Rest)
    end.

-spec normalize(binary()) -> binary().
normalize(Bin) ->
    iolist_to_binary(string:lowercase(Bin)).