Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.1] - 2026-03-27

### Added
- `make demo` target for running the example application locally

### Changed
- Demo application restructured as a proper OTP app in the `example/` directory, replacing the previous `demo.escript`

### Fixed
- Documentation improvements for handler spec placement and argument descriptions

## [0.1.0] - 2026-03-27

### Added
Expand All @@ -14,6 +25,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support for multiple HTTP status codes per endpoint via union types in function specs
- Request and response body validation and encoding (JSON and text/plain)
- Request header validation against declared function specs
- Swagger UI served at `/api-docs`
- Redoc UI served at `/redoc`
- Swagger UI served at `/swagger`, ReDoc at `/redoc`, raw OpenAPI JSON at `/api-docs`
- OpenAPI spec stored in `persistent_term` for fast in-memory access
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all compile format test cover clean doc hank format_verify build-test dialyzer xref type_check check_app_calls hex release
.PHONY: all compile format test cover clean doc hank format_verify build-test dialyzer xref type_check check_app_calls hex release demo

all: compile format test cover

Expand Down Expand Up @@ -47,6 +47,9 @@ check_app_calls:
doc:
rebar3 ex_doc

demo:
rebar3 as demo shell

hex:
rebar3 hex build
rebar3 hex publish
Expand Down
60 changes: 47 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This library is not ready for production use, but it wont take long to finish it

```erlang
{deps, [
{elli_openapi, "~> 0.1.0"}
{elli_openapi, "~> 0.1.1"}
]}.
```

Expand All @@ -17,11 +17,11 @@ This library is not ready for production use, but it wont take long to finish it
```erlang
%% Define your routes
Routes = [
{<<"POST">>, <<"/api/users">>, fun user_handler:create_user/3},
{<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/3}
{<<"POST">>, <<"/api/users">>, fun user_handler:create_user/4},
{<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/4}
Comment thread
andreashasse marked this conversation as resolved.
],

%% Configure and start Elli, preferably in you supervisor spec.
%% Configure and start Elli, preferably in your supervisor spec.
ElliOpts = [
{callback, elli_openapi_handler},
{callback_args, Routes},
Expand All @@ -31,29 +31,45 @@ ElliOpts = [
{ok, Pid} = elli:start_link(ElliOpts).
```

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

```erlang
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:

```erlang
handler_name(PathArgs, Headers, Body) -> {StatusCode, ResponseHeaders, ResponseBody}
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 => ...the provided userid...}`
- Example: For route `<<"/api/users/{userId}">>`, PathArgs would be `#{userId => <<"42">>}`
- Empty map `#{}` if no path parameters

2. **Headers** (`map()`): HTTP request headers with atom keys
- Example: `#{'Authorization' => ..., 'Content-Type' => ...}`
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

3. **Body** (`any()`): Request body, automatically decoded based on the type in your function spec
- Plain text requests: `binary()`
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
Expand All @@ -67,12 +83,31 @@ 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:

```erlang
-spec my_handler(PathArgs, Headers, Body) ->
-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.

```erlang
%% 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](https://hexdocs.pm/spectra/readme.html) for supported types and serialization rules.

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

## Example Application
Expand All @@ -82,8 +117,7 @@ The `example/` directory contains a runnable demo application showcasing multipl
To run the example:

```bash
cd example
rebar3 shell
make demo
```

The demo starts on port 3000. Access the API documentation at:
Expand Down
3 changes: 1 addition & 2 deletions example/rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@
]}.

{shell, [
{apps, [demo]},
{script_file, "demo.escript"}
{apps, [demo]}
]}.
Comment thread
andreashasse marked this conversation as resolved.
1 change: 1 addition & 0 deletions example/src/demo.app.src
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{application, demo, [
{description, "elli_openapi example application"},
{vsn, "0.1.0"},
{mod, {demo_app, []}},
{applications, [kernel, stdlib, elli, elli_openapi]}
]}.
13 changes: 13 additions & 0 deletions example/src/demo_app.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-module(demo_app).

-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
{ok, Pid} = demo_sup:start_link(),
io:format("Demo started. API docs at http://localhost:3000/swagger~n"),
{ok, Pid}.

stop(_State) ->
ok.
45 changes: 22 additions & 23 deletions example/demo.escript → example/src/demo_sup.erl
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
#!/usr/bin/env escript
%% -*- erlang -*-
%%! -pa _build/default/lib/*/ebin
-module(demo_sup).

main(_) ->
Routes = [
-behaviour(supervisor).

-export([init/1, start_link/0]).

start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
ElliOpts = [
{callback, elli_openapi_handler},
{callback_args, routes()},
{port, 3000}
],
Children = [
#{id => elli, start => {elli, start_link, [ElliOpts]}, restart => permanent}
],
{ok, {#{strategy => one_for_one}, Children}}.

routes() ->
[
{<<"POST">>, <<"/api/users">>, fun elli_openapi_demo:create_user/4},
{<<"GET">>, <<"/api/users/{userId}">>, fun elli_openapi_demo:get_user/4},
{<<"POST">>, <<"/api/echo">>, fun elli_openapi_demo:echo_text/4},
{<<"POST">>, <<"/api/status">>, fun elli_openapi_demo:update_status/4},
{<<"PUT">>, <<"/api/items/{itemId}">>, fun elli_openapi_demo:update_item/4},
{<<"GET">>, <<"/api/users">>, fun elli_openapi_demo:list_users/4},
{<<"GET">>, <<"/api/search">>, fun elli_openapi_demo:search_users/4}
],
Port = 3000,
ElliOpts = [
{callback, elli_openapi_handler},
{callback_args, Routes},
{port, Port}
],

%% Start Elli
case elli:start_link(ElliOpts) of
{ok, _Pid} ->
io:format(
"Elli openapi is started. Access the API documentation at: http://localhost:~p/swagger~n",
[Port]
);
{error, Reason} ->
io:format("Failed to start Elli server: ~p~n~n", [Reason])
end.
].
13 changes: 6 additions & 7 deletions example/src/user_handler.erl
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
-module(user_handler).

-export([get_user/3, create_user/3]).
-export([get_user/4, create_user/4]).

-record(user, {
id :: binary(),
name :: binary(),
role :: admin | user | guest
}).

-ignore_xref([create_user/3, get_user/3]).
-hank([{unnecessary_function_arguments, [{get_user, 3}]}]).
-ignore_xref([create_user/4, get_user/4]).

-spec get_user(#{userId := binary()}, #{}, binary()) ->
-spec get_user(#{userId := binary()}, #{}, #{}, binary()) ->
{200, #{}, #user{}}
| {404, #{}, #{message := binary()}}.
get_user(#{userId := Id}, _Hdrs, _Body) ->
get_user(#{userId := Id}, #{}, _Hdrs, _Body) ->
case find_user(Id) of
{ok, User} -> {200, #{}, User};
not_found -> {404, #{}, #{message => ~"User not found"}}
end.

-spec create_user(#{}, #{}, #user{}) ->
-spec create_user(#{}, #{}, #{}, #user{}) ->
{201, #{'Location' => binary()}, #user{}}.
create_user(#{}, #{}, User) ->
create_user(#{}, #{}, #{}, User) ->
io:format("Creating user: ~s with role ~p~n", [User#user.name, User#user.role]),
Location = <<"/api/users/", (User#user.id)/binary>>,
{201, #{'Location' => Location}, User}.
Expand Down
4 changes: 4 additions & 0 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
{git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"},
"eqwalizer_support"}}
]}
]},
{demo, [
{project_app_dirs, [".", "example"]},
{shell, [{apps, [demo]}]}
]}
]}.

Expand Down
2 changes: 1 addition & 1 deletion src/elli_openapi.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, elli_openapi, [
{description, "OpenAPI in Elli using Spectra"},
{vsn, "0.1.0"},
{vsn, "0.1.1"},
{registered, []},
{applications, [kernel, stdlib, elli, spectra]},
{env, []},
Expand Down
Loading
Loading