Adapters

View Source

This page explains what an adapter is and why Livery keeps it thin. You rarely write one, but understanding the boundary tells you where each kind of work belongs. An adapter is the thin translator between a wire library and Livery's request/response model. It is the only part of Livery that knows there is a socket. Everything above it (the router, the middleware, your handlers) works in terms of request and response values, and the adapter turns those into the bytes a particular protocol expects.

Because the adapter does so little, the same handler runs unchanged over HTTP/1.1, HTTP/2, and HTTP/3, and over an in-memory test harness with no socket at all.

When you would write one

Almost never: the four shipped adapters cover HTTP/1.1, HTTP/2, HTTP/3, and in-memory testing. You write an adapter when you want Livery's handler model over a transport it does not speak yet, for example a different HTTP implementation, an RPC over a message bus, or a test harness of your own. If you want one to add buffering, routing, or protocol logic, that work belongs in the wire library below it or in a middleware above it instead.

The behaviour

An adapter implements the livery_adapter behaviour:

-callback start(Name, ListenSpec, Opts) -> {ok, Listener}.
-callback stop(Listener) -> ok.
-callback send_headers(Stream, Status, Headers, SendOpts) -> SendResult.
-callback send_data(Stream, IoData, SendOpts) -> SendResult.
-callback send_trailers(Stream, Trailers) -> SendResult.
-callback reset(Stream, Reason) -> ok.
-callback peer_info(Stream) -> #{peer, tls, alpn}.
-callback capabilities(Listener) -> #{trailers, extended_connect, datagrams,
                                      capsules, informational}.

%% Optional
-callback send_full(Stream, Status, Headers, IoData, SendOpts) -> SendResult.
-callback send_informational(Stream, Status, Headers) -> SendResult.
-callback accept_ws(Stream, Req, HandlerMod, Opts) -> {ok, Pid} | {error, Reason}.
-callback accept_wt(Stream, Req, HandlerMod, Opts) -> {ok, Pid} | {error, Reason}.

SendOpts is #{end_stream => boolean(), flush => boolean()}. SendResult is ok | {error, closed | flow | term()}. send_full/5 sends headers and body in one write, and livery:emit/3 uses it when present. send_informational/3 sends a 1xx (livery_req:inform/3). accept_ws/4 and accept_wt/4 hand the stream to a WebSocket or WebTransport session (livery_ws:upgrade/3, livery_wt:upgrade/3).

Adapters that ship

AdapterServesBacked by
livery_test_adapterin-memoryETS, no socket
livery_h1HTTP/1.1h1
livery_h2HTTP/2h2
livery_h3HTTP/3quic (quic_h3 subsystem)

The test adapter is the one to read first: it is the smallest complete implementation, and the parity SUITE drives one handler set through every adapter to prove they behave the same.

The ALPN listener

livery_h1h2 is a listener, not an adapter. It owns one TLS port, resolves ALPN itself, and hands each connection to h2:serve_socket/2 or h1:serve_socket/2; a client that offered no ALPN goes to HTTP/1.1, since ALPN is what makes h2 over TLS possible in the first place.

Requests still dispatch through livery_h1 and livery_h2, so from a handler's side nothing changes: livery_req:protocol/1 is the protocol that connection negotiated, and peer_info/1's alpn is the exact value ALPN settled on (undefined when the client offered none, or when the listener is cleartext).

Use it with https => #{..., alpn => [h2, http1]} on a service, or livery:start_listener(livery_h1h2, Opts) directly.

What an adapter is not

  • Not a state machine. Framing, header compression, flow control, and TLS belong to the wire library.
  • Not a buffer. The body reader buffers; the adapter does not.
  • Not a router. Routing happens in the worker, after the request reaches it: a compiled router is a handler (livery:router_handler/1), behind the middleware stack.

How an adapter is wired

On a new request the adapter builds a request value, asks livery_req_sup:start_request/1 to spawn the per-request worker, and feeds the body in as {livery_body, Ref, _} messages:

{ok, Worker} = livery_req_sup:start_request(#{
    adapter => ?MODULE, stream => Stream, req => Req,
    stack => Stack, handler => Handler
}),
Worker ! {livery_body, BodyRef, {data, Chunk}},
Worker ! {livery_body, BodyRef, eof}.

The worker runs the middleware and handler, then sends the response back out through livery:emit/3, which calls your send_headers/4, send_data/3, and send_trailers/2. So the adapter is two halves: turn inbound wire events into the body protocol, and implement the send_* callbacks for the outbound side.

Livery's own HTTP adapters share the inbound half in livery_translator. Each runs in the process its wire library starts for the request, builds the request value, registers the stream if the library needs that, and hands over with a classify fun that recognises the library's body, trailer and reset events. The translator starts the worker, enforces max_body, and reports disconnects, the same way on every protocol.

examples/livery_example_adapter.erl is a complete, readable adapter that does exactly this, capturing the response in ETS instead of a socket so you can follow how the pieces connect. Section 10 of Build a complete service walks through it. To grow it into a real transport, keep the callbacks, replace the ETS writes with socket writes, translate your wire's body events into {livery_body, Ref, _} messages, then add a group to test/livery_parity_SUITE.erl so it is held to the same behaviour as the others.

Capability gating

A handler can branch on what the arriving protocol supports:

Adapter = livery_req:adapter(Req),
case Adapter:capabilities(livery_req:stream(Req)) of
    #{trailers := true} ->
        livery_resp:with_trailers([{<<"x-fin">>, <<"1">>}], Resp);
    _ ->
        Resp
end.

Call capabilities/1 on the concrete adapter module the request arrived on (livery_req:adapter/1), not on livery_adapter. trailers and extended_connect depend on the protocol; datagrams and capsules apply to WebTransport on H3.

Some features are optional callbacks instead: send_full/5, send_informational/3, accept_ws/4 and accept_wt/4. An adapter offers one by implementing it, and Livery checks with livery_adapter:implements/3, never with a list of adapters. That is how an adapter written outside Livery gets Early Hints, WebSocket or WebTransport.

Listen address

Every adapter takes the same ip => inet:ip_address() and inet6 => boolean() listen options, translated to the wire library by livery_inet:socket_addr_opts/1. See Bind to an address or IPv6.

The client adapter, its dual

The same idea works outbound. livery_client_adapter is the mirror image of this behaviour: it does the network work for an outgoing request, while the client's layers (timeout, retry, circuit breaker) decide the policy above it. The default livery_client_hackney covers HTTP/1.1, HTTP/2, and HTTP/3. When the target is a pool of replicas, a balance layer spreads requests across them and livery_client_discover finds the set of endpoints. See Make outbound HTTP requests and Load-balance outbound requests.

See also