-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.