Adapters
View SourceThis 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
| Adapter | Serves | Backed by |
|---|---|---|
livery_test_adapter | in-memory | ETS, no socket |
livery_h1 | HTTP/1.1 | h1 |
livery_h2 | HTTP/2 | h2 |
livery_h3 | HTTP/3 | quic (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
- Concept: Architecture
- Concept: Request lifecycle
- Tutorial: Build a complete service
- Guide: Bind to an address or IPv6
- Reference:
livery_adapter,livery_test_adapter