Skip to main content

src/livery_test_adapter.erl

-module(livery_test_adapter).
-moduledoc """
In-memory adapter used by tests and the parity suite.

Implements `livery_adapter` against an ETS-backed capture store
instead of a real socket. Callers can build a synthetic request,
run a middleware stack and handler against it, and inspect the
emitted status, headers, body chunks, and trailers without
touching a wire.

Response emission is delegated to `livery:emit/3`, the shared
walker that every adapter calls back into.
""".

-behaviour(livery_adapter).

-include("livery.hrl").

%% Public test helpers
-export([
    start/0,
    stop/1,
    new_stream/1,
    new_stream/2,
    feed_body/3,
    capture/1,
    status/1,
    headers/1,
    header/2,
    body/1,
    body_chunks/1,
    trailers/1,
    reset_reason/1,
    end_stream/1,
    run/3,
    run/4
]).

%% livery_adapter callbacks (stop/1 is shared with the public helper above)
-export([
    start/3,
    send_headers/4,
    send_data/3,
    send_trailers/2,
    reset/2,
    peer_info/1,
    capabilities/1
]).

-export_type([listener/0, stream/0, capture/0]).

-record(captured, {
    status :: undefined | 100..599,
    headers = [] :: [{binary(), binary()}],
    body_chunks = [] :: [iodata()],
    end_stream = false :: boolean(),
    trailers :: undefined | [{binary(), binary()}],
    reset :: undefined | term()
}).

-type listener() :: ets:tid().
-type stream() :: {listener(), reference()}.
-opaque capture() :: #captured{}.

%%====================================================================
%% Public test helpers
%%====================================================================

-spec start() -> listener().
start() ->
    ets:new(?MODULE, [public, set]).

-spec stop(listener()) -> ok.
stop(Tab) ->
    ets:delete(Tab),
    ok.

-spec new_stream(listener()) -> stream().
new_stream(Tab) ->
    new_stream(Tab, #{}).

-spec new_stream(listener(), map()) -> stream().
new_stream(Tab, _Meta) ->
    Ref = make_ref(),
    true = ets:insert(Tab, {Ref, #captured{}}),
    {Tab, Ref}.

-doc """
Push a body chunk (or terminal marker) into the per-request
process mailbox in the `livery_body` protocol.
""".
-spec feed_body(
    reference(),
    pid(),
    {data, iodata()}
    | {trailers, [{binary(), binary()}]}
    | eof
    | {reset, term()}
) -> ok.
feed_body(Ref, Pid, Event) ->
    Pid ! {livery_body, Ref, Event},
    ok.

-spec capture(stream()) -> capture() | undefined.
capture({Tab, Ref}) ->
    case ets:lookup(Tab, Ref) of
        [{_, C}] -> C;
        [] -> undefined
    end.

-spec status(capture()) -> undefined | 100..599.
status(#captured{status = S}) -> S.

-spec headers(capture()) -> [{binary(), binary()}].
headers(#captured{headers = H}) -> H.

-spec header(binary(), capture()) -> binary() | undefined.
header(Name, #captured{headers = H}) ->
    case lists:keyfind(Name, 1, H) of
        {_, V} -> V;
        false -> undefined
    end.

-spec body(capture()) -> binary().
body(#captured{body_chunks = Cs}) ->
    iolist_to_binary(lists:reverse(Cs)).

-spec body_chunks(capture()) -> [iodata()].
body_chunks(#captured{body_chunks = Cs}) ->
    lists:reverse(Cs).

-spec trailers(capture()) -> undefined | [{binary(), binary()}].
trailers(#captured{trailers = T}) -> T.

-spec reset_reason(capture()) -> undefined | term().
reset_reason(#captured{reset = R}) -> R.

-spec end_stream(capture()) -> boolean().
end_stream(#captured{end_stream = E}) -> E.

-doc """
Drive a request through a middleware stack and handler.

`Spec` is a map of `#livery_req{}` fields. Returns the captured
response. Listener lifecycle is managed for the caller.
""".
-spec run(
    livery_middleware:stack(),
    livery_middleware:handler(),
    map()
) -> capture().
run(Stack, Handler, Spec) ->
    run(Stack, Handler, Spec, #{}).

-spec run(
    livery_middleware:stack(),
    livery_middleware:handler(),
    map(),
    map()
) -> capture().
run(Stack, Handler, Spec, Opts) ->
    Tab = start(),
    try
        Stream = new_stream(Tab),
        Req = build_req(Spec, Stream, Opts),
        Resp =
            try
                livery:dispatch(Stack, Handler, Req)
            catch
                _Class:_Reason:_St ->
                    livery_resp:text(500, <<"internal server error">>)
            end,
        _ = livery:emit(?MODULE, Stream, Resp),
        capture(Stream)
    after
        stop(Tab)
    end.

%%====================================================================
%% livery_adapter callbacks
%%====================================================================

-spec start(atom(), term(), map()) -> {ok, listener()}.
start(_Name, _Spec, _Opts) ->
    {ok, start()}.

-spec send_headers(
    stream(),
    100..599,
    [{binary(), binary()}],
    livery_adapter:send_opts()
) -> ok.
send_headers({Tab, Ref}, Status, Headers, Opts) ->
    update(Tab, Ref, fun(C) ->
        C#captured{
            status = Status,
            headers = Headers,
            end_stream = maps:get(end_stream, Opts, false) orelse C#captured.end_stream
        }
    end).

-spec send_data(stream(), iodata(), livery_adapter:send_opts()) -> ok.
send_data({Tab, Ref}, IoData, Opts) ->
    update(Tab, Ref, fun(C) ->
        C#captured{
            body_chunks = [IoData | C#captured.body_chunks],
            end_stream = maps:get(end_stream, Opts, false) orelse C#captured.end_stream
        }
    end).

-spec send_trailers(stream(), [{binary(), binary()}]) -> ok.
send_trailers({Tab, Ref}, Trailers) ->
    update(Tab, Ref, fun(C) ->
        C#captured{trailers = Trailers, end_stream = true}
    end).

-spec reset(stream(), term()) -> ok.
reset({Tab, Ref}, Reason) ->
    update(Tab, Ref, fun(C) -> C#captured{reset = Reason} end).

-spec peer_info(stream()) -> livery_adapter:peer_info().
peer_info(_Stream) ->
    #{peer => {{127, 0, 0, 1}, 0}, tls => undefined, alpn => undefined}.

-spec capabilities(listener()) -> livery_adapter:capabilities().
capabilities(_) ->
    #{
        trailers => true,
        extended_connect => true,
        datagrams => false,
        capsules => false
    }.

%%====================================================================
%% Internals
%%====================================================================

-spec build_req(map(), stream(), map()) -> livery_req:req().
build_req(Spec, Stream, Opts) ->
    Defaults = #{
        protocol => h1,
        method => <<"GET">>,
        path => <<"/">>
    },
    Fields = maps:merge(Defaults, Spec),
    Req = livery_req:new(Fields),
    %% `config' may come from the request spec or the run/4 opts (spec wins),
    %% mirroring a per-listener config on the real adapters.
    Config = maps:get(config, Fields, maps:get(config, Opts, undefined)),
    Req#livery_req{adapter = ?MODULE, stream = Stream, config = Config}.

-spec update(listener(), reference(), fun((#captured{}) -> #captured{})) -> ok.
update(Tab, Ref, F) ->
    [{_, C}] = ets:lookup(Tab, Ref),
    true = ets:insert(Tab, {Ref, F(C)}),
    ok.