Packages

OpenAPI in Elli using Spectra

Current section

Files

Jump to
elli_openapi src elli_openapi.erl
Raw

src/elli_openapi.erl

-module(elli_openapi).
-export([
setup_routes/1,
setup_routes/2,
route_call/1,
to_handler_type/1,
to_endpoint/2,
generate_openapi_spec/2
]).
-ignore_xref([to_handler_type/1, to_endpoint/2, generate_openapi_spec/2]).
-include_lib("spectra/include/spectra_internal.hrl").
-include_lib("stdlib/include/ms_transform.hrl").
-compile(nowarn_unused_type).
-type content_type() :: plain | json.
-record(response_spec, {
status_code :: integer(),
headers :: spectra:sp_type(),
body :: spectra:sp_type(),
content_type :: content_type()
}).
-record(handler_type, {
mfa :: mfa(),
path_args :: #sp_map{},
query_args :: #sp_map{},
header_args :: #sp_map{},
request_body :: spectra:sp_type(),
request_content_type :: content_type(),
responses :: #{integer() => #response_spec{}},
doc :: spectra:function_doc()
}).
-type spectra_openapi__endpoint_spec() :: map().
setup_routes(Routes) ->
setup_routes(#{title => ~"My API", version => ~"1.0.0"}, Routes).
setup_routes(MetaData, Routes) ->
RouteEndpoints =
lists:map(
fun(Route) ->
HandlerType = to_handler_type(Route),
{Route, to_endpoint(Route, HandlerType), HandlerType}
end,
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, OpenApiJson}),
ok.
route_call(ElliRequest) ->
{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
[{RoutePath, HttpPathArgsList}] ->
HttpPathArgs = maps:from_list(HttpPathArgsList),
{Fun, _Endpoint, HandlerType} = maps:get({Method, RoutePath}, MyMap),
case check_types(HandlerType, HttpPathArgs, ElliRequest) of
{ok, PathArgs, QueryArgs, Headers, Body} ->
Response = Fun(PathArgs, QueryArgs, Headers, Body),
check_and_convert_response(HandlerType, Response);
{error, ErldanticErrors} ->
{400, [], spectra_error_to_response_body(ErldanticErrors)}
end;
[] ->
{404, [], ~"Not Found"}
end.
check_and_convert_response(HandlerType, {HttpCode, Headers, Body}) ->
#handler_type{
mfa = {Module, _, _},
responses = Responses
} =
HandlerType,
case maps:find(HttpCode, Responses) of
{ok, ResponseSpec} ->
#response_spec{
headers = ReturnHeadersType,
body = ReturnBodyType,
content_type = ResponseContentType
} = ResponseSpec,
EncodeFormat =
case ResponseContentType of
plain -> binary_string;
json -> json
end,
case spectra:encode(EncodeFormat, Module, ReturnBodyType, Body) of
{ok, EncodedBody} ->
case encode_headers(Module, ReturnHeadersType, Headers) of
{ok, EncodedHeaders} ->
{HttpCode, EncodedHeaders, EncodedBody};
{error, ErldanticErrors} ->
{500, [], spectra_error_to_response_body(ErldanticErrors)}
end;
{error, ErldanticErrors} ->
{500, [], spectra_error_to_response_body(ErldanticErrors)}
end;
error ->
ErrorMsg = iolist_to_binary(
io_lib:format("Invalid status code ~p returned by handler", [HttpCode])
),
{500, [], ErrorMsg}
end.
encode_headers(Module, ReturnHeadersType, Headers) ->
spectra_util:fold_until_error(
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} ->
{ok, [{BinaryName, EncodedHeader} | Acc]};
{error, _} = Error ->
Error
end;
error when Kind =:= exact ->
{error, {missing_header, FieldName}};
error ->
{ok, Acc}
end
end,
[],
ReturnHeadersType#sp_map.fields
).
spectra_error_to_response_body(Errors) ->
try
iolist_to_binary(io_lib:format("Errors: ~p", [Errors]))
catch
_:_ ->
<<"Error formatting error message">>
end.
ensure_binary(Bin) when is_binary(Bin) ->
Bin;
ensure_binary(Atom) when is_atom(Atom) ->
atom_to_binary(Atom, utf8).
check_types(HandlerType, PathArgs, ElliRequest) ->
#handler_type{
mfa = {Module, _, _},
path_args = PathArgsType,
query_args = QueryArgsType,
header_args = HeadersType,
request_body = RequestBodyType,
request_content_type = RequestContentType
} = HandlerType,
maybe
{ok, DecodedPathArgs} ?= decode_path_args(Module, PathArgs, PathArgsType),
{ok, DecodedQueryArgs} ?= decode_query_args(Module, QueryArgsType, ElliRequest),
{ok, DecodedHeaders} ?=
decode_headers(Module, HeadersType, elli_request:headers(ElliRequest)),
{ok, DecodedBody} ?= decode_body(Module, RequestBodyType, RequestContentType, ElliRequest),
{ok, DecodedPathArgs, DecodedQueryArgs, DecodedHeaders, DecodedBody}
end.
decode_body(Module, RequestBodyType, ExpectedContentType, ElliRequest) ->
Body = elli_request:body(ElliRequest),
ActualContentType = get_content_type(ElliRequest),
case {ExpectedContentType, ActualContentType} of
{json, {ok, <<"application/json">>}} ->
spectra:decode(json, Module, RequestBodyType, Body);
{json, {error, missing}} ->
spectra:decode(json, Module, RequestBodyType, Body);
{plain, {ok, <<"text/", _/binary>>}} ->
spectra:decode(binary_string, Module, RequestBodyType, Body);
{plain, {error, missing}} ->
spectra:decode(binary_string, Module, RequestBodyType, Body);
{ExpectedType, {ok, ActualType}} ->
ExpectedMime = content_type_to_mime(ExpectedType),
{error, [
{sp_error, [], decode_error, #{
reason => content_type_mismatch,
expected => ExpectedMime,
got => ActualType
}}
]}
end.
get_content_type(ElliRequest) ->
Headers = elli_request:headers(ElliRequest),
case lists:keyfind(<<"Content-Type">>, 1, Headers) of
{_, ContentTypeHeader} ->
[MediaType | _] = binary:split(ContentTypeHeader, <<";">>),
ContentType = string:trim(MediaType, both, " \t"),
{ok, ContentType};
false ->
{error, missing}
end.
decode_path_args(Module, PathArgs, PathArgsType) ->
spectra_util:fold_until_error(
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, Acc#{FieldName => DecodedPathArgs}};
{error, _} = Error ->
Error
end;
#{} ->
{error, {missing_path_arg, FieldName}}
end
end,
#{},
PathArgsType#sp_map.fields
).
decode_query_args(Module, QueryArgsType, ElliRequest) ->
QueryParams = elli_request:get_args(ElliRequest),
spectra_util:fold_until_error(
fun(
#literal_map_field{
kind = Kind, name = FieldName, binary_name = BinaryName, val_type = Type
},
Acc
) ->
case lists:keyfind(BinaryName, 1, QueryParams) of
{BinaryName, ParamValue} ->
case spectra:decode(binary_string, Module, Type, ParamValue) of
{ok, DecodedParam} ->
{ok, Acc#{FieldName => DecodedParam}};
{error, _} = Error ->
Error
end;
false when Kind =:= exact ->
{error, {missing_query_param, FieldName}};
false ->
{ok, Acc}
end
end,
#{},
QueryArgsType#sp_map.fields
).
decode_headers(Module, HeadersType, Headers) ->
spectra_util:fold_until_error(
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 ->
{ok, Acc}
end
end,
#{},
HeadersType#sp_map.fields
).
to_matchspec(RouteEndpoints) ->
Ms = elli_openapi_matchspec:routes_to_matchspecs(RouteEndpoints),
ets:match_spec_compile(Ms).
path_map(RouteEndpoints) ->
lists:foldl(
fun({{Method, Path, Fun}, Endpoint, HandlerType}, Acc) ->
Acc#{{Method, Path} => {Fun, Endpoint, HandlerType}}
end,
maps:new(),
RouteEndpoints
).
-spec to_handler_type({binary(), binary(), fun()}) -> #handler_type{}.
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:find_function(TypeInfo, Function, Arity),
join_function_specs(MFA, FunctionSpecs).
-spec to_endpoint({binary(), binary(), fun()}, #handler_type{}) ->
spectra_openapi__endpoint_spec().
to_endpoint(
{HttpMethod, Path, _CallFun},
#handler_type{
mfa = {Module, _Function, _Arity},
path_args = PathArgs,
query_args = QueryArgs,
header_args = HeaderArgs,
request_body = RequestBody,
request_content_type = RequestContentType,
responses = Responses,
doc = FunctionDoc
}
) ->
EndpointDoc = maps:with([summary, description, deprecated], FunctionDoc),
Endpoint0 = spectra_openapi:endpoint(to_spectra_http_method(HttpMethod), Path, EndpointDoc),
PathFun =
fun(Key, Val, EndpointAcc) ->
PathArg =
#{
name => Key,
in => path,
required => true,
schema => Val
},
spectra_openapi:with_parameter(EndpointAcc, Module, PathArg)
end,
EndpointWithPath = maps:fold(PathFun, Endpoint0, to_map(PathArgs)),
QueryFun =
fun(
#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = Type}, EndpointAcc
) ->
QueryArg =
#{
name => BinaryName,
in => query,
required => Kind =:= exact,
schema => Type
},
spectra_openapi:with_parameter(EndpointAcc, Module, QueryArg)
end,
EndpointWithQuery = lists:foldl(QueryFun, EndpointWithPath, QueryArgs#sp_map.fields),
HeaderFun =
fun(
#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = Type}, EndpointAcc
) ->
HeaderArg =
#{
name => BinaryName,
in => header,
required => Kind =:= exact,
schema => Type
},
spectra_openapi:with_parameter(EndpointAcc, Module, HeaderArg)
end,
EndpointWithHeaders = lists:foldl(HeaderFun, EndpointWithQuery, HeaderArgs#sp_map.fields),
%% Only add request body for HTTP methods that support it
Endpoint1 =
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 =
fun(_StatusCode, ResponseSpec, EndpointAcc) ->
#response_spec{
status_code = ReturnCode,
headers = ReturnHeaders,
body = ReturnBody,
content_type = ResponseContentType
} = ResponseSpec,
Description = status_code_to_description(ReturnCode),
Response0 = spectra_openapi:response(ReturnCode, Description),
ResponseContentTypeMime = content_type_to_mime(ResponseContentType),
Response1 =
spectra_openapi:response_with_body(
Response0, Module, ReturnBody, ResponseContentTypeMime
),
ResponseWithHeaders = add_response_headers(Response1, Module, ReturnHeaders),
spectra_openapi:add_response(EndpointAcc, ResponseWithHeaders)
end,
Endpoint2 = maps:fold(ResponseFun, Endpoint1, Responses),
Endpoint2.
add_response_headers(Response, Module, #sp_map{fields = Fields}) ->
lists:foldl(
fun(
#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = Type}, ResponseAcc
) ->
HeaderSpec = #{
required => Kind =:= exact,
schema => Type
},
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;
to_spectra_http_method(~"DELETE") -> delete;
to_spectra_http_method(~"PATCH") -> patch;
to_spectra_http_method(~"HEAD") -> head;
to_spectra_http_method(~"OPTIONS") -> options;
to_spectra_http_method(~"TRACE") -> trace.
to_map(#sp_map{fields = Fields}) ->
lists:foldl(
fun(#literal_map_field{binary_name = BinaryName, val_type = Type}, Acc) ->
Acc#{BinaryName => Type}
end,
#{},
Fields
).
generate_openapi_spec(MetaData, Routes) ->
RouteEndpoints =
lists:map(
fun(Route) ->
HandlerType = to_handler_type(Route),
{Route, to_endpoint(Route, HandlerType), HandlerType}
end,
Routes
),
Endpoints =
lists:map(fun({_Route, Endpoint, _HandlerType}) -> Endpoint end, RouteEndpoints),
spectra_openapi:endpoints_to_openapi(MetaData, Endpoints, [pre_encoded]).
-spec infer_content_type(spectra:sp_type()) -> content_type().
infer_content_type(#sp_simple_type{type = binary}) ->
plain;
infer_content_type(#sp_simple_type{type = nonempty_binary}) ->
plain;
infer_content_type(#sp_simple_type{type = atom}) ->
plain;
infer_content_type(#sp_literal{value = V}) when is_atom(V) -> plain;
infer_content_type(#sp_union{types = Types}) ->
case lists:all(fun(T) -> infer_content_type(T) =:= plain end, Types) of
true -> plain;
false -> json
end;
infer_content_type(_) ->
json.
-spec content_type_to_mime(content_type()) -> binary().
content_type_to_mime(plain) -> ~"text/plain";
content_type_to_mime(json) -> ~"application/json".
-spec status_code_to_description(integer()) -> binary().
status_code_to_description(200) -> ~"Success";
status_code_to_description(201) -> ~"Created";
status_code_to_description(202) -> ~"Accepted";
status_code_to_description(204) -> ~"No Content";
status_code_to_description(400) -> ~"Bad Request";
status_code_to_description(401) -> ~"Unauthorized";
status_code_to_description(403) -> ~"Forbidden";
status_code_to_description(404) -> ~"Not Found";
status_code_to_description(409) -> ~"Conflict";
status_code_to_description(422) -> ~"Unprocessable Entity";
status_code_to_description(500) -> ~"Internal Server Error";
status_code_to_description(502) -> ~"Bad Gateway";
status_code_to_description(503) -> ~"Service Unavailable";
status_code_to_description(Code) when Code >= 200, Code < 300 -> ~"Success";
status_code_to_description(Code) when Code >= 300, Code < 400 -> ~"Redirection";
status_code_to_description(Code) when Code >= 400, Code < 500 -> ~"Client Error";
status_code_to_description(Code) when Code >= 500, Code < 600 -> ~"Server Error";
status_code_to_description(_) -> ~"Unknown Status".
join_function_specs(
MFA,
[
#sp_function_spec{
args = [PathArgs, QueryArgs, HeaderArgs, Body],
return = ReturnType,
meta = Meta
}
]
) ->
Responses = extract_responses(ReturnType),
Doc = maps:get(doc, Meta, #{}),
#handler_type{
mfa = MFA,
path_args = PathArgs,
query_args = QueryArgs,
header_args = HeaderArgs,
request_body = Body,
request_content_type = infer_content_type(Body),
responses = Responses,
doc = Doc
};
join_function_specs({Module, Function, Arity}, [#sp_function_spec{args = Args}]) ->
erlang:error(
{handler_wrong_arity, #{
mfa => {Module, Function, Arity},
expected_args => 4,
got_args => length(Args),
hint => ~"Handler spec must be: (PathArgs, QueryArgs, Headers, Body) -> Response"
}}
).
%% Extract response specifications from return type
%% Handles both single tuple: {200, Headers, Body}
%% And union of tuples: {200, H1, B1} | {400, H2, B2} | {404, H3, B3}
-spec extract_responses(spectra:sp_type()) -> #{integer() => #response_spec{}}.
extract_responses(#sp_union{types = Types}) ->
%% Union of multiple status codes
lists:foldl(
fun(TupleType, Acc) ->
case extract_single_response(TupleType) of
{ok, StatusCode, ResponseSpec} ->
maps:put(StatusCode, ResponseSpec, Acc);
error ->
Acc
end
end,
#{},
Types
);
extract_responses(TupleType) ->
%% Single status code
case extract_single_response(TupleType) of
{ok, StatusCode, ResponseSpec} ->
#{StatusCode => ResponseSpec};
error ->
#{}
end.
-spec extract_single_response(spectra:sp_type()) ->
{ok, integer(), #response_spec{}} | error.
extract_single_response(
#sp_tuple{
fields =
[
#sp_literal{value = ReturnCode},
ReturnHeaders,
ReturnBody
]
}
) when is_integer(ReturnCode) ->
{ok, ReturnCode, #response_spec{
status_code = ReturnCode,
headers = ReturnHeaders,
body = ReturnBody,
content_type = infer_content_type(ReturnBody)
}};
extract_single_response(_) ->
error.