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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ 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 my_handler:create_user/3},
{<<"GET">>, <<"/api/users/{userId}">>, fun my_handler:get_user/3}
{<<"POST">>, <<"/api/users">>, fun user_handler:create_user/3},
{<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/3}
],

%% Configure and start Elli, preferably in you supervisor spec.
Expand Down
10 changes: 6 additions & 4 deletions demo.escript
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

main(_) ->
Routes = [
{<<"POST">>, <<"/api/users">>, fun elli_openapi_demo:create_user/3},
{<<"GET">>, <<"/api/users/{userId}">>, fun elli_openapi_demo:get_user/3},
{<<"POST">>, <<"/api/echo">>, fun elli_openapi_demo:echo_text/3},
{<<"PUT">>, <<"/api/items/{itemId}">>, fun elli_openapi_demo:update_item/3}
%% {<<"POST">>, <<"/api/users">>, fun elli_openapi_demo:create_user/3},
%% {<<"GET">>, <<"/api/users/{userId}">>, fun elli_openapi_demo:get_user/3},
%% {<<"POST">>, <<"/api/echo">>, fun elli_openapi_demo:echo_text/3},
%% {<<"PUT">>, <<"/api/items/{itemId}">>, fun elli_openapi_demo:update_item/3}
{<<"GET">>, <<"/api/users/{userId}">>, fun user_handler:get_user/3},
{<<"POST">>, <<"/api/users/">>, fun user_handler:create_user/3}

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The POST route has a trailing slash in the path which is inconsistent with the rest of the codebase. All other POST routes to /api/users use the path without a trailing slash. For example, in the test file test/elli_openapi_integration_SUITE.erl lines 78, 392, 449, and 534, the path is consistently <<"/api/users">> without a trailing slash.

Copilot uses AI. Check for mistakes.
],
Port = 3000,
ElliOpts = [
Expand Down
2 changes: 1 addition & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

{deps, [
{elli, "~> 3.3.0"},
{spectra, "~> 0.2.0"}
{spectra, "~> 0.4.0"}
]}.

{hank, [
Expand Down
6 changes: 3 additions & 3 deletions rebar.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
{ref,"c4d1098174cec06bd124855f3a28dfd6eda0a581"},
"eqwalizer_support"},
0},
{<<"spectra">>,{pkg,<<"spectra">>,<<"0.1.1">>},0}]}.
{<<"spectra">>,{pkg,<<"spectra">>,<<"0.4.0">>},0}]}.
[
{pkg_hash,[
{<<"elli">>, <<"089218762A7FF3D20AE81C8E911BD0F73EE4EE0ED85454226D1FC6B4FFF3B4F6">>},
{<<"spectra">>, <<"DB3DE5D46B25BF608318E6C8FB8EDEDDE25A21F2AC5DD91B41BBC3800E3C8226">>}]},
{<<"spectra">>, <<"1A5390E55B34F83693998FD7ECBC448BCDC5FC5EBCCA790C7B0DD2D6A0FED255">>}]},
{pkg_hash_ext,[
{<<"elli">>, <<"698B13B33D05661DB9FE7EFCBA41B84825A379CCE86E486CF6AFF9285BE0CCF8">>},
{<<"spectra">>, <<"76C684A56D016EA37443D4BCAEEDDBFB20366536F026BDB0B43D3C6C02225D13">>}]}
{<<"spectra">>, <<"E51E88BE6EB3B85A1E3D0A9651A9F76ACA5A83A1EF940467541B77D078C5FB84">>}]}
].
91 changes: 56 additions & 35 deletions src/elli_openapi.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

-include_lib("spectra/include/spectra_internal.hrl").
-include_lib("stdlib/include/ms_transform.hrl").
-include_lib("elli/include/elli.hrl").

-compile(nowarn_unused_type).

Expand Down Expand Up @@ -110,19 +109,21 @@ check_and_convert_response(HandlerType, {HttpCode, Headers, Body}) ->

encode_headers(Module, ReturnHeadersType, Headers) ->
spectra_util:fold_until_error(
fun({FieldType, FieldName, Type}, Acc) when
FieldType =:= map_field_exact orelse FieldType =:= map_field_assoc
->
fun(
#literal_map_field{
kind = Kind, name = FieldName, binary_name = BinaryName, val_type = Type
},
Acc
) ->
case maps:find(FieldName, Headers) of
{ok, HeaderValue} ->
case spectra:encode(binary_string, Module, Type, HeaderValue) of
{ok, EncodedHeader} ->
HeaderName = atom_to_binary(FieldName),
{ok, [{HeaderName, EncodedHeader} | Acc]};
{ok, [{BinaryName, EncodedHeader} | Acc]};
{error, _} = Error ->
Error
end;
error when FieldType =:= map_field_exact ->
error when Kind =:= exact ->
{error, {missing_header, FieldName}};
error ->
{ok, Acc}
Expand Down Expand Up @@ -209,16 +210,16 @@ get_content_type(ElliRequest) ->

decode_path_args(Module, PathArgs, PathArgsType) ->
spectra_util:fold_until_error(
fun({map_field_exact, FieldName, Type}, Acc) ->
case maps:find(FieldName, PathArgs) of
{ok, PathArg} ->
fun(#literal_map_field{name = FieldName, val_type = Type}, Acc) ->
case PathArgs of
#{FieldName := PathArg} ->
case spectra:decode(binary_string, Module, Type, PathArg) of
{ok, DecodedPathArgs} ->
{ok, maps:put(FieldName, DecodedPathArgs, Acc)};
{ok, Acc#{FieldName => DecodedPathArgs}};
{error, _} = Error ->
Error
end;
error ->
#{} ->
{error, {missing_path_arg, FieldName}}
end
end,
Expand All @@ -228,18 +229,24 @@ decode_path_args(Module, PathArgs, PathArgsType) ->

decode_headers(Module, HeadersType, Headers) ->
spectra_util:fold_until_error(
fun({map_field_exact, FieldName, Type}, Acc) ->
HeaderName = atom_to_binary(FieldName),
case lists:keyfind(HeaderName, 1, Headers) of
{HeaderName, HeaderValue} ->
fun(
#literal_map_field{
kind = Kind, name = FieldName, binary_name = BinaryName, val_type = Type
},
Acc
) ->
case lists:keyfind(BinaryName, 1, Headers) of
{BinaryName, HeaderValue} ->
case spectra:decode(binary_string, Module, Type, HeaderValue) of
{ok, DecodedHeader} ->
{ok, maps:put(FieldName, DecodedHeader, Acc)};
{error, _} = Error ->
Error
end;
false when Kind =:= exact ->
{error, {missing_header, FieldName}};
false ->
{error, {missing_header, FieldName}}
{ok, Acc}
end
end,
#{},
Expand All @@ -253,7 +260,7 @@ to_matchspec(RouteEndpoints) ->
path_map(RouteEndpoints) ->
lists:foldl(
fun({{Method, Path, Fun}, Endpoint, HandlerType}, Acc) ->
maps:put({Method, Path}, {Fun, Endpoint, HandlerType}, Acc)
Acc#{{Method, Path} => {Fun, Endpoint, HandlerType}}
end,
maps:new(),
RouteEndpoints
Expand All @@ -263,7 +270,7 @@ path_map(RouteEndpoints) ->
to_handler_type({_HttpMethod, _Path, CallFun}) ->
{Module, Function, Arity} = MFA = erlang:fun_info_mfa(CallFun),
TypeInfo = spectra_abstract_code:types_in_module(Module),
{ok, FunctionSpecs} = spectra_type_info:get_function(TypeInfo, Function, Arity),
{ok, FunctionSpecs} = spectra_type_info:find_function(TypeInfo, Function, Arity),
join_function_specs(MFA, FunctionSpecs).

-spec to_endpoint({binary(), binary(), fun()}, #handler_type{}) ->
Expand Down Expand Up @@ -295,26 +302,32 @@ to_endpoint(
end,
EndpointWithPath = maps:fold(PathFun, Endpoint0, to_map(PathArgs)),
HeaderFun =
fun({FieldType, Name, Type}, EndpointAcc) when
FieldType =:= map_field_exact orelse FieldType =:= map_field_assoc
->
fun(
#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = Type}, EndpointAcc
) ->
HeaderArg =
#{
name => atom_to_binary(Name),
name => BinaryName,
in => header,
required => FieldType =:= map_field_exact,
required => Kind =:= exact,
module => Module,
schema => Type
},
spectra_openapi:with_parameter(EndpointAcc, Module, HeaderArg)
end,
EndpointWithHeaders = lists:foldl(HeaderFun, EndpointWithPath, HeaderArgs#sp_map.fields),

RequestContentTypeMime = content_type_to_mime(RequestContentType),
%% Only add request body for HTTP methods that support it
Endpoint1 =
spectra_openapi:with_request_body(
EndpointWithHeaders, Module, RequestBody, RequestContentTypeMime
),
case http_method_supports_body(HttpMethod) of
true ->
RequestContentTypeMime = content_type_to_mime(RequestContentType),
spectra_openapi:with_request_body(
EndpointWithHeaders, Module, RequestBody, RequestContentTypeMime
);
false ->
EndpointWithHeaders
end,

%% Add all responses from the responses map
ResponseFun =
Expand All @@ -341,22 +354,28 @@ to_endpoint(

add_response_headers(Response, Module, #sp_map{fields = Fields}) ->
lists:foldl(
fun({FieldType, Name, Type}, ResponseAcc) when
FieldType =:= map_field_exact orelse FieldType =:= map_field_assoc
->
HeaderName = atom_to_binary(Name),
fun(
#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = Type}, ResponseAcc
) ->
HeaderSpec = #{
required => FieldType =:= map_field_exact,
required => Kind =:= exact,
schema => Type
},
spectra_openapi:response_with_header(ResponseAcc, HeaderName, Module, HeaderSpec)
spectra_openapi:response_with_header(ResponseAcc, BinaryName, Module, HeaderSpec)
end,
Response,
Fields
);
add_response_headers(Response, _Module, _Other) ->
Response.

%% HTTP methods that support request bodies
http_method_supports_body(~"POST") -> true;
http_method_supports_body(~"PUT") -> true;
http_method_supports_body(~"PATCH") -> true;
http_method_supports_body(~"DELETE") -> true;
http_method_supports_body(_) -> false.

to_spectra_http_method(~"GET") -> get;
to_spectra_http_method(~"POST") -> post;
to_spectra_http_method(~"PUT") -> put;
Expand All @@ -368,7 +387,9 @@ to_spectra_http_method(~"TRACE") -> trace.

to_map(#sp_map{fields = Fields}) ->
lists:foldl(
fun({map_field_exact, Name, Type}, Acc) -> Acc#{atom_to_binary(Name) => Type} end,
fun(#literal_map_field{binary_name = BinaryName, val_type = Type}, Acc) ->
Acc#{BinaryName => Type}
end,
#{},
Fields
).
Expand Down
8 changes: 5 additions & 3 deletions src/elli_openapi_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ handle(#req{path = [~"api-docs"]}, _Args) ->
handle(ElliRequest, _Args) ->
elli_openapi:route_call(ElliRequest).

-spec handle_event(Event, Args, Config) -> ok when
-spec handle_event(Event, Args :: term(), Config) -> ok when
Event :: elli_handler:event(),
Args :: elli_handler:callback_args(),
Config :: [tuple()].
handle_event(elli_startup, [], Routes) ->
Modules =
Expand All @@ -37,5 +36,8 @@ handle_event(elli_startup, [], Routes) ->
lists:foreach(fun code:ensure_loaded/1, lists:usort(Modules)),
elli_openapi:setup_routes(Routes),
ok;
handle_event(_Event, _Args, _Config) ->
handle_event(request_complete, [Req, ReturnCode, _, _, _], _Config) ->
io:format("Req complete: ~s ~p ~n", [elli_request:raw_path(Req), ReturnCode]),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logging statement should not be in production code. The handle_event callback should not have side effects like console logging for every request, as this can produce excessive output and impact performance in production. This appears to be debug code that should either be removed or made conditional based on a debug flag.

Suggested change
io:format("Req complete: ~s ~p ~n", [elli_request:raw_path(Req), ReturnCode]),
case application:get_env(elli_openapi, debug_logging, false) of
true ->
io:format("Req complete: ~s ~p ~n", [elli_request:raw_path(Req), ReturnCode]);
_ ->
ok
end,

Copilot uses AI. Check for mistakes.
ok;
handle_event(_Event, _Data, _Config) ->
ok.
33 changes: 33 additions & 0 deletions src/user_handler.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-module(user_handler).

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

-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}]}]).

-spec get_user(#{userId := binary()}, #{}, binary()) ->
{200, #{}, #user{}}
| {404, #{}, #{message := binary()}}.
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{}) ->
{201, #{'Location' => binary()}, #user{}}.
create_user(#{}, #{}, User) ->
io:format("Creating user: ~s with role ~p~n", [User#user.name, User#user.role]),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logging statement should not be in a handler function. The create_user function should not have side effects like console logging, as this mixes concerns and makes the function less testable. Production handler code should focus on business logic, with logging handled at the framework level if needed.

Suggested change
io:format("Creating user: ~s with role ~p~n", [User#user.name, User#user.role]),

Copilot uses AI. Check for mistakes.
Location = <<"/api/users/", (User#user.id)/binary>>,
{201, #{'Location' => Location}, User}.

find_user(~"123") ->
{ok, #user{id = ~"123", name = ~"Alice", role = user}};
find_user(_) ->
not_found.
Loading