From 42fcea089be3a7d0b31fa88e8905fbc2c6a8ffe2 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 10 Jan 2026 14:36:26 +0100 Subject: [PATCH 1/5] Upgrade to latest version of spectra --- rebar.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.config b/rebar.config index 98ca0c5..3c6d3f4 100644 --- a/rebar.config +++ b/rebar.config @@ -18,7 +18,7 @@ {deps, [ {elli, "~> 3.3.0"}, - {spectra, "~> 0.1.1"} + {spectra, "~> 0.2.0"} ]}. {hank, [ From d0f5e4992e28aaed3f4218a07a6207c5ca08f8e9 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 10 Jan 2026 15:14:21 +0100 Subject: [PATCH 2/5] Add tests & Cleanup --- test/elli_openapi_integration_SUITE.erl | 216 ++++++++++-------------- 1 file changed, 86 insertions(+), 130 deletions(-) diff --git a/test/elli_openapi_integration_SUITE.erl b/test/elli_openapi_integration_SUITE.erl index 4401c99..79e7dad 100644 --- a/test/elli_openapi_integration_SUITE.erl +++ b/test/elli_openapi_integration_SUITE.erl @@ -30,7 +30,10 @@ update_item_conflict_409/1, openapi_spec_includes_response_headers/1, openapi_spec_content_types/1, - openapi_spec_multi_status/1 + openapi_spec_multi_status/1, + swagger_ui_endpoint/1, + redoc_endpoint/1, + api_docs_endpoint/1 ]). %%==================================================================== @@ -57,7 +60,10 @@ all() -> update_item_conflict_409, openapi_spec_includes_response_headers, openapi_spec_content_types, - openapi_spec_multi_status + openapi_spec_multi_status, + swagger_ui_endpoint, + redoc_endpoint, + api_docs_endpoint ]. init_per_suite(Config) -> @@ -98,13 +104,32 @@ init_per_testcase(_TestCase, Config) -> end_per_testcase(_TestCase, _Config) -> ok. +%%==================================================================== +%% Helper Functions +%%==================================================================== + +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_get(Url, Headers) -> + httpc:request(get, {Url, Headers}, [], []). + +http_post(Url, ContentType, Body) -> + httpc:request(post, {Url, [], ContentType, Body}, [], []). + +http_put(Url, ContentType, Body) -> + httpc:request(put, {Url, [], ContentType, Body}, [], []). + %%==================================================================== %% Test Cases - Create User %%==================================================================== create_user_success(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = json:encode( @@ -115,12 +140,7 @@ create_user_success(Config) -> } ), - {ok, {{_, 201, _}, Headers, ResponseBody}} = httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ), + {ok, {{_, 201, _}, Headers, ResponseBody}} = http_post(Url, "application/json", RequestBody), ?assertMatch({_, _}, lists:keyfind("location", 1, Headers)), ?assertMatch({_, _}, lists:keyfind("etag", 1, Headers)), @@ -138,8 +158,7 @@ create_user_success(Config) -> ok. create_user_missing_required_field(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = json:encode( @@ -151,37 +170,25 @@ create_user_missing_required_field(Config) -> ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ) + http_post(Url, "application/json", RequestBody) ), ok. create_user_invalid_body_format(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = "{ invalid json", ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ) + http_post(Url, "application/json", RequestBody) ), ok. create_user_invalid_role(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = json:encode( @@ -194,48 +201,31 @@ create_user_invalid_role(Config) -> ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ) + http_post(Url, "application/json", RequestBody) ), ok. create_user_empty_body(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = "", ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ) + http_post(Url, "application/json", RequestBody) ), ok. create_user_wrong_content_type(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), RequestBody = "email=test@example.com&name=Test User", ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/x-www-form-urlencoded", RequestBody}, - [], - [] - ) + http_post(Url, "application/x-www-form-urlencoded", RequestBody) ), ok. @@ -245,16 +235,11 @@ create_user_wrong_content_type(Config) -> %%==================================================================== get_user_success(Config) -> - Port = ?config(port, Config), UserId = "user-456", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users/~s", [Port, UserId])), + Url = url(Config, "/api/users/" ++ UserId), - {ok, {{_, 200, _}, Headers, ResponseBody}} = httpc:request( - get, - {Url, [{"authorization", "Bearer token123"}, {"content-type", "text/plain"}]}, - [], - [] - ), + {ok, {{_, 200, _}, Headers, ResponseBody}} = + http_get(Url, [{"authorization", "Bearer token123"}, {"content-type", "text/plain"}]), ?assertMatch({_, _}, lists:keyfind("etag", 1, Headers)), ?assertMatch({_, _}, lists:keyfind("cache-control", 1, Headers)), @@ -272,33 +257,21 @@ get_user_success(Config) -> ok. get_user_missing_auth_header(Config) -> - Port = ?config(port, Config), UserId = "user-456", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users/~s", [Port, UserId])), + Url = url(Config, "/api/users/" ++ UserId), ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - get, - {Url, []}, - [], - [] - ) + http_get(Url) ), ok. get_user_not_found(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/users", [Port])), + Url = url(Config, "/api/users"), ?assertMatch( {ok, {{_, 404, _}, _Headers, _ResponseBody}}, - httpc:request( - get, - {Url, [{"authorization", "Bearer token123"}]}, - [], - [] - ) + http_get(Url, [{"authorization", "Bearer token123"}]) ), ok. @@ -307,55 +280,37 @@ get_user_not_found(Config) -> %%==================================================================== update_status_success(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/status", [Port])), + Url = url(Config, "/api/status"), RequestBody = "running", ?assertMatch( {ok, {{_, 200, _}, _Headers, "running"}}, - httpc:request( - post, - {Url, [], "text/plain", RequestBody}, - [], - [] - ) + http_post(Url, "text/plain", RequestBody) ), ok. update_status_invalid_value(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/status", [Port])), + Url = url(Config, "/api/status"), RequestBody = "invalid_status", ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "text/plain", RequestBody}, - [], - [] - ) + http_post(Url, "text/plain", RequestBody) ), ok. update_status_wrong_content_type(Config) -> - Port = ?config(port, Config), - Url = lists:flatten(io_lib:format("http://localhost:~p/api/status", [Port])), + Url = url(Config, "/api/status"), RequestBody = json:encode(#{<<"status">> => <<"running">>}), ?assertMatch( {ok, {{_, 400, _}, _Headers, _ResponseBody}}, - httpc:request( - post, - {Url, [], "application/json", RequestBody}, - [], - [] - ) + http_post(Url, "application/json", RequestBody) ), ok. @@ -365,18 +320,12 @@ update_status_wrong_content_type(Config) -> %%==================================================================== update_item_success_200(Config) -> - Port = ?config(port, Config), ItemId = "item-123", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/items/~s", [Port, ItemId])), + Url = url(Config, "/api/items/" ++ ItemId), RequestBody = json:encode(#{<<"name">> => <<"Updated Item">>, <<"version">> => 5}), - {ok, {{_, 200, _}, Headers, ResponseBody}} = httpc:request( - put, - {Url, [], "application/json", RequestBody}, - [], - [] - ), + {ok, {{_, 200, _}, Headers, ResponseBody}} = http_put(Url, "application/json", RequestBody), ?assertMatch({_, _}, lists:keyfind("etag", 1, Headers)), @@ -388,18 +337,12 @@ update_item_success_200(Config) -> ok. update_item_not_found_404(Config) -> - Port = ?config(port, Config), ItemId = "item-notfound", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/items/~s", [Port, ItemId])), + Url = url(Config, "/api/items/" ++ ItemId), RequestBody = json:encode(#{<<"name">> => <<"Any Name">>, <<"version">> => 1}), - {ok, {{_, 404, _}, _Headers, ResponseBody}} = httpc:request( - put, - {Url, [], "application/json", RequestBody}, - [], - [] - ), + {ok, {{_, 404, _}, _Headers, ResponseBody}} = http_put(Url, "application/json", RequestBody), ?assertMatch( #{~"message" := ~"Item not found", ~"code" := ~"ITEM_NOT_FOUND"}, @@ -409,18 +352,12 @@ update_item_not_found_404(Config) -> ok. update_item_invalid_version_400(Config) -> - Port = ?config(port, Config), ItemId = "item-123", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/items/~s", [Port, ItemId])), + Url = url(Config, "/api/items/" ++ ItemId), RequestBody = json:encode(#{<<"name">> => <<"Any Name">>, <<"version">> => -1}), - {ok, {{_, 400, _}, _Headers, ResponseBody}} = httpc:request( - put, - {Url, [], "application/json", RequestBody}, - [], - [] - ), + {ok, {{_, 400, _}, _Headers, ResponseBody}} = http_put(Url, "application/json", RequestBody), ?assertMatch( #{~"message" := ~"Version must be non-negative", ~"code" := ~"INVALID_VERSION"}, @@ -430,18 +367,12 @@ update_item_invalid_version_400(Config) -> ok. update_item_conflict_409(Config) -> - Port = ?config(port, Config), ItemId = "item-conflict", - Url = lists:flatten(io_lib:format("http://localhost:~p/api/items/~s", [Port, ItemId])), + Url = url(Config, "/api/items/" ++ ItemId), RequestBody = json:encode(#{<<"name">> => <<"Any Name">>, <<"version">> => 5}), - {ok, {{_, 409, _}, _Headers, ResponseBody}} = httpc:request( - put, - {Url, [], "application/json", RequestBody}, - [], - [] - ), + {ok, {{_, 409, _}, _Headers, ResponseBody}} = http_put(Url, "application/json", RequestBody), ?assertMatch( #{~"message" := ~"Version conflict detected", ~"code" := ~"VERSION_CONFLICT"}, @@ -592,3 +523,28 @@ openapi_spec_multi_status(_Config) -> ), ok. + +%%==================================================================== +%% Test Cases - Documentation Endpoints +%%==================================================================== + +swagger_ui_endpoint(Config) -> + ?assertMatch( + {ok, {{_, 200, _}, _Headers, _ResponseBody}}, + http_get(url(Config, "/swagger")) + ), + ok. + +redoc_endpoint(Config) -> + ?assertMatch( + {ok, {{_, 200, _}, _Headers, _ResponseBody}}, + http_get(url(Config, "/redoc")) + ), + ok. + +api_docs_endpoint(Config) -> + ?assertMatch( + {ok, {{_, 200, _}, _Headers, _ResponseBody}}, + http_get(url(Config, "/api-docs")) + ), + ok. From 1ce6f9e53026cc79a838d8c9f8f45c1d8953f7a0 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 10 Jan 2026 15:19:45 +0100 Subject: [PATCH 3/5] workflow --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f30232..cf14738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,13 @@ jobs: strategy: matrix: - otp: ["27.3"] + otp: ["27.3", "28"] rebar3: ["3.25"] include: - otp: "27.3" - elp_build: "https://github.com/WhatsApp/erlang-language-platform/releases/download/2025-07-08/elp-linux-x86_64-unknown-linux-gnu-otp-27.3.tar.gz" + elp_build: "https://github.com/WhatsApp/erlang-language-platform/releases/download/2025-10-21/elp-linux-x86_64-unknown-linux-gnu-otp-27.3.tar.gz" + - otp: "28" + elp_build: "https://github.com/WhatsApp/erlang-language-platform/releases/download/2025-10-21/elp-linux-x86_64-unknown-linux-gnu-otp-28.tar.gz" steps: - uses: actions/checkout@v4 From 0c1cff03f005ba108f6f9213a9d93b1a15f58aa9 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 10 Jan 2026 17:38:55 +0100 Subject: [PATCH 4/5] Fix --- .tool-versions | 2 ++ src/elli_openapi.erl | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..48e6916 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.18.4 +erlang 28.2 diff --git a/src/elli_openapi.erl b/src/elli_openapi.erl index 2b79ed5..b53f323 100644 --- a/src/elli_openapi.erl +++ b/src/elli_openapi.erl @@ -388,7 +388,9 @@ generate_openapi_spec(MetaData, Routes) -> endpoints_to_file(MetaData, Routes) -> {ok, EndpointsJson} = generate_openapi_spec(MetaData, Routes), Json = json:encode(EndpointsJson), - file:write_file("priv/openapi.json", Json). + PrivDir = code:priv_dir(elli_openapi), + FilePath = filename:join(PrivDir, "openapi.json"), + file:write_file(FilePath, Json). -spec infer_content_type(spectra:sp_type()) -> content_type(). infer_content_type(#sp_simple_type{type = binary}) -> From 8f658f3abeb98934ad1a6b06c340878cc5fdf88f Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 10 Jan 2026 17:42:50 +0100 Subject: [PATCH 5/5] Don't store openapi spec on disc, keep in persitent_term --- src/elli_openapi.erl | 14 ++++---------- src/elli_openapi_handler.erl | 4 ++-- test/elli_openapi_integration_SUITE.erl | 1 - 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/elli_openapi.erl b/src/elli_openapi.erl index b53f323..e3cd89a 100644 --- a/src/elli_openapi.erl +++ b/src/elli_openapi.erl @@ -46,14 +46,15 @@ setup_routes(Routes) -> Routes ), MetaData = #{title => ~"My API", version => ~"1.0.0"}, - endpoints_to_file(MetaData, Routes), + {ok, OpenApiSpec} = generate_openapi_spec(MetaData, Routes), + OpenApiJson = json:encode(OpenApiSpec), Mref = to_matchspec(RouteEndpoints), MyMap = path_map(RouteEndpoints), - persistent_term:put(?MODULE, {Mref, MyMap}), + persistent_term:put(?MODULE, {Mref, MyMap, OpenApiJson}), ok. route_call(ElliRequest) -> - {Mref, MyMap} = persistent_term:get(?MODULE), + {Mref, MyMap, _OpenApiJson} = persistent_term:get(?MODULE), Method = ensure_binary(elli_request:method(ElliRequest)), Path = list_to_tuple(elli_request:path(ElliRequest)), case ets:match_spec_run([{to_spectra_http_method(Method), Path}], Mref) of @@ -385,13 +386,6 @@ generate_openapi_spec(MetaData, Routes) -> lists:map(fun({_Route, Endpoint, _HandlerType}) -> Endpoint end, RouteEndpoints), spectra_openapi:endpoints_to_openapi(MetaData, Endpoints). -endpoints_to_file(MetaData, Routes) -> - {ok, EndpointsJson} = generate_openapi_spec(MetaData, Routes), - Json = json:encode(EndpointsJson), - PrivDir = code:priv_dir(elli_openapi), - FilePath = filename:join(PrivDir, "openapi.json"), - file:write_file(FilePath, Json). - -spec infer_content_type(spectra:sp_type()) -> content_type(). infer_content_type(#sp_simple_type{type = binary}) -> plain; diff --git a/src/elli_openapi_handler.erl b/src/elli_openapi_handler.erl index bfb48ee..a65dab7 100644 --- a/src/elli_openapi_handler.erl +++ b/src/elli_openapi_handler.erl @@ -16,8 +16,8 @@ handle(#req{path = [~"redoc"]}, _Args) -> F = filename:join(code:priv_dir(elli_openapi), "redoc.html"), {ok, [], {file, F}}; handle(#req{path = [~"api-docs"]}, _Args) -> - F = filename:join(code:priv_dir(elli_openapi), "openapi.json"), - {ok, [], {file, F}}; + {_Mref, _MyMap, OpenApiJson} = persistent_term:get(elli_openapi), + {ok, [{~"Content-Type", ~"application/json"}], OpenApiJson}; handle(ElliRequest, _Args) -> elli_openapi:route_call(ElliRequest). diff --git a/test/elli_openapi_integration_SUITE.erl b/test/elli_openapi_integration_SUITE.erl index 79e7dad..0ff0650 100644 --- a/test/elli_openapi_integration_SUITE.erl +++ b/test/elli_openapi_integration_SUITE.erl @@ -70,7 +70,6 @@ init_per_suite(Config) -> process_flag(trap_exit, true), {ok, _} = application:ensure_all_started(inets), {ok, _} = application:ensure_all_started(elli), - ok = filelib:ensure_dir("priv/openapi.json"), Routes = [