Tutorial: Build a complete service

View Source

In this tutorial you start a real service that listens on a socket and walk the whole path: routing, middleware, reading requests and writing responses, streaming, WebSockets, serving three protocols at once, shutting down gracefully, and writing your own adapter. It is for you once the smaller tutorials feel familiar and you want to see the pieces fit together. Take your time; it is a longer read, maybe twenty-five minutes.

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 you will build

A tiny notes service. You keep notes in an ETS table and expose them over HTTP: list them, create one, fetch one, delete one. Then you add a live events feed and a WebSocket echo on top. It uses every part of Livery you will need in a real service.

Run it first, so you know where you 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/1

When you are done, livery_example_complete:stop(Pid) puts everything away. Now build it from scratch.

2. Start a service

A service is the front door. livery:start_service/1 takes one map, starts the listeners, connects 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 you 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 starts a single adapter directly, for example livery:start_listener(livery_h1, Opts). The service is the easier choice when you want several protocols sharing one set of handlers, which is where you are going in section 8.

3. Routing

A router maps a method and a path to a handler. You 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; you 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 plain: 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.

You 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, use livery_ext:query/2, covered in Read query strings.

5. Middleware

Middleware is where you put the work every request shares: logging, request IDs, limits, timing. A Livery middleware receives the request value and a Next function that runs the rest of the stack, and returns a response, as in Tower and Axum. Nothing is changed in place: this is not the older style where each step edits shared request and response objects and then calls next. The shape is call(Req, Next, State), or a fun((Req, Next)). You may change the request before calling Next, change the response after, answer on your own by never calling Next, or all three.

Here is a 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.

You 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 only 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 shortcut for the common case where you only change the response. There is before/1 for the request side, and wrap/1 to catch errors from the rest of the stack and turn them into a response. More in The middleware pipeline.

6. Streaming with Server-Sent Events

Not every response is ready all at once. For a live feed you want to send events as they happen. livery_resp:sse/2 gives your function an Emit callback; you call it as often as you like, and Livery formats each event and sends it.

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 events arriving. The same idea works for 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: the upgrade uses the listener you already have. On HTTP/1.1 it is the usual Upgrade handshake; on HTTP/2 and HTTP/3 it is extended CONNECT. The same handler works on all three, with nothing extra to set up.

8. Serve three protocols at once

This is what the service is for. 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 testing only, never production. Add alt_svc => advertise to the map and the service advertises HTTP/3 with an Alt-Svc header on the H1 and H2 responses, so clients that support HTTP/3 can switch to it. Without it, no Alt-Svc header is sent. To bind to a specific address or use IPv6, see Bind to an address or IPv6, and for the bigger picture, Adapters.

9. Shut down gracefully

Cutting a request off halfway is rude to the client. 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 running. More in Shut down gracefully.

10. Write your own adapter

So far you 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 holds almost no logic: the protocol's framing and TLS live in the wire library, routing and middleware live in Livery above it. The adapter 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 short, runnable adapter that does this, with one shortcut: instead of a socket it stores the response in an ETS table, so you can see how the pieces connect without a network. The main part is the function that drives a request:

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 turn 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 behaviour, seen from outside, as the others. livery_test_adapter is the smallest complete adapter to compare against, and Adapters explains in full what an adapter must do.

11. Test it without a socket

Because a handler takes a request value and returns a response value, you can test it 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 have now seen the whole core. From here, the how-to guides go further into the more specialised parts: