elli_openapi

Library for building type-safe HTTP APIs with automatic OpenAPI documentation generation using Elli and Spectra. This library is not ready for production use, but it wont take long to finish it.

Usage

  1. Add to your rebar.config dependencies:
{deps, [
    {elli_openapi, "~> 0.1.1"}
]}.
  1. Start Elli with elli_openapi_handler as the callback and your routes as arguments to elli_openapi_handler:
%% Define your routes
Routes = [
    {<<"POST">>, <<"/api/users">>, fun user_handler:create_user/4},
    {<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/4}
],

%% Configure and start Elli, preferably in your supervisor spec.
ElliOpts = [
    {callback, elli_openapi_handler},
    {callback_args, Routes},
    {port, 3000}
],

{ok, Pid} = elli:start_link(ElliOpts).

You can optionally pass custom OpenAPI metadata by wrapping callback_args in a {MetaData, Routes} tuple:

MetaData = #{title => <<"My API">>, version => <<"1.0.0">>},
ElliOpts = [
    {callback, elli_openapi_handler},
    {callback_args, {MetaData, Routes}},
    {port, 3000}
].

See the example/ directory for a runnable example application with handler implementations.

Handler Functions

All handler functions must follow this signature:

handler_name(PathArgs, QueryArgs, Headers, Body) -> {StatusCode, ResponseHeaders, ResponseBody}

Arguments

  1. PathArgs (map()): URL path parameters extracted from the route

    • Example: For route <<"/api/users/{userId}">>, PathArgs would be #{userId => <<"42">>}
    • Empty map #{} if no path parameters
  2. QueryArgs (map()): URL query parameters

    • Example: #{page => 1, per_page => 20}
    • Declare expected query params in the function spec; undeclared params are ignored
  3. Headers (map()): HTTP request headers with atom keys

    • Example: #{'Authorization' => <<"Bearer ...">>, 'Content-Type' => <<"application/json">>}
    • Required headers must be declared in the function spec
  4. Body (any()): Request body, automatically decoded based on the type in your function spec

    • JSON requests: map() or record type
    • Plain text requests: binary()
    • Bodyless methods (GET, HEAD, etc.): declare as binary() — an empty body decodes cleanly to <<"">>
    • The library validates and decodes the body according to your spec

Return Value

Must be a 3-tuple: {StatusCode, ResponseHeaders, ResponseBody}

To return different status codes from the same handler, use union types in your function spec where each branch represents a possible response:

-spec my_handler(PathArgs, QueryArgs, Headers, Body) ->
    {200, Headers1, SuccessBody}
    | {400, Headers2, ErrorBody}
    | {404, Headers3, NotFoundBody}.

Spec placement

-spectra() metadata attributes and -spec declarations must appear before any function clause in the file. The Erlang compiler processes attributes in declaration order — placing them after a function clause will cause them to be ignored or crash at startup.

%% Correct order
-spectra(#{summary => <<"Create user">>}).
-spec create_user(#{}, #{}, #{}, #user{}) -> {201, #{}, #user{}}.
create_user(#{}, #{}, #{}, User) -> ...

%% Wrong — attributes after a function clause are not processed
some_other_function() -> ...
-spectra(#{summary => <<"Create user">>}).   %% too late
-spec create_user(...) -> ...
create_user(...) -> ...

Handler specs use Spectra's type system. See the Spectra documentation for supported types and serialization rules.

For complete handler examples, see example/src/elli_openapi_demo.erl.

Example Application

The example/ directory contains a runnable demo application showcasing multiple handler implementations including user management, echo, status updates, and item updates with conflict detection.

To run the example:

make demo

The demo starts on port 3000. Access the API documentation at: