Tutorial: Build a complete service
View SourceThe other tutorials keep things small and run everything through the in-memory test adapter. This one is the big tour. We start a real service that listens on a socket, and we walk the whole path together: routing, middleware, reading requests and writing responses, streaming, WebSockets, serving three protocols at once, shutting down gracefully, and finally writing your own adapter. Take your time; it is a longer read, maybe twenty-five minutes, and worth it.
Every step has a companion in examples/livery_example_complete.erl, so
you can run the finished thing while you read, and in
examples/livery_example_adapter.erl for the adapter at the end.
1. The app we will build
A tiny notes service. We keep notes in an ETS table and expose them over HTTP: list them, create one, fetch one, delete one. Then we add a live events feed and a WebSocket echo on top. Nothing fancy, but it touches every part of Livery you will reach for in a real service.
Let us run it first, so you know where we are going:
rebar3 as examples shell{ok, Pid} = livery_example_complete:start(8080).In another terminal:
curl http://127.0.0.1:8080/notes
curl -XPOST --data '{"text":"buy bread"}' http://127.0.0.1:8080/notes
curl http://127.0.0.1:8080/notes/1
curl -XDELETE http://127.0.0.1:8080/notes/1When you are done, livery_example_complete:stop(Pid) puts everything
away. Now let us build it from scratch.
2. Start a service
A service is the front door. livery:start_service/1 takes one map and
brings up listeners, wires your router, and shares one middleware stack
across all of them.
start(Port) ->
ensure_table(),
livery:start_service(#{
http => #{port => Port},
middleware => base_stack(),
router => router()
}).That is the whole startup. The http key asks for an HTTP/1.1 listener
on Port; router and middleware we define in the next sections. You
get back {ok, Pid}, and livery:stop_service(Pid) later stops it.
If you only ever want one protocol, livery:start_listener/2 gives you a
single adapter directly, for example
livery:start_listener(livery_h1, Opts). The service is the friendlier
choice when you want several protocols sharing one set of handlers, which
is exactly where we are headed in section 8.
3. Routing
A router maps a method and a path to a handler. We compile a list of routes once, at startup:
router() ->
livery_router:compile([
{<<"GET">>, <<"/notes">>, {?MODULE, list_notes}, #{middleware => [list_marker()]}},
{<<"POST">>, <<"/notes">>, {?MODULE, create_note}},
{<<"GET">>, <<"/notes/:id">>, {?MODULE, show_note}},
{<<"DELETE">>, <<"/notes/:id">>, {?MODULE, delete_note}},
{<<"GET">>, <<"/events">>, {?MODULE, events}},
{<<"GET">>, <<"/ws">>, {?MODULE, ws}}
]).Three kinds of segment exist: a plain word like notes, a parameter like
:id, and a trailing wildcard like *rest. A parameter captures whatever
sits in that slot, and you read it back in the handler:
show_note(Req) ->
Id = livery_req:binding(<<"id">>, Req),
...A handler is {Module, Function} or a plain fun((Req) -> Resp). The
fourth element of a route, when present, is its Meta; we use it here to
attach a per-route middleware, which section 5 comes back to. For the full
matching rules, see Routing.
4. Request and response
Handlers in Livery are refreshingly boring: one request value in, one
response value out, no socket in sight. You read what you need from the
request, and you build a response with the livery_resp helpers.
Reading the body deserves a word. The socket adapters hand you the body as a stream, so you read it to the end before you decode it:
decode_body(Req) ->
Bin =
case livery_req:body(Req) of
{stream, Reader} ->
{ok, Data, _} = livery_body:read_all(Reader),
Data;
{buffered, IoData} ->
iolist_to_binary(IoData);
empty ->
<<>>
end,
try {ok, json:decode(Bin)} catch
_:_ -> {error, invalid_json}
end.We accept {buffered, _} too, so the same handler runs under the test
adapter in section 11. With that in hand, creating a note is small:
create_note(Req) ->
case decode_body(Req) of
{ok, #{<<"text">> := Text}} when is_binary(Text) ->
Note = put_note(Text),
Id = maps:get(<<"id">>, Note),
Location = <<"/notes/", Id/binary>>,
Resp = livery_resp:json(201, json:encode(Note)),
livery_resp:with_header(<<"location">>, Location, Resp);
{ok, _} ->
livery_resp:json(422, <<"{\"error\":\"text is required\"}">>);
{error, _} ->
livery_resp:json(400, <<"{\"error\":\"invalid json\"}">>)
end.You have met most of the response builders already: livery_resp:json/2,
text/2, and empty/1 for a bodiless answer like our 204 on delete.
with_header/3 adds or replaces a header on any response. There are more
(redirect/2, html/2, file/2); see
Request and response. To read query
string parameters, reach for livery_ext:query/2, covered in
Read query strings.
5. Middleware
Middleware is how you do the cross-cutting work: logging, request IDs,
limits, timing. A Livery middleware is a continuation over immutable
values, in the Tower and Axum spirit, not the old mutate-and-next style.
The shape is call(Req, Next, State), or simply a fun((Req, Next)). You
may change the request before calling Next, change the response after,
short-circuit by never calling Next, or all three.
Here is our timing middleware, written as a fun:
timing() ->
fun(Req, Next) ->
Start = erlang:monotonic_time(millisecond),
Resp = Next(Req),
Elapsed = erlang:monotonic_time(millisecond) - Start,
livery_resp:with_header(
<<"x-response-time-ms">>,
integer_to_binary(Elapsed),
Resp
)
end.We stack it after the built-ins, and the whole stack runs for every request, in order:
base_stack() ->
[
{livery_request_id, undefined},
{livery_access_log, #{}},
{livery_body_limit, #{max => 1_048_576}},
timing()
].Sometimes a rule belongs to one route only. That is what the route Meta
was for in section 3: the middleware key holds a stack that runs just
for that route, nested inside the service-wide one.
list_marker() ->
livery_middleware:after_response(
fun(Resp) -> livery_resp:with_header(<<"x-list">>, <<"notes">>, Resp) end
).livery_middleware:after_response/1 is a small convenience for the common
"only touch the response" case. There is before/1 for the request side
and wrap/1 for try/catch recovery. More in
The middleware pipeline.
6. Streaming with Server-Sent Events
Not every response fits in one buffer. For a live feed you want to push
events as they happen. livery_resp:sse/2 hands your function an Emit
callback; you call it as often as you like, and Livery frames each event
on the wire.
events(_Req) ->
Count = length(all_notes()),
livery_resp:sse(200, fun(Emit) ->
_ = [
Emit(#{event => <<"notes">>, data => integer_to_binary(Count)})
|| _ <- lists:seq(1, 3)
],
ok
end).curl -N http://127.0.0.1:8080/events shows the frames arriving. The same
idea drives chunked bodies (livery_resp:stream/3) and NDJSON
(livery_resp:ndjson/2). See
Streaming and backpressure.
7. WebSocket
A WebSocket route hands the stream over to a session handler. The route handler is a one-liner:
ws(Req) ->
livery_ws:upgrade(Req, ?MODULE, #{}).The handler module implements the ws_handler behaviour. Ours is a plain
echo: whatever comes in goes back out.
init(_Req, _Opts) -> {ok, undefined}.
handle_in({text, Bin}, State) -> {reply, [{text, Bin}], State};
handle_in({binary, Bin}, State) -> {reply, [{binary, Bin}], State};
handle_in({ping, Bin}, State) -> {reply, [{pong, Bin}], State};
handle_in({close, Code, _}, State) -> {stop, {closed, Code}, State};
handle_in(_Frame, State) -> {ok, State}.
handle_info(_Msg, State) -> {ok, State}.
terminate(_Reason, _State) -> ok.The nice part: this upgrade rides the listener you already have. On HTTP/1.1 it is the classic Upgrade handshake; on HTTP/2 and HTTP/3 it is extended CONNECT. Same handler, no extra plumbing.
8. Serve three protocols at once
This is where the service really earns its keep. Add an https key and an
http3 key to the same map, point all three at the same router, and you
are serving HTTP/1.1, HTTP/2 over TLS, and HTTP/3 over QUIC from one set of
handlers.
start_tls(Port) ->
ensure_table(),
{ok, Cert, Key} = load_certs(),
livery:start_service(#{
http => #{port => Port},
https => #{port => Port, cert => Cert, key => Key},
http3 => #{port => Port, cert => Cert, key => Key},
middleware => base_stack(),
router => router()
}).The example borrows the self-signed certs under test/certs, which are
for local play only, never production. The service advertises HTTP/3 with
an Alt-Svc header on the H1 and H2 responses, so clients that know how
can upgrade themselves. To pin a specific address or go IPv6, see
Bind to an address or IPv6, and for the
bigger picture, Adapters.
9. Shut down gracefully
Pulling the plug mid-request is rude. livery:drain/2 stops accepting new
connections, waits for the requests already running to finish, then stops
the service.
ok = livery:drain(Pid, #{timeout => 30000}).If you want to watch it happen, livery_drain:in_flight/0 tells you how
many requests are still in flight. More in
Shut down gracefully.
10. Write your own adapter
So far we have used the adapters that ship with Livery. What if you have a transport they do not cover? You write an adapter. It is less work than it sounds, because an adapter owns almost no logic: framing and TLS live in the wire library, routing and middleware live above. The adapter just translates between the two.
An adapter implements the livery_adapter behaviour, eight callbacks:
start(Name, ListenSpec, Opts) -> {ok, Listener}.
stop(Listener) -> ok.
send_headers(Stream, Status, Headers, SendOpts) -> SendResult.
send_data(Stream, IoData, SendOpts) -> SendResult.
send_trailers(Stream, Trailers) -> SendResult.
reset(Stream, Reason) -> ok.
peer_info(Stream) -> map().
capabilities(Listener) -> map().The lifecycle is the same for every adapter. On a new request you spawn a
worker with livery_req_sup:start_request/1, feed the body into it as
{livery_body, Ref, _} messages, and the worker runs your middleware and
handler and drives the response back out through livery:emit/3, which
calls the send_* callbacks above.
examples/livery_example_adapter.erl is a readable, runnable adapter that
does exactly this, with one shortcut: instead of a socket it captures the
response in an ETS table, so you can study the wiring without a wire. The
heart of it is the request driver:
request(Listener, Stack, Handler, Spec) ->
Stream = new_stream(Listener),
BodyRef = make_ref(),
Reader = livery_body:new(BodyRef),
Req0 = livery_req:new(Fields),
Req = Req0#livery_req{adapter = ?MODULE, stream = Stream, body = {stream, Reader}},
{ok, Worker} = livery_req_sup:start_request(#{
adapter => ?MODULE, stream => Stream, req => Req,
stack => Stack, handler => Handler
}),
MRef = erlang:monitor(process, Worker),
Worker ! {livery_body, BodyRef, eof},
receive {'DOWN', MRef, process, Worker, _} -> ok after 5000 -> error(worker_timeout) end,
capture(Stream).To grow this into a real transport, keep the callbacks, replace the ETS
sink with socket writes, and translate your wire's incoming body events
into {livery_body, Ref, _} messages (the {h1_stream, _} loop in
livery_h1 is the template). When it works, add a group to
test/livery_parity_SUITE.erl so your adapter is held to the same
observable behaviour as the others. livery_test_adapter is the canonical
minimal reference, and Adapters explains the
contract in full.
11. Test it without a socket
Because handlers are pure functions of the request, you can test them with
no service running at all. livery_test_adapter:run/3 builds a request,
runs the stack and handler, and captures the response:
create_rejects_bad_json_test() ->
Cap = livery_test_adapter:run(
[], fun livery_example_complete:create_note/1,
#{method => <<"POST">>, body => {buffered, <<"not json">>}}),
?assertEqual(400, livery_test_adapter:status(Cap)).(The happy path writes to the notes table that start/1 creates, so a
test that creates a note would set the table up first or drive the live
service. The rejection path answers before touching the store, which
makes it the simplest thing to check in isolation.)
That is also how the example adapter is tested end to end, in
test/livery_example_adapter_tests.erl. For the four levels of testing,
see Test your handlers.
Next steps
You now have the whole core in your hands. From here, the how-to guides go deeper on the specialised pieces: