From a92f1fb7f5d50143169d7458d308993c84b7c261 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 09:13:46 +0100 Subject: [PATCH 1/6] Refactor demo to proper OTP app structure Replace demo.escript with demo_app and demo_sup modules. Routes are defined in demo_sup:routes/0. Co-Authored-By: Claude Sonnet 4.6 --- example/rebar.config | 3 +- example/src/demo.app.src | 1 + example/src/demo_app.erl | 13 +++++++ example/{demo.escript => src/demo_sup.erl} | 45 +++++++++++----------- 4 files changed, 37 insertions(+), 25 deletions(-) create mode 100644 example/src/demo_app.erl rename example/{demo.escript => src/demo_sup.erl} (54%) diff --git a/example/rebar.config b/example/rebar.config index d6532b6..8bf5410 100644 --- a/example/rebar.config +++ b/example/rebar.config @@ -5,6 +5,5 @@ ]}. {shell, [ - {apps, [demo]}, - {script_file, "demo.escript"} + {apps, [demo]} ]}. diff --git a/example/src/demo.app.src b/example/src/demo.app.src index e9214dc..7d25f60 100644 --- a/example/src/demo.app.src +++ b/example/src/demo.app.src @@ -1,5 +1,6 @@ {application, demo, [ {description, "elli_openapi example application"}, {vsn, "0.1.0"}, + {mod, {demo_app, []}}, {applications, [kernel, stdlib, elli, elli_openapi]} ]}. diff --git a/example/src/demo_app.erl b/example/src/demo_app.erl new file mode 100644 index 0000000..082d886 --- /dev/null +++ b/example/src/demo_app.erl @@ -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. diff --git a/example/demo.escript b/example/src/demo_sup.erl similarity index 54% rename from example/demo.escript rename to example/src/demo_sup.erl index 2758d8c..6444a89 100644 --- a/example/demo.escript +++ b/example/src/demo_sup.erl @@ -1,9 +1,25 @@ -#!/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}, @@ -11,21 +27,4 @@ main(_) -> {<<"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. + ]. From 6eb85a1920a54d326a80d49eedb1fd1fc2baa1fb Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 09:16:02 +0100 Subject: [PATCH 2/6] Add demo make target and profile for running the example app Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 5 ++++- README.md | 3 +-- rebar.config | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 31fe46a..b8307cd 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -47,6 +47,9 @@ check_app_calls: doc: rebar3 ex_doc +demo: + rebar3 as demo shell + hex: rebar3 hex build rebar3 hex publish diff --git a/README.md b/README.md index 8f023fb..9e03cd9 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,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: diff --git a/rebar.config b/rebar.config index a4dc902..3b9bf97 100644 --- a/rebar.config +++ b/rebar.config @@ -9,6 +9,10 @@ {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, "eqwalizer_support"}} ]} + ]}, + {demo, [ + {project_app_dirs, [".", "example"]}, + {shell, [{apps, [demo]}]} ]} ]}. From 95eae5f089864bb41bca3e468b76b3084b7c495c Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 09:21:05 +0100 Subject: [PATCH 3/6] Fix documentation based on review feedback - Handler arity is 4 (PathArgs, QueryArgs, Headers, Body), not 3 - Document {MetaData, Routes} variant for callback_args - Document QueryArgs as second handler argument - Document binary() as the body type for bodyless methods (GET etc.) - Document -spectra()/-spec placement constraint - Fix Swagger UI URL in CHANGELOG (was /api-docs, is /swagger) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +-- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 285dc71..448fb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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 diff --git a/README.md b/README.md index 9e03cd9..22c790d 100644 --- a/README.md +++ b/README.md @@ -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} ], -%% 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}, @@ -31,6 +31,17 @@ 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 @@ -38,22 +49,27 @@ See the `example/` directory for a runnable example application with handler imp 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 @@ -67,12 +83,29 @@ 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(...) -> ... +``` + For complete handler examples, see `example/src/elli_openapi_demo.erl`. ## Example Application From aa8307a26f14f02b9596ae5da779f5f9c3d26ae6 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 09:30:11 +0100 Subject: [PATCH 4/6] Prepare release 0.1.1 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- src/elli_openapi.app.src | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 448fb03..13a53b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 22c790d..479e83e 100644 --- a/README.md +++ b/README.md @@ -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"} ]}. ``` diff --git a/src/elli_openapi.app.src b/src/elli_openapi.app.src index 9795b04..5792654 100644 --- a/src/elli_openapi.app.src +++ b/src/elli_openapi.app.src @@ -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, []}, From c3f5fa444232c2742483af8d5c939ee9c5b4a628 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 09:43:27 +0100 Subject: [PATCH 5/6] Add link to Spectra documentation in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 479e83e..389a571 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ some_other_function() -> ... 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 From f056d2222d10a8f85bd89293e90eab53e782524b Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 27 Mar 2026 10:08:44 +0100 Subject: [PATCH 6/6] Fix user_handler arity to /4 and add integration tests user_handler was exporting /3 functions while the library requires /4 (PathArgs, QueryArgs, Headers, Body). Add user_handler_SUITE to catch this class of regression going forward. Co-Authored-By: Claude Sonnet 4.6 --- example/src/user_handler.erl | 13 ++-- test/user_handler_SUITE.erl | 119 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 test/user_handler_SUITE.erl diff --git a/example/src/user_handler.erl b/example/src/user_handler.erl index c115aef..cc9e892 100644 --- a/example/src/user_handler.erl +++ b/example/src/user_handler.erl @@ -1,6 +1,6 @@ -module(user_handler). --export([get_user/3, create_user/3]). +-export([get_user/4, create_user/4]). -record(user, { id :: binary(), @@ -8,21 +8,20 @@ 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}. diff --git a/test/user_handler_SUITE.erl b/test/user_handler_SUITE.erl new file mode 100644 index 0000000..3e61008 --- /dev/null +++ b/test/user_handler_SUITE.erl @@ -0,0 +1,119 @@ +-module(user_handler_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-export([ + all/0, + init_per_suite/1, + end_per_suite/1, + init_per_testcase/2, + end_per_testcase/2 +]). + +-export([ + get_user_success/1, + get_user_not_found/1, + create_user_success/1, + create_user_missing_field/1 +]). + +%%==================================================================== +%% CT Callbacks +%%==================================================================== + +all() -> + [ + get_user_success, + get_user_not_found, + create_user_success, + create_user_missing_field + ]. + +init_per_suite(Config) -> + process_flag(trap_exit, true), + {ok, _} = application:ensure_all_started(inets), + {ok, _} = application:ensure_all_started(elli), + + Routes = [ + {<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/4}, + {<<"POST">>, <<"/api/users">>, fun user_handler:create_user/4} + ], + + Port = 8766, + ElliOpts = [ + {callback, elli_openapi_handler}, + {callback_args, Routes}, + {port, Port} + ], + + {ok, Pid} = elli:start_link(ElliOpts), + unlink(Pid), + [{elli_pid, Pid}, {port, Port} | Config]. + +end_per_suite(Config) -> + Pid = ?config(elli_pid, Config), + elli:stop(Pid), + ok. + +init_per_testcase(_TestCase, Config) -> + Config. + +end_per_testcase(_TestCase, _Config) -> + ok. + +%%==================================================================== +%% Helpers +%%==================================================================== + +url(Config, Path) -> + Port = ?config(port, Config), + lists:flatten(io_lib:format("http://localhost:~p~s", [Port, Path])). + +http_get(Url) -> + httpc:request(get, {Url, []}, [], []). + +http_post(Url, ContentType, Body) -> + httpc:request(post, {Url, [], ContentType, Body}, [], []). + +%%==================================================================== +%% Test Cases +%%==================================================================== + +get_user_success(Config) -> + {ok, {{_, 200, _}, _Headers, ResponseBody}} = http_get(url(Config, "/api/users/123")), + + ?assertMatch( + #{<<"id">> := <<"123">>, <<"name">> := <<"Alice">>, <<"role">> := <<"user">>}, + json:decode(list_to_binary(ResponseBody)) + ). + +get_user_not_found(Config) -> + ?assertMatch( + {ok, {{_, 404, _}, _Headers, _Body}}, + http_get(url(Config, "/api/users/999")) + ). + +create_user_success(Config) -> + RequestBody = json:encode(#{ + <<"id">> => <<"user-42">>, + <<"name">> => <<"Bob">>, + <<"role">> => <<"admin">> + }), + + {ok, {{_, 201, _}, Headers, ResponseBody}} = + http_post(url(Config, "/api/users"), "application/json", RequestBody), + + ?assertMatch({_, _}, lists:keyfind("location", 1, Headers)), + ?assertMatch( + #{<<"id">> := <<"user-42">>, <<"name">> := <<"Bob">>, <<"role">> := <<"admin">>}, + json:decode(list_to_binary(ResponseBody)) + ). + +create_user_missing_field(Config) -> + RequestBody = json:encode(#{<<"name">> => <<"Bob">>}), + + ?assertMatch( + {ok, {{_, 400, _}, _Headers, _Body}}, + http_post(url(Config, "/api/users"), "application/json", RequestBody) + ).