diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb910e2..86a0ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ -name: erf ci +name: CI on: push: branches: [main] pull_request: - branches: [main] env: OTP-VERSION: 25.2.3 @@ -17,10 +16,6 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: webfactory/ssh-agent@v0.7.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP-VERSION }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0ce3bd3..8670447 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,10 +15,6 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: webfactory/ssh-agent@v0.7.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP-VERSION }} diff --git a/README.md b/README.md index a0b58d1..b62abdd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Design-first is an approach to API development that prioritises the design of th ## Quickstart -1. Design your API using OpenAPI 3.0. For example: [users.json](examples/users/users.json). +1. Design your API using OpenAPI 3.0. For example: [users.openapi.json](examples/users/priv/users.openapi.json). 2. Add `erf` as a dependency in your `rebar3` project. ```erl @@ -25,27 +25,27 @@ Design-first is an approach to API development that prioritises the design of th ]}. ``` -3. Implement a callback module for your API. A hypothetical example for [users.json](examples/users/users.json) would be [users_callback.erl](examples/users/users_callback.erl). +3. Implement a callback module for your API. A hypothetical example for [users.openapi.json](examples/users/priv/users.openapi.json) would be [users_callback.erl](examples/users/src/users_callback.erl). ```erl %% An erf callback for the users REST API. -module(users_callback). %%% EXTERNAL EXPORTS -export([ - create_user/4, - get_user/4, - delete_user/4 + create_user/1, + get_user/1, + delete_user/1 ]). %%%------------------------------------------------------- %%% EXTERNAL EXPORTS %%%------------------------------------------------------- -create_user(_PathParameters, _QueryParameters, _Headers, Body) -> +create_user(#{body := Body} = _Request) -> Id = base64:encode(crypto:strong_rand_bytes(16)), ets:insert(users, {Id, Body#{<<"id">> => Id}}), {201, [], Body#{<<"id">> => Id}}. -get_user(PathParameters, _QueryParameters, _Headers, _Body) -> +get_user(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> @@ -57,7 +57,7 @@ get_user(PathParameters, _QueryParameters, _Headers, _Body) -> {200, [], User} end. -delete_user(PathParameters, _QueryParameters, _Headers, _Body) -> +delete_user(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> @@ -97,8 +97,10 @@ init([]) -> % Users storage ets:new(users, [public, named_table]), UsersAPIConf = #{ - spec_path => <<"doc/openapi/users.openapi.json">>, + spec_path => <<"priv/users.openapi.json">>, callback => users_callback, + preprocess_middlewares => [users_preprocess], + postprocess_middlewares => [users_postprocess], port => 8080 }, UsersChildSpec = { @@ -111,10 +113,11 @@ init([]) -> }, {ok, {{one_for_one, 5, 10}, [UsersChildSpec]}}. ``` +Notice the configured preprocess and postprocess middlewares. They implement a basic authorization mechanism, short-circuiting the request and returning a 403 HTTP error code if the `X-API-KEY: api-key` header is missing, and they print in console the time in microseconds that authorized requests take to complete. 5. Start requesting your service. ```sh -$ curl -vvv 'localhost:8080/users' -H 'Content-Type: application/json' -d '{"username": "foo", "password": "foobar"}' +$ curl -vvv 'localhost:8080/users' -H 'Content-Type: application/json' -H 'X-API-KEY: api-key' -d '{"username": "foo", "password": "foobar"}' * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > POST /users HTTP/1.1 @@ -168,7 +171,7 @@ A detailed description of each parameter can be found in the following list: - `callback`: Name of the callback module. - `port`: Port the server will listen to. Defaults to `8080`. - `name`: Name under which the server is registered. Defaults to `erf`. -- `spec_parser`: Name of the specification parser module. Defaults to `erf_oas_3_0`. +- `spec_parser`: Name of the specification parser module. Defaults to `erf_parser_oas_3_0`. - `preprocess_middlewares`: List of names of middlewares to be invoked before the request is forwarded to the callback. Defaults to `[]`. - `postprocess_middlewares`: List of names of middlewares to be invoked after the response is returned by the callback. Defaults to `[]`. - `ssl`: Boolean flag that enables/disables SSL. Defaults to `false`. @@ -182,20 +185,20 @@ A detailed description of each parameter can be found in the following list: - `header_timeout`: Timeout in ms for receiving more packets when waiting for the headers. Defaults to `10000`. - `body_timeout`: Timeout in ms for receiving more packets when waiting for the body. Defaults to `30000`. - `max_body_size`: Maximum size in bytes for the body of allowed received messages. Defaults to `1024000`. -- `log_level`: Severity asociated to logged messages. Defaults to `error`. +- `log_level`: Severity associated to logged messages. Defaults to `error`. -## Callback modules +## Callback modules & middlewares -`erf` dynamically generates a router that type check the received requests against the API specification. If the request passes the validation, it is deconstructed and passed to the callback module. But, how does the callback module must look like? +`erf` dynamically generates a router that type check the received requests against the API specification. If the request passes the validation, it is deconstructed and passed to the middleware and callback modules. But, how do those middleware and callback modules must look like? -The router expects your callback module to export one function per operation defined in your API specification. It also expects each operation to include an `operationId` that, after being transformed to _snake_case_, will identify the function that is going to be called. Regarding the expected arguments in those functions, `erf` will provide 4 arguments that include the variable data of the request (i.e., that data that cannot be inferred just from the `operationId`): -- `PathParameters :: [{Name :: binary(), Value :: binary()}]` -- `QueryParameters :: [{Name :: binary(), Value :: binary()}]` -- `Headers :: [{Name :: binary(), Value :: binary()}]` -- `Body :: njson:t()` -> [`njson`](https://github.com/nomasystems/njson) is the library used in `erf` to deserialize JSON values to Erlang terms. +- **Preprocess middlewares** receive a request, do something with it (such as adding an entry to an access log) and return it for the next middleware or callback module to process it. This allows each preprocess middleware to modify the content of the request, updating any of its fields such as the `context` field, specifically dedicated to store contextual information middlewares might want to provide. Preprocess middlewares can short-circuit the processing flow, returning `{stop, Response}` or `{stop, Response, Request}` instead of just `Request`. The first of those alternatives prevents the following preprocess middlewares to execute, as well as the callback module, skipping directly to the postprocess middlewares. The second alternative response format does the same but allows to modify the request information. -An example of an API specification and a supported callback can be seen in [Quickstart](#quickstart). +- **Callback module**. +The router expects your callback module to export one function per operation defined in your API specification. It also expects each operation to include an `operationId` that, after being transformed to _snake_case_, will identify the function that is going to be called. Such function receives an `erf:request()` and must return an `erf:response()`. + +- **Postprocess middlewares** can also update the request, like the preprocess middlewares, by returning a `{erf:response(), erf:request()}` tuple or just return a `erf:response()` and leave the received request intact. This middlewares cannot short-circuit the processing flow. + +An example of an API specification and a supported callback can be seen in [Quickstart](#quickstart). Files `users_preprocess.erl` and `users_postprocess.erl` under `examples/users` exemplify how to use `erf` middlewares. Try out the example by running `rebar3 as examples shell` from the root of this project. ## Hot-configuration reloading diff --git a/examples/users/users.json b/examples/users/priv/users.openapi.json similarity index 100% rename from examples/users/users.json rename to examples/users/priv/users.openapi.json diff --git a/examples/users/src/users.app.src b/examples/users/src/users.app.src new file mode 100644 index 0000000..5878146 --- /dev/null +++ b/examples/users/src/users.app.src @@ -0,0 +1,8 @@ +{application, users, [ + {description, "A tiny app exemplifying erf usage"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib, erf]}, + {mod, {users, []}}, + {env, []} +]}. diff --git a/examples/users/src/users.erl b/examples/users/src/users.erl new file mode 100644 index 0000000..e386a85 --- /dev/null +++ b/examples/users/src/users.erl @@ -0,0 +1,11 @@ +-module(users). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + users_sup:start_link(). + +stop(_State) -> + ok. diff --git a/examples/users/users_callback.erl b/examples/users/src/users_callback.erl similarity index 80% rename from examples/users/users_callback.erl rename to examples/users/src/users_callback.erl index b082af9..5197963 100644 --- a/examples/users/users_callback.erl +++ b/examples/users/src/users_callback.erl @@ -3,20 +3,20 @@ %%% EXTERNAL EXPORTS -export([ - create_user/4, - get_user/4, - delete_user/4 + create_user/1, + get_user/1, + delete_user/1 ]). %%%------------------------------------------------------- %%% EXTERNAL EXPORTS %%%------------------------------------------------------- -create_user(_PathParameters, _QueryParameters, _Headers, Body) -> +create_user(#{body := Body} = _Request) -> Id = base64:encode(crypto:strong_rand_bytes(16)), ets:insert(users, {Id, Body#{<<"id">> => Id}}), {201, [], Body#{<<"id">> => Id}}. -get_user(PathParameters, _QueryParameters, _Headers, _Body) -> +get_user(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> @@ -28,7 +28,7 @@ get_user(PathParameters, _QueryParameters, _Headers, _Body) -> {200, [], User} end. -delete_user(PathParameters, _QueryParameters, _Headers, _Body) -> +delete_user(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> diff --git a/examples/users/src/users_postprocess.erl b/examples/users/src/users_postprocess.erl new file mode 100644 index 0000000..e4ea68d --- /dev/null +++ b/examples/users/src/users_postprocess.erl @@ -0,0 +1,22 @@ +%% An erf postprocess middleware for the users REST API. +-module(users_postprocess). + +-behaviour(erf_postprocess_middleware). + +%%% EXTERNAL EXPORTS +-export([ + postprocess/2 +]). + +%%%------------------------------------------------------- +%%% EXTERNAL EXPORTS +%%%------------------------------------------------------- +% Here we exemplify how information previously inserted on the request context +% can be used to condition the request processing flow. +postprocess(#{method := post, context := #{post_init := PostInitT}} = _Request, Response) -> + PostEndT = erlang:timestamp(), + Diff = timer:now_diff(PostEndT, PostInitT), + io:format("Post time diff : ~p~n", [Diff]), + Response; +postprocess(_Request, Response) -> + Response. diff --git a/examples/users/src/users_preprocess.erl b/examples/users/src/users_preprocess.erl new file mode 100644 index 0000000..532c45f --- /dev/null +++ b/examples/users/src/users_preprocess.erl @@ -0,0 +1,37 @@ +%% An erf preprocess middleware for the users REST API. +-module(users_preprocess). + +-behaviour(erf_preprocess_middleware). + +%%% EXTERNAL EXPORTS +-export([ + preprocess/1 +]). + +%%%------------------------------------------------------- +%%% EXTERNAL EXPORTS +%%%------------------------------------------------------- +preprocess(#{headers := Headers} = Request) -> + Authorization = proplists:get_value(<<"x-api-key">>, Headers, undefined), + case is_authorized(Authorization) of + false -> + % For delete operations, if delete is disabled, + % we skip to the post-process middlewares. + {stop, {403, [], <<"Missing valid basic authorization header">>}}; + true -> + PostInitT = erlang:timestamp(), + Context = maps:get(context, Request, #{}), + % We store the current timestamp on the the request context + % for latter use. + Request#{context => Context#{post_init => PostInitT}} + end. + +%%%------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%------------------------------------------------------- +is_authorized(undefined) -> + false; +is_authorized(<<"api-key">>) -> + true; +is_authorized(_) -> + false. diff --git a/examples/users/src/users_sup.erl b/examples/users/src/users_sup.erl new file mode 100644 index 0000000..52aabf6 --- /dev/null +++ b/examples/users/src/users_sup.erl @@ -0,0 +1,39 @@ +-module(users_sup). + +%%% BEHAVIOURS +-behaviour(supervisor). + +%%% START/STOP EXPORTS +-export([start_link/0]). + +%%% INTERNAL EXPORTS +-export([init/1]). + +%%%------------------------------------------------------- +%%% START/STOP EXPORTS +%%%------------------------------------------------------- +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +%%%------------------------------------------------------- +%%% INTERNAL EXPORTS +%%%------------------------------------------------------- +init([]) -> + % Users storage + ets:new(users, [public, named_table]), + UsersAPIConf = #{ + spec_path => filename:join(code:priv_dir(users), <<"users.openapi.json">>), + callback => users_callback, + preprocess_middlewares => [users_preprocess], + postprocess_middlewares => [users_postprocess], + port => 8080 + }, + UsersChildSpec = { + public_api_server, + {erf, start_link, [UsersAPIConf]}, + permanent, + 5000, + worker, + [erf] + }, + {ok, {{one_for_one, 5, 10}, [UsersChildSpec]}}. diff --git a/rebar.config b/rebar.config index eb3dc9b..cf40042 100644 --- a/rebar.config +++ b/rebar.config @@ -4,13 +4,13 @@ ]}. {deps, [ - {elli, {git, "git@github.com:elli-lib/elli.git", {branch, "main"}}}, - {ndto, {git, "git@github.com:nomasystems/ndto.git", {branch, "main"}}}, - {njson, {git, "git@github.com:nomasystems/njson.git", {branch, "main"}}} + {elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}}, + {ndto, {git, "https://github.com/nomasystems/ndto.git", {tag, "0.3.1"}}}, + {njson, {git, "https://github.com/nomasystems/njson.git", {tag, "2.0.0"}}} ]}. {plugins, [ - {rebar3_ndto, {git, "git@github.com:nomasystems/rebar3_ndto.git", {tag, "0.1.0"}}} + {rebar3_ndto, {git, "https://github.com/nomasystems/rebar3_ndto.git", {tag, "0.3.1"}}} ]}. {ndto, [ {specs, [ @@ -26,21 +26,24 @@ ]}. {project_plugins, [ - {erlfmt, {git, "git@github.com:WhatsApp/erlfmt.git", {branch, "main"}}}, - {gradualizer, {git, "git@github.com:josefs/Gradualizer.git", {branch, "master"}}}, + erlfmt, + {gradualizer, {git, "https://github.com/josefs/Gradualizer.git", {tag, "0.3.0"}}}, rebar3_ex_doc ]}. {erlfmt, [write]}. {profiles, [ {examples, [ - {extra_src_dirs, ["examples"]} + {project_app_dirs, ["examples/users", "."]}, + {shell, [ + {apps, [users]} + ]} ]}, {test, [ {erl_opts, [nowarn_export_all]}, {deps, [ - {meck, {git, "git@github.com:eproxus/meck.git", {branch, "master"}}}, - {nct_util, {git, "git@github.com:nomasystems/nct_util.git", {branch, "main"}}} + {meck, {git, "https://github.com/eproxus/meck.git", {branch, "master"}}}, + {nct_util, {git, "https://github.com/nomasystems/nct_util.git", {branch, "main"}}} ]} ]} ]}. @@ -88,5 +91,7 @@ {gradualizer_opts, [ %% TODO: address - {exclude, ["src/erf_oas_3_0.erl", "src/erf_router.erl"]} + {exclude, [ + "src/erf_router.erl" + ]} ]}. diff --git a/rebar.lock b/rebar.lock index 5725a88..dbc5a56 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,16 +1,16 @@ [{<<"elli">>, - {git,"git@github.com:elli-lib/elli.git", + {git,"https://github.com/elli-lib/elli.git", {ref,"3ec352293ef493c142767127f4113c85541c32cc"}}, 0}, {<<"ncalendar">>, - {git,"git@github.com:nomasystems/ncalendar.git", - {ref,"0237766de898145bbd55bb26eef8917535f341ca"}}, + {git,"https://github.com/nomasystems/ncalendar.git", + {ref,"aa5615f6723585e45e82fa5524cb976cdfe3d7f7"}}, 1}, {<<"ndto">>, - {git,"git@github.com:nomasystems/ndto.git", - {ref,"491a2441e43afa2fb037c6e7e826c45a383e3bd9"}}, + {git,"https://github.com/nomasystems/ndto.git", + {ref,"295281b72ea4ac85e7c4d5ca42337c68b1aac137"}}, 0}, {<<"njson">>, - {git,"git@github.com:nomasystems/njson.git", - {ref,"76ab40033ee977f876e7b3addca5de981ff4a9ef"}}, + {git,"https://github.com/nomasystems/njson.git", + {ref,"94c586b92a7e24c403089cdbe2994b7e7c87b9cc"}}, 0}]. diff --git a/src/erf.app.src b/src/erf.app.src index e6a659c..af7ed84 100644 --- a/src/erf.app.src +++ b/src/erf.app.src @@ -1,6 +1,6 @@ {application, erf, [ {description, "A design-first Erlang REST Framework"}, - {vsn, "0.1.0"}, + {vsn, "0.1.2"}, {registered, []}, {applications, [kernel, stdlib, compiler, syntax_tools, elli, ndto, njson]}, {env, []} diff --git a/src/erf.erl b/src/erf.erl index 174570f..88a8a37 100644 --- a/src/erf.erl +++ b/src/erf.erl @@ -41,7 +41,7 @@ %%% TYPES -type api() :: erf_parser:api(). --type body() :: njson:t(). +-type body() :: undefined | njson:t(). -type conf() :: #{ spec_path := binary(), callback := module(), @@ -76,12 +76,15 @@ | connect. -type path_parameter() :: {binary(), binary()}. -type query_parameter() :: {binary(), binary()}. --type request() :: { - Path :: [binary()], - Method :: method(), - QueryParameters :: [query_parameter()], - Headers :: [header()], - Body :: body() +-type request() :: #{ + path := [binary()], + path_parameters => [path_parameter()], + method := method(), + query_parameters := [query_parameter()], + headers := [header()], + body := body(), + peer := undefined | binary(), + context => any() }. -type response() :: { StatusCode :: pos_integer(), @@ -204,7 +207,7 @@ reload_conf(Name, NewConf) -> init([Name, RawConf]) -> RawErfConf = #{ spec_path => maps:get(spec_path, RawConf), - spec_parser => maps:get(spec_parser, RawConf, erf_oas_3_0), + spec_parser => maps:get(spec_parser, RawConf, erf_parser_oas_3_0), callback => maps:get(callback, RawConf), static_routes => maps:get(static_routes, RawConf, []), swagger_ui => maps:get(swagger_ui, RawConf, false), diff --git a/src/erf_oas_3_0.erl b/src/erf_oas_3_0.erl deleted file mode 100644 index b80cd77..0000000 --- a/src/erf_oas_3_0.erl +++ /dev/null @@ -1,577 +0,0 @@ -%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License - -%% @doc An OpenAPI Specification 3.0 erf_parser. --module(erf_oas_3_0). - -%%% BEHAVIOURS --behaviour(erf_parser). - -%%% EXTERNAL EXPORTS --export([ - parse/1 -]). - -%%% RECORDS --record(ctx, { - base_path :: binary(), - namespace :: binary(), - resolved :: [erf_parser:ref()], - spec :: oas() -}). - -%%% TYPES --type ctx() :: #ctx{}. --type oas() :: njson:t(). - -%%% MACROS --define(METHODS, [ - <<"get">>, - <<"put">>, - <<"post">>, - <<"delete">>, - <<"options">>, - <<"head">>, - <<"patch">>, - <<"trace">> -]). - -%%%----------------------------------------------------------------------------- -%%% EXTERNAL EXPORTS -%%%----------------------------------------------------------------------------- --spec parse(SpecPath) -> Result when - SpecPath :: binary(), - Result :: {ok, API} | {error, Reason}, - API :: erf:api(), - Reason :: term(). -%% @doc Parses an OpenAPI Specification 3.0 file into an API AST. -parse(SpecPath) -> - case read_spec(SpecPath) of - {ok, BinSpec} -> - case deserialize_spec(BinSpec) of - {ok, OAS} -> - case oas_3_0:is_valid(OAS) of - true -> - CTX = #ctx{ - base_path = SpecPath, - namespace = filename:rootname(filename:basename(SpecPath)), - resolved = [], - spec = OAS - }, - {ok, parse_api(OAS, CTX)}; - false -> - {error, {invalid_spec, <<"Invalid OpenAPI Specification 3.0">>}} - end; - {error, Reason} -> - {error, {invalid_spec, Reason}} - end; - {error, Reason} -> - {error, {invalid_spec, Reason}} - end. - -%%%----------------------------------------------------------------------------- -%%% INTERNAL FUNCTIONS -%%%----------------------------------------------------------------------------- --spec deserialize_spec(Bin) -> Result when - Bin :: binary(), - Result :: {ok, map()} | {error, Reason}, - Reason :: term(). -deserialize_spec(Bin) -> - try - Data = njson:decode(Bin), - {ok, Data} - catch - _Error:Reason -> - {error, {invalid_json, Reason}} - end. - --spec get(Keys, Spec) -> Result when - Keys :: [binary()], - Spec :: map(), - Result :: term(). -get([], Spec) -> - Spec; -get([Key | Keys], Spec) -> - get(Keys, maps:get(Key, Spec)). - --spec parse_api(OAS, CTX) -> Result when - OAS :: oas(), - CTX :: ctx(), - Result :: erf:api(). -parse_api(OAS, CTX) -> - Name = parse_name(OAS), - Version = parse_version(OAS), - {RawEndpoints, RawSchemas, _NewCTX} = lists:foldl( - fun({Path, RawEndpoint}, {EndpointsAcc, SchemasAcc, CTXAcc}) -> - {Endpoint, EndpointSchemas, NewCTX} = parse_endpoint(Path, RawEndpoint, CTXAcc), - {[Endpoint | EndpointsAcc], SchemasAcc ++ EndpointSchemas, NewCTX} - end, - {[], [], CTX}, - maps:to_list(maps:get(<<"paths">>, OAS)) - ), - Endpoints = lists:reverse(RawEndpoints), - Schemas = maps:from_list(RawSchemas), - #{ - name => Name, - version => Version, - endpoints => Endpoints, - schemas => Schemas - }. - --spec parse_endpoint(Path, RawEndpoint, CTX) -> Result when - Path :: binary(), - RawEndpoint :: oas(), - CTX :: ctx(), - Result :: {Endpoint, Schemas, NewCTX}, - Endpoint :: erf_parser:endpoint(), - Schemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_endpoint(Path, RawEndpoint, #ctx{namespace = Namespace} = CTX) -> - EndpointNamespace = erf_util:to_snake_case(<>), - {Parameters, ParametersSchemas, ParametersCTX} = - lists:foldl( - fun(RawParameter, {ParametersAcc, ParametersExtraSchemasAcc, ParametersCTXAcc}) -> - {Parameter, ParameterExtraSchemas, ParameterCTX} = parse_parameter( - RawParameter, ParametersCTXAcc - ), - { - [Parameter | ParametersAcc], - ParameterExtraSchemas ++ ParametersExtraSchemasAcc, - ParameterCTX - } - end, - {[], [], CTX#ctx{namespace = EndpointNamespace}}, - maps:get(<<"parameters">>, RawEndpoint, []) - ), - - RawOperations = lists:reverse( - lists:filtermap( - fun(Method) -> - case maps:get(Method, RawEndpoint, undefined) of - undefined -> - false; - Operation -> - {true, {Method, Operation}} - end - end, - ?METHODS - ) - ), - {Operations, OperationsSchemas, OperationsCTX} = - lists:foldl( - fun( - {Method, RawOperation}, {OperationsAcc, OperationsExtraSchemasAcc, OperationsCTXAcc} - ) -> - {Operation, Schemas, OperationCTX} = parse_operation( - Path, Method, RawOperation, OperationsCTXAcc - ), - {[Operation | OperationsAcc], Schemas ++ OperationsExtraSchemasAcc, OperationCTX} - end, - {[], [], ParametersCTX#ctx{namespace = Namespace}}, - RawOperations - ), - - Endpoint = #{ - path => Path, - parameters => Parameters, - operations => Operations - }, - Schemas = ParametersSchemas ++ OperationsSchemas, - {Endpoint, Schemas, OperationsCTX#ctx{namespace = Namespace}}. - --spec parse_method(Method) -> Result when - Method :: binary(), - Result :: erf_parser:method(). -parse_method(<<"get">>) -> - get; -parse_method(<<"post">>) -> - post; -parse_method(<<"put">>) -> - put; -parse_method(<<"delete">>) -> - delete; -parse_method(<<"patch">>) -> - patch; -parse_method(<<"head">>) -> - head; -parse_method(<<"options">>) -> - options; -parse_method(<<"trace">>) -> - trace; -parse_method(<<"connect">>) -> - connect. - --spec parse_name(Val) -> Result when - Val :: oas(), - Result :: binary(). -parse_name(#{<<"info">> := #{<<"title">> := Name}}) -> - Name. - --spec parse_operation(Path, Method, RawOperation, CTX) -> Result when - Path :: binary(), - Method :: binary(), - RawOperation :: oas(), - CTX :: ctx(), - Result :: {Operation, Schemas, NewCTX}, - Operation :: erf_parser:operation(), - Schemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_operation( - Path, - RawMethod, - #{<<"responses">> := RawResponses} = RawOperation, - #ctx{namespace = Namespace} = CTX -) -> - OperationId = - case maps:get(<<"operationId">>, RawOperation, undefined) of - undefined -> - erf_util:to_snake_case(<>); - RawOperationId -> - erf_util:to_snake_case(RawOperationId) - end, - NewCTX = CTX#ctx{namespace = <>}, - Method = parse_method(RawMethod), - - {Parameters, ParametersSchemas, ParametersCTX} = - lists:foldl( - fun(RawParameter, {ParametersAcc, ParametersExtraSchemasAcc, ParametersCTXAcc}) -> - {Parameter, ParameterExtraSchemas, ParameterCTX} = parse_parameter( - RawParameter, ParametersCTXAcc - ), - { - [Parameter | ParametersAcc], - ParameterExtraSchemas ++ ParametersExtraSchemasAcc, - ParameterCTX - } - end, - {[], [], NewCTX}, - maps:get(<<"parameters">>, RawOperation, []) - ), - - RequestBodyRef = erf_util:to_snake_case(<<(NewCTX#ctx.namespace)/binary, "_request_body">>), - RawRequestBody = maps:get(<<"requestBody">>, RawOperation, undefined), - {RequestBodySchema, RequestBodyExtraSchemas, RequestBodyCTX} = parse_request_body( - RawRequestBody, ParametersCTX - ), - RequestBodySchemas = [{RequestBodyRef, RequestBodySchema} | RequestBodyExtraSchemas], - - {Responses, ResponsesSchemas, ResponsesCTX} = - lists:foldl( - fun( - {RawStatusCode, RawResponse}, - {ResponsesAcc, ResponsesExtraSchemasAcc, ResponsesCTXAcc} - ) -> - StatusCode = - case RawStatusCode of - <<"default">> -> - '*'; - _RawStatusCode -> - erlang:binary_to_integer(RawStatusCode) - end, - {ResponseBody, ResponseExtraSchemas, ResponseCTX} = parse_response_body( - RawResponse, ResponsesCTXAcc - ), - RawRef = - case StatusCode of - '*' -> - <<"default">>; - _StatusCode -> - erlang:integer_to_binary(StatusCode) - end, - Ref = erf_util:to_snake_case(<< - (NewCTX#ctx.namespace)/binary, "_response_body_", RawRef/binary - >>), - { - ResponsesAcc#{StatusCode => Ref}, - [{Ref, ResponseBody} | ResponseExtraSchemas] ++ ResponsesExtraSchemasAcc, - ResponseCTX - } - end, - {#{}, [], RequestBodyCTX}, - maps:to_list(RawResponses) - ), - - Operation = #{ - id => OperationId, - method => Method, - parameters => Parameters, - request_body => RequestBodyRef, - responses => Responses - }, - Schemas = ParametersSchemas ++ RequestBodySchemas ++ ResponsesSchemas, - {Operation, Schemas, ResponsesCTX}. - --spec parse_parameter(OAS, CTX) -> Result when - OAS :: oas(), - CTX :: ctx(), - Result :: {Parameter, ExtraSchemas, NewCTX}, - Parameter :: erf_parser:parameter(), - ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_parameter(#{<<"$ref">> := Ref}, CTX) -> - {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), - parse_parameter(RefOAS, RefCTX); -parse_parameter(#{<<"content">> := Content} = RawParameter, #ctx{namespace = Namespace} = CTX) -> - ParameterType = - case maps:get(<<"in">>, RawParameter) of - <<"query">> -> - query; - <<"header">> -> - header; - <<"path">> -> - path; - <<"cookie">> -> - cookie - end, - DefaultRequired = - case ParameterType of - path -> - true; - _Type -> - false - end, - Required = maps:get(<<"required">>, RawParameter, DefaultRequired), - ParameterName = maps:get(<<"name">>, RawParameter), - ParameterRef = erf_util:to_snake_case(<>), - Parameter = #{ - ref => ParameterRef, - name => ParameterName, - type => ParameterType - }, - {AnyOf, ExtraSchemas, NewCTX} = - lists:foldl( - fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - { - [Schema#{<<"nullable">> => not Required} | AnyOfAcc], - ExtraSchemas ++ ExtraSchemasAcc, - SchemaCTX - } - end, - {[], [], CTX}, - maps:to_list(Content) - ), - ParameterSchema = #{<<"anyOf">> => AnyOf}, - {Parameter, [{ParameterRef, ParameterSchema} | ExtraSchemas], NewCTX}; -parse_parameter(#{<<"schema">> := RawSchema} = RawParameter, #ctx{namespace = Namespace} = CTX) -> - ParameterType = - case maps:get(<<"in">>, RawParameter) of - <<"query">> -> - query; - <<"header">> -> - header; - <<"path">> -> - path; - <<"cookie">> -> - cookie - end, - DefaultRequired = - case ParameterType of - path -> - true; - _Type -> - false - end, - Required = maps:get(<<"required">>, RawParameter, DefaultRequired), - ParameterName = maps:get(<<"name">>, RawParameter), - ParameterRef = erf_util:to_snake_case(<>), - Parameter = #{ - ref => ParameterRef, - name => ParameterName, - type => ParameterType - }, - {RawParameterSchema, NewExtraSchemas, NewCTX} = parse_schema(RawSchema, CTX), - ParameterSchema = maps:put(<<"nullable">>, not Required, RawParameterSchema), - {Parameter, [{ParameterRef, ParameterSchema} | NewExtraSchemas], NewCTX}. - --spec parse_request_body(OAS, CTX) -> Result when - OAS :: oas(), - CTX :: ctx(), - Result :: {RequestBody, ExtraSchemas, NewCTX}, - RequestBody :: erf_parser:schema(), - ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_request_body(#{<<"$ref">> := Ref}, CTX) -> - {RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), - {NewSchema, NewExtraSchemas, NewCTX} = parse_request_body(RefOAS, RefCTX), - {#{<<"$ref">> => RefResolved}, [{RefResolved, NewSchema} | NewExtraSchemas], NewCTX}; -parse_request_body(#{<<"content">> := Content} = ReqBody, CTX) -> - Required = maps:get(<<"required">>, ReqBody, false), - {AnyOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - { - [Schema#{<<"nullable">> => not Required} | AnyOfAcc], - ExtraSchemas ++ ExtraSchemasAcc, - SchemaCTX - } - end, - {[], [], CTX}, - maps:to_list(Content) - ), - {#{<<"anyOf">> => AnyOf}, NewExtraSchemas, NewCTX}; -parse_request_body(_ReqBody, CTX) -> - {undefined, [], CTX}. - --spec parse_response_body(OAS, CTX) -> Result when - OAS :: oas(), - CTX :: ctx(), - Result :: {ResponseBody, ExtraSchemas, NewCTX}, - ResponseBody :: erf_parser:schema(), - ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_response_body(#{<<"$ref">> := Ref}, CTX) -> - {RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), - {NewSchema, NewExtraSchemas, NewCTX} = parse_response_body(RefOAS, RefCTX), - {#{<<"$ref">> => RefResolved}, [{RefResolved, NewSchema} | NewExtraSchemas], NewCTX}; -parse_response_body(#{<<"content">> := Content}, CTX) -> - {AnyOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} - end, - {[], [], CTX}, - maps:to_list(Content) - ), - {#{<<"anyOf">> => AnyOf}, NewExtraSchemas, NewCTX}; -parse_response_body(_Response, CTX) -> - {undefined, [], CTX}. - --spec parse_schema(OAS, CTX) -> Result when - OAS :: oas(), - CTX :: ctx(), - Result :: {Schema, ExtraSchemas, NewCTX}, - Schema :: erf_parser:schema(), - ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], - NewCTX :: ctx(). -parse_schema(#{<<"$ref">> := Ref}, CTX) -> - {RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), - {NewSchema, NewExtraSchemas, NewCTX} = parse_schema(RefOAS, RefCTX), - {#{<<"$ref">> => RefResolved}, [{RefResolved, NewSchema} | NewExtraSchemas], NewCTX}; -parse_schema(#{<<"items">> := RawItems} = Schema, CTX) -> - {Items, NewExtraSchemas, NewCTX} = parse_schema(RawItems, CTX), - {Schema#{<<"items">> => Items}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"properties">> := RawProperties} = Schema, CTX) -> - {Properties, PropertiesExtraSchemas, PropertiesCTX} = lists:foldl( - fun({Name, RawProperty}, {PropertiesAcc, ExtraSchemasAcc, CTXAcc}) -> - {Property, ExtraSchemas, PropertyCTX} = parse_schema(RawProperty, CTXAcc), - {PropertiesAcc#{Name => Property}, ExtraSchemas ++ ExtraSchemasAcc, PropertyCTX} - end, - {#{}, [], CTX}, - maps:to_list(RawProperties) - ), - PropertiesSchema = Schema#{<<"properties">> => Properties}, - RawAdditionalProperties = maps:get(<<"additionalProperties">>, Schema, undefined), - {AdditionalProperties, AdditionalPropertiesExtraSchemas, AdditionalPropertiesCTX} = parse_schema( - RawAdditionalProperties, PropertiesCTX - ), - AdditionalPropertiesSchema = PropertiesSchema#{ - <<"additionalProperties">> => AdditionalProperties - }, - {AdditionalPropertiesSchema, PropertiesExtraSchemas ++ AdditionalPropertiesExtraSchemas, - AdditionalPropertiesCTX}; -parse_schema(#{<<"allOf">> := RawAllOf}, CTX) -> - {AllOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {AllOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AllOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} - end, - {[], [], CTX}, - RawAllOf - ), - {#{<<"allOf">> => AllOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"oneOf">> := RawOneOf}, CTX) -> - {OneOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {OneOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | OneOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} - end, - {[], [], CTX}, - RawOneOf - ), - {#{<<"oneOf">> => OneOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"anyOf">> := RawAnyOf}, CTX) -> - {AnyOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} - end, - {[], [], CTX}, - RawAnyOf - ), - {#{<<"anyOf">> => AnyOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"not">> := RawNot}, CTX) -> - {Not, NewExtraSchemas, NewCTX} = parse_schema(RawNot, CTX), - {#{<<"not">> => Not}, NewExtraSchemas, NewCTX}; -parse_schema(Schema, CTX) -> - {Schema, [], CTX}. - --spec parse_version(OAS) -> Version when - OAS :: oas(), - Version :: binary(). -parse_version(#{<<"info">> := #{<<"version">> := Version}}) -> - Version. - --spec read_spec(SpecPath) -> Result when - SpecPath :: binary(), - Result :: {ok, BinSpec} | {error, Reason}, - BinSpec :: binary(), - Reason :: term(). -read_spec(SpecPath) -> - case file:read_file(SpecPath) of - {ok, BinSpec} -> - {ok, BinSpec}; - {error, Reason} -> - {error, {invalid_spec, Reason}} - end. - --spec resolve_ref(Ref, CTX) -> Result when - Ref :: binary(), - CTX :: ctx(), - Result :: {NewResolved, NewOAS, NewCTX}, - NewResolved :: binary(), - NewOAS :: oas(), - NewCTX :: ctx(). -resolve_ref(Ref, #ctx{base_path = BasePath, resolved = Resolved, spec = Spec}) -> - [FilePath, ElementPath] = binary:split(Ref, <<"#">>, [global]), - [<<>> | LocalPath] = binary:split(ElementPath, <<"/">>, [global]), - {NewSpec, NewBasePath, NewNamespace} = - case FilePath of - <<>> -> - ResetNamespace = filename:rootname(filename:basename(BasePath)), - {Spec, BasePath, ResetNamespace}; - _FilePath -> - RefBasePath = filename:join(filename:dirname(BasePath), FilePath), - case read_spec(RefBasePath) of - {ok, Bin} -> - case deserialize_spec(Bin) of - {ok, RefSpec} -> - RefNamespace = filename:rootname(filename:basename(FilePath)), - {RefSpec, RefBasePath, RefNamespace}; - {error, Reason} -> - erlang:error({invalid_ref, Reason}) - end; - {error, Reason} -> - erlang:error({invalid_ref, Reason}) - end - end, - NewResolved = <>, - NewOAS = get(LocalPath, NewSpec), - NewCTX = #ctx{ - base_path = NewBasePath, - namespace = NewNamespace, - resolved = [NewResolved | Resolved], - spec = NewSpec - }, - {NewResolved, NewOAS, NewCTX}. diff --git a/src/erf_parser.erl b/src/erf_parser.erl index ac1d4dd..95ca878 100644 --- a/src/erf_parser.erl +++ b/src/erf_parser.erl @@ -27,6 +27,10 @@ endpoints := [endpoint()], schemas := #{ref() => schema()} }. +-type body() :: #{ + ref := ref(), + required := boolean() +}. -type endpoint() :: #{ path := path(), parameters := [parameter()], @@ -37,32 +41,46 @@ id := binary(), method := method(), parameters := [parameter()], - request_body := ref(), + request := request(), responses := #{ - '*' | status_code() := ref() + '*' | status_code() := response() } }. -type parameter() :: #{ ref := ref(), name := parameter_name(), - type := parameter_type() + type := parameter_type(), + required := boolean() }. -type parameter_name() :: binary(). -type parameter_type() :: header | cookie | path | query. -type path() :: binary(). -type ref() :: binary(). +-type request() :: #{ + body := body() +}. +-type response() :: #{ + body := body() +}. -type schema() :: ndto:schema(). -type status_code() :: 100..599. %%% TYPE EXPORTS -export_type([ api/0, + body/0, endpoint/0, method/0, operation/0, parameter/0, + parameter_name/0, + parameter_type/0, + path/0, ref/0, - schema/0 + request/0, + response/0, + schema/0, + status_code/0 ]). %%%----------------------------------------------------------------------------- diff --git a/src/erf_parser/erf_parser_oas_3_0.erl b/src/erf_parser/erf_parser_oas_3_0.erl new file mode 100644 index 0000000..5d66765 --- /dev/null +++ b/src/erf_parser/erf_parser_oas_3_0.erl @@ -0,0 +1,813 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License + +%% @doc An OpenAPI Specification 3.0 erf_parser. +-module(erf_parser_oas_3_0). + +%%% BEHAVIOURS +-behaviour(erf_parser). + +%%% EXTERNAL EXPORTS +-export([ + parse/1 +]). + +%%% TYPES +-type ctx() :: #{ + base_path := binary(), + base_name := binary(), + namespace := binary(), + resolved := [binary()], + spec := spec() +}. +-type spec() :: njson:t(). + +%%% MACROS +-define(METHODS, [ + <<"get">>, + <<"put">>, + <<"post">>, + <<"delete">>, + <<"options">>, + <<"head">>, + <<"patch">>, + <<"trace">> +]). + +%%%----------------------------------------------------------------------------- +%%% EXTERNAL EXPORTS +%%%----------------------------------------------------------------------------- +-spec parse(SpecPath) -> Result when + SpecPath :: binary(), + Result :: {ok, API} | {error, Reason}, + API :: erf:api(), + Reason :: term(). +%% @doc Parses an OpenAPI Specification 3.0 file into an API AST. +parse(SpecPath) -> + case parse_spec(SpecPath) of + {ok, OAS} -> + case oas_3_0:is_valid(OAS) of + true -> + BasePath = filename:dirname(SpecPath), + BaseName = filename:rootname(filename:basename(SpecPath)), + CTX = #{ + base_path => BasePath, + base_name => BaseName, + namespace => BaseName, + resolved => [], + spec => OAS + }, + API = parse_api(OAS, CTX), + {ok, ndto_parser_json_schema:clean_optionals(API)}; + {false, Reason} -> + {error, {invalid_oas_3_0_spec, Reason}} + end; + {error, Reason} -> + {error, {invalid_oas_3_0_spec, Reason}} + end. + +%%%----------------------------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%----------------------------------------------------------------------------- +-spec parse_api(OAS, CTX) -> Result when + OAS :: spec(), + CTX :: ctx(), + Result :: erf:api(). +parse_api(OAS, CTX) -> + Name = parse_name(OAS), + Version = parse_version(OAS), + {RawEndpoints, RawSchemas, _NewCTX} = lists:foldl( + fun({Path, RawEndpoint}, {EndpointsAcc, SchemasAcc, CTXAcc}) -> + {Endpoint, EndpointSchemas, NewCTX} = parse_endpoint(Path, RawEndpoint, CTXAcc), + { + [Endpoint | EndpointsAcc], + SchemasAcc ++ EndpointSchemas, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } + end, + {[], [], CTX}, + maps:to_list(maps:get(<<"paths">>, OAS)) + ), + Endpoints = lists:reverse(RawEndpoints), + Schemas = maps:from_list(RawSchemas), + #{ + name => Name, + version => Version, + endpoints => Endpoints, + schemas => Schemas + }. + +-spec parse_endpoint(Path, RawEndpoint, CTX) -> Result when + Path :: binary(), + RawEndpoint :: spec(), + CTX :: ctx(), + Result :: {Endpoint, Schemas, NewCTX}, + Endpoint :: erf_parser:endpoint(), + Schemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_endpoint(Path, RawEndpoint, CTX) -> + {Parameters, ParametersSchemas, ParametersCTX} = + lists:foldl( + fun(RawParameter, {ParametersAcc, ParametersExtraSchemasAcc, ParametersCTXAcc}) -> + {Parameter, ParameterExtraSchemas, ParameterCTX} = parse_parameter( + RawParameter, ParametersCTXAcc + ), + { + [Parameter | ParametersAcc], + ParameterExtraSchemas ++ ParametersExtraSchemasAcc, + ParametersCTXAcc#{resolved => maps:get(resolved, ParameterCTX)} + } + end, + {[], [], CTX}, + maps:get(<<"parameters">>, RawEndpoint, []) + ), + + RawOperations = lists:reverse( + lists:filtermap( + fun(Method) -> + case maps:get(Method, RawEndpoint, undefined) of + undefined -> + false; + Operation -> + {true, {Method, Operation}} + end + end, + ?METHODS + ) + ), + {Operations, OperationsSchemas, OperationsCTX} = + lists:foldl( + fun( + {Method, RawOperation}, {OperationsAcc, OperationsExtraSchemasAcc, OperationsCTXAcc} + ) -> + {Operation, Schemas, OperationCTX} = parse_operation( + Path, Method, RawOperation, OperationsCTXAcc + ), + { + [Operation | OperationsAcc], + Schemas ++ OperationsExtraSchemasAcc, + OperationsCTXAcc#{resolved => maps:get(resolved, OperationCTX)} + } + end, + {[], [], ParametersCTX}, + RawOperations + ), + + Endpoint = #{ + path => Path, + parameters => Parameters, + operations => Operations + }, + Schemas = ParametersSchemas ++ OperationsSchemas, + {Endpoint, Schemas, OperationsCTX}. + +-spec parse_method(Method) -> Result when + Method :: binary(), + Result :: erf_parser:method(). +parse_method(<<"get">>) -> + get; +parse_method(<<"post">>) -> + post; +parse_method(<<"put">>) -> + put; +parse_method(<<"delete">>) -> + delete; +parse_method(<<"patch">>) -> + patch; +parse_method(<<"head">>) -> + head; +parse_method(<<"options">>) -> + options; +parse_method(<<"trace">>) -> + trace; +parse_method(<<"connect">>) -> + connect. + +-spec parse_name(Val) -> Result when + Val :: spec(), + Result :: binary(). +parse_name(#{<<"info">> := #{<<"title">> := Name}}) -> + Name. + +-spec parse_operation(Path, Method, RawOperation, CTX) -> Result when + Path :: binary(), + Method :: binary(), + RawOperation :: spec(), + CTX :: ctx(), + Result :: {Operation, Schemas, NewCTX}, + Operation :: erf_parser:operation(), + Schemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_operation( + Path, + RawMethod, + #{<<"responses">> := RawResponses} = RawOperation, + #{namespace := Namespace} = CTX +) -> + OperationId = + case maps:get(<<"operationId">>, RawOperation, undefined) of + undefined -> + erf_util:to_snake_case(<>); + RawOperationId -> + erf_util:to_snake_case(RawOperationId) + end, + NewCTX = CTX#{namespace => <>}, + Method = parse_method(RawMethod), + + {Parameters, ParametersSchemas, ParametersCTX} = + lists:foldl( + fun(RawParameter, {ParametersAcc, ParametersExtraSchemasAcc, ParametersCTXAcc}) -> + {Parameter, ParameterExtraSchemas, ParameterCTX} = parse_parameter( + RawParameter, ParametersCTXAcc + ), + { + [Parameter | ParametersAcc], + ParameterExtraSchemas ++ ParametersExtraSchemasAcc, + ParametersCTXAcc#{resolved => maps:get(resolved, ParameterCTX)} + } + end, + {[], [], NewCTX}, + maps:get(<<"parameters">>, RawOperation, []) + ), + + RawRequestBody = maps:get(<<"requestBody">>, RawOperation, undefined), + {ParsedRequestBody, RawRequestBodySchemas, RequestBodyCTX} = parse_request_body( + RawRequestBody, ParametersCTX + ), + RequestBodyRef = + erf_util:to_snake_case(<< + (maps:get(namespace, NewCTX))/binary, "_request_body" + >>), + RequestBodyRequired = maps:get(required, ParsedRequestBody), + RequestBodySchema = maps:get(schema, ParsedRequestBody), + RequestBodySchemas = [{RequestBodyRef, RequestBodySchema} | RawRequestBodySchemas], + RequestBody = #{ + ref => RequestBodyRef, + required => RequestBodyRequired + }, + Request = #{ + body => RequestBody + }, + + {Responses, ResponsesSchemas, ResponsesCTX} = + lists:foldl( + fun( + {RawStatusCode, RawResponse}, + {ResponsesAcc, ResponsesExtraSchemasAcc, ResponsesCTXAcc} + ) -> + StatusCode = + case RawStatusCode of + <<"default">> -> + '*'; + _RawStatusCode -> + erlang:binary_to_integer(RawStatusCode) + end, + {ParsedResponse, RawResponseExtraSchemas, ResponseCTX} = parse_response( + RawResponse, ResponsesCTXAcc + ), + ResponseRef = + erf_util:to_snake_case(<< + (maps:get(namespace, NewCTX))/binary, + "_response_body_", + RawStatusCode/binary + >>), + ResponseRequired = maps:get(required, ParsedResponse), + ResponseSchema = maps:get(schema, ParsedResponse), + ResponseExtraSchemas = [{ResponseRef, ResponseSchema} | RawResponseExtraSchemas], + ResponseBody = #{ + ref => ResponseRef, + required => ResponseRequired + }, + Response = #{ + body => ResponseBody + }, + { + ResponsesAcc#{StatusCode => Response}, + ResponseExtraSchemas ++ ResponsesExtraSchemasAcc, + ResponsesCTXAcc#{resolved => maps:get(resolved, ResponseCTX)} + } + end, + {#{}, [], RequestBodyCTX}, + maps:to_list(RawResponses) + ), + + Operation = #{ + id => OperationId, + method => Method, + parameters => Parameters, + request => Request, + responses => Responses + }, + Schemas = ParametersSchemas ++ RequestBodySchemas ++ ResponsesSchemas, + {Operation, Schemas, ResponsesCTX}. + +-spec parse_parameter(OAS, CTX) -> Result when + OAS :: spec(), + CTX :: ctx(), + Result :: {Parameter, ExtraSchemas, NewCTX}, + Parameter :: erf_parser:parameter(), + ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_parameter(#{<<"$ref">> := Ref}, CTX) -> + {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), + parse_parameter(RefOAS, RefCTX); +parse_parameter(#{<<"content">> := Content} = RawParameter, #{namespace := Namespace} = CTX) -> + ParameterType = + case maps:get(<<"in">>, RawParameter) of + <<"query">> -> + query; + <<"header">> -> + header; + <<"path">> -> + path; + <<"cookie">> -> + cookie + end, + DefaultRequired = + case ParameterType of + path -> + true; + _Type -> + false + end, + Required = maps:get(<<"required">>, RawParameter, DefaultRequired), + ParameterName = maps:get(<<"name">>, RawParameter), + ParameterRef = erf_util:to_snake_case(<>), + Parameter = #{ + ref => ParameterRef, + name => ParameterName, + type => ParameterType, + required => Required + }, + {AnyOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Schema, NewExtraSchemas, NewCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + NewExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } + end, + {[], [], CTX}, + maps:to_list(Content) + ), + ParameterSchema = #{any_of => AnyOf}, + {Parameter, [{ParameterRef, ParameterSchema} | ExtraSchemas], NewCTX}; +parse_parameter(#{<<"schema">> := RawSchema} = RawParameter, #{namespace := Namespace} = CTX) -> + ParameterType = + case maps:get(<<"in">>, RawParameter) of + <<"query">> -> + query; + <<"header">> -> + header; + <<"path">> -> + path; + <<"cookie">> -> + cookie + end, + DefaultRequired = + case ParameterType of + path -> + true; + _Type -> + false + end, + Required = maps:get(<<"required">>, RawParameter, DefaultRequired), + ParameterName = maps:get(<<"name">>, RawParameter), + ParameterRef = erf_util:to_snake_case(<>), + Parameter = #{ + ref => ParameterRef, + name => ParameterName, + type => ParameterType, + required => Required + }, + {ParameterSchema, NewExtraSchemas, NewCTX} = parse_schemas(RawSchema, CTX), + {Parameter, [{ParameterRef, ParameterSchema} | NewExtraSchemas], NewCTX}. + +-spec parse_request_body(OAS, CTX) -> Result when + OAS :: spec(), + CTX :: ctx(), + Result :: {RequestBody, ExtraSchemas, NewCTX}, + RequestBody :: #{schema := erf_parser:schema(), required := boolean()}, + ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_request_body(#{<<"$ref">> := Ref}, CTX) -> + {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), + parse_request_body(RefOAS, RefCTX); +parse_request_body(#{<<"content">> := Content} = ReqBody, CTX) -> + Required = maps:get(<<"required">>, ReqBody, false), + {AnyOf, ExtraSchemas, NewCTX} = lists:foldl( + fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Schema, NewExtraSchemas, SchemaCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + NewExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{ + resolved => maps:get(resolved, SchemaCTX) + } + } + end, + {[], [], CTX}, + maps:to_list(Content) + ), + RequestBodySchema = #{any_of => AnyOf}, + RequestBody = #{ + schema => RequestBodySchema, + required => Required + }, + {RequestBody, ExtraSchemas, NewCTX}; +parse_request_body(_ReqBody, CTX) -> + RequestBody = #{ + schema => true, + required => false + }, + {RequestBody, [], CTX}. + +-spec parse_response(OAS, CTX) -> Result when + OAS :: spec(), + CTX :: ctx(), + Result :: {Response, ExtraSchemas, NewCTX}, + Response :: #{schema := erf_parser:schema(), required := boolean()}, + ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_response(#{<<"$ref">> := Ref}, CTX) -> + {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), + parse_response(RefOAS, RefCTX); +parse_response(#{<<"content">> := Content}, CTX) -> + {AnyOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Schema, ExtraSchemas, SchemaCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + ExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{ + resolved => maps:get(resolved, SchemaCTX) + } + } + end, + {[], [], CTX}, + maps:to_list(Content) + ), + ResponseSchema = #{any_of => AnyOf}, + Response = #{ + schema => ResponseSchema, + required => false + }, + {Response, ExtraSchemas, NewCTX}; +parse_response(_Response, CTX) -> + Response = #{ + schema => true, + required => false + }, + {Response, [], CTX}. + +-spec parse_spec(SpecPath) -> Result when + SpecPath :: binary(), + Result :: {ok, Spec} | {error, Reason}, + Spec :: spec(), + Reason :: term(). +parse_spec(SpecPath) -> + case file:read_file(SpecPath) of + {ok, BinSpec} -> + case filename:extension(SpecPath) of + JSON when JSON =:= <<".json">> orelse JSON =:= ".json" -> + case njson:decode(BinSpec) of + {ok, undefined} -> + {error, {invalid_spec, BinSpec}}; + {ok, Spec} -> + {ok, Spec}; + {error, Reason} -> + {error, {invalid_json, Reason}} + end; + Extension -> + {error, {unsupported_extension, Extension}} + end; + {error, Reason} -> + {error, {invalid_spec, Reason}} + end. + +-spec parse_version(OAS) -> Version when + OAS :: spec(), + Version :: binary(). +parse_version(#{<<"info">> := #{<<"version">> := Version}}) -> + Version. + +-spec parse_schemas(Spec, CTX) -> Result when + Spec :: spec(), + CTX :: ctx(), + Result :: {Schema, ExtraSchemas, NewCTX}, + Schema :: erf_parser:schema(), + ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], + NewCTX :: ctx(). +parse_schemas(false, CTX) -> + Schema = false, + {Schema, [], CTX}; +parse_schemas(true, CTX) -> + Schema = #{}, + {Schema, [], CTX}; +parse_schemas(#{<<"$ref">> := Ref} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {RefName, RefSchema, RefCTX} = resolve_ref(Ref, CTX), + Schema = #{ + ref => RefName, + nullable => Nullable + }, + case lists:member(RefName, maps:get(resolved, CTX)) of + true -> + {Schema, [], CTX}; + false -> + {NewSchema, NewExtraSchemas, NewCTX} = parse_schemas(RefSchema, RefCTX), + {Schema, [{RefName, NewSchema} | NewExtraSchemas], NewCTX} + end; +parse_schemas(#{<<"enum">> := Enum} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Schema = #{ + enum => Enum, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"boolean">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Schema = #{ + type => boolean, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"integer">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Minimum = maps:get(<<"minimum">>, RawSchema, undefined), + ExclusiveMinimum = maps:get(<<"exclusiveMinimum">>, RawSchema, undefined), + Maximum = maps:get(<<"maximum">>, RawSchema, undefined), + ExclusiveMaximum = maps:get(<<"exclusiveMaximum">>, RawSchema, undefined), + MultipleOf = maps:get(<<"multipleOf">>, RawSchema, undefined), + Schema = + #{ + type => integer, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum, + multiple_of => MultipleOf, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"number">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Minimum = maps:get(<<"minimum">>, RawSchema, undefined), + ExclusiveMinimum = maps:get(<<"exclusiveMinimum">>, RawSchema, undefined), + Maximum = maps:get(<<"maximum">>, RawSchema, undefined), + ExclusiveMaximum = maps:get(<<"exclusiveMaximum">>, RawSchema, undefined), + MultipleOf = maps:get(<<"multipleOf">>, RawSchema, undefined), + Schema = + #{ + any_of => [ + #{ + type => integer, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum, + multiple_of => MultipleOf + }, + #{ + type => float, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum + } + ], + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"string">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + MinLength = maps:get(<<"minLength">>, RawSchema, undefined), + MaxLength = maps:get(<<"maxLength">>, RawSchema, undefined), + Format = + case maps:get(<<"format">>, RawSchema, undefined) of + <<"iso8601">> -> + iso8601; + <<"byte">> -> + base64; + _Otherwise -> + undefined + end, + Pattern = maps:get(<<"pattern">>, RawSchema, undefined), + Schema = + #{ + type => string, + min_length => MinLength, + max_length => MaxLength, + format => Format, + pattern => Pattern, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"array">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Items, ItemsExtraSchemas, ItemsCTX} = + case maps:get(<<"items">>, RawSchema, undefined) of + undefined -> + {undefined, [], CTX}; + RawItems -> + parse_schemas(RawItems, CTX) + end, + MinItems = maps:get(<<"minItems">>, RawSchema, undefined), + MaxItems = maps:get(<<"maxItems">>, RawSchema, undefined), + UniqueItems = maps:get(<<"uniqueItems">>, RawSchema, undefined), + Schema = + #{ + type => array, + items => Items, + min_items => MinItems, + max_items => MaxItems, + unique_items => UniqueItems, + nullable => Nullable + }, + {Schema, ItemsExtraSchemas, ItemsCTX}; +parse_schemas(#{<<"type">> := <<"object">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Properties, PropertiesExtraSchemas, PropertiesCTX} = + case maps:get(<<"properties">>, RawSchema, undefined) of + undefined -> + {undefined, [], CTX}; + RawProperties -> + lists:foldl( + fun({Property, RawPropertySchema}, {PropertiesAcc, ExtraSchemasAcc, CTXAcc}) -> + {PropertySchema, ExtraSchemas, NewCTX} = parse_schemas( + RawPropertySchema, CTXAcc + ), + { + PropertiesAcc#{Property => PropertySchema}, + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } + end, + {#{}, [], CTX}, + maps:to_list(RawProperties) + ) + end, + Required = maps:get(<<"required">>, RawSchema, undefined), + MinProperties = maps:get(<<"minProperties">>, RawSchema, undefined), + MaxProperties = maps:get(<<"maxProperties">>, RawSchema, undefined), + {AdditionalProperties, AdditionalPropertiesExtraSchemas, AdditionalPropertiesCTX} = + case maps:get(<<"additionalProperties">>, RawSchema, undefined) of + undefined -> + {undefined, [], PropertiesCTX}; + RawAdditionalProperties -> + parse_schemas(RawAdditionalProperties, PropertiesCTX) + end, + Schema = + #{ + type => object, + properties => Properties, + required => Required, + min_properties => MinProperties, + max_properties => MaxProperties, + additional_properties => AdditionalProperties, + nullable => Nullable + }, + ExtraSchemas = PropertiesExtraSchemas ++ AdditionalPropertiesExtraSchemas, + {Schema, ExtraSchemas, AdditionalPropertiesCTX}; +parse_schemas(#{<<"anyOf">> := RawAnyOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {AnyOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | AnyOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawAnyOf + ), + Schema = #{ + any_of => AnyOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"allOf">> := RawAllOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {AllOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {AllOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | AllOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawAllOf + ), + Schema = #{ + all_of => AllOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"not">> := RawNot} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Not, ExtraSchemas, NewCTX} = parse_schemas(RawNot, CTX), + Schema = #{ + 'not' => Not, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"oneOf">> := RawOneOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {OneOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {OneOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | OneOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawOneOf + ), + Schema = #{ + one_of => OneOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(RawUniversalSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawUniversalSchema, undefined), + Schema = #{ + nullable => Nullable + }, + {Schema, [], CTX}. + +-spec resolve_ref(Ref, CTX) -> Result when + Ref :: binary(), + CTX :: ctx(), + Result :: {NewResolved, NewSchema, NewCTX}, + NewResolved :: binary(), + NewSchema :: ndto:schema(), + NewCTX :: ctx(). +resolve_ref(Ref, CTX) -> + BasePath = maps:get(base_path, CTX), + BaseName = maps:get(base_name, CTX), + Resolved = maps:get(resolved, CTX), + Spec = maps:get(spec, CTX), + + [FilePath, ElementPath] = binary:split(Ref, <<"#">>, [global]), + LocalPath = binary:split(ElementPath, <<"/">>, [global, trim_all]), + {NewSpec, NewBasePath, NewBaseName} = + case FilePath of + <<>> -> + {Spec, BasePath, BaseName}; + _FilePath -> + AbsPath = filename:join(BasePath, FilePath), + case parse_spec(AbsPath) of + {ok, RefSpec} -> + RefBasePath = filename:dirname(AbsPath), + RefBaseName = filename:rootname(filename:basename(AbsPath)), + {RefSpec, RefBasePath, RefBaseName}; + {error, Reason} -> + % TODO: handle error + erlang:error({invalid_ref, Reason}) + end + end, + NewResolved = + case LocalPath of + [] -> + NewBaseName; + _LocalPath -> + erf_util:to_snake_case(<>) + end, + NewSchema = ndto_parser_json_schema:get(LocalPath, NewSpec), + NewCTX = #{ + base_path => NewBasePath, + base_name => NewBaseName, + namespace => NewBaseName, + resolved => [NewResolved | Resolved], + spec => NewSpec + }, + {NewResolved, NewSchema, NewCTX}. diff --git a/src/erf_postprocess_middleware.erl b/src/erf_postprocess_middleware.erl index 0ecc6a4..17c65b0 100644 --- a/src/erf_postprocess_middleware.erl +++ b/src/erf_postprocess_middleware.erl @@ -30,4 +30,6 @@ -callback postprocess(Request, Response) -> Result when Request :: erf:request(), Response :: erf:response(), - Result :: erf:response(). + Result :: + erf:response() + | {erf:request(), erf:response()}. diff --git a/src/erf_preprocess_middleware.erl b/src/erf_preprocess_middleware.erl index 346765d..e377ef5 100644 --- a/src/erf_preprocess_middleware.erl +++ b/src/erf_preprocess_middleware.erl @@ -29,4 +29,9 @@ %%%----------------------------------------------------------------------------- -callback preprocess(Request) -> Result when Request :: erf:request(), - Result :: erf:request() | {stop, erf:response()}. + Result :: + {stop, Response} + | {stop, Response, NewRequest} + | NewRequest, + NewRequest :: erf:request(), + Response :: erf:response(). diff --git a/src/erf_router.erl b/src/erf_router.erl index d854cc2..98aee22 100644 --- a/src/erf_router.erl +++ b/src/erf_router.erl @@ -126,18 +126,24 @@ handle(ElliRequest, [Name]) -> {ok, PreProcessMiddlewares} = erf_conf:preprocess_middlewares(Name), {ok, RouterMod} = erf_conf:router_mod(Name), {ok, PostProcessMiddlewares} = erf_conf:postprocess_middlewares(Name), - InitialRequest = preprocess(ElliRequest), - InitialResponse = - case apply_preprocess_middlewares(InitialRequest, PreProcessMiddlewares) of - {stop, Resp} -> - Resp; - Request -> - RouterMod:handle(Request) - end, - Response = apply_postprocess_middlewares( - InitialRequest, InitialResponse, PostProcessMiddlewares - ), - postprocess(InitialRequest, Response). + case preprocess(ElliRequest) of + {ok, Request} -> + {InitialResponse, InitialRequest} = + case apply_preprocess_middlewares(Request, PreProcessMiddlewares) of + {stop, PreprocessResponse, PreprocessRequest} -> + {PreprocessResponse, PreprocessRequest}; + PreprocessRequest -> + {RouterMod:handle(PreprocessRequest), PreprocessRequest} + end, + Response = apply_postprocess_middlewares( + InitialRequest, InitialResponse, PostProcessMiddlewares + ), + postprocess(InitialRequest, Response); + {error, _Reason} -> + ContentTypeHeader = string:casefold(<<"content-type">>), + % TODO: handle error + {500, [{ContentTypeHeader, <<"text/plain">>}], <<"Internal Server Error">>} + end. -spec handle_event(Event, Data, CallbackArgs) -> ok when Event :: atom(), @@ -171,13 +177,15 @@ handle_event(_Event, _Data, _CallbackArgs) -> -spec apply_preprocess_middlewares(Request, Middlewares) -> Result when Request :: erf:request(), Middlewares :: [erf_preprocess_middleware:t()], - Result :: erf:request() | {stop, erf:response()}. + Result :: erf:request() | {stop, erf:response(), erf:request()} | {stop, erf:response()}. apply_preprocess_middlewares(Request, []) -> Request; apply_preprocess_middlewares(RawRequest, [Middleware | Rest]) -> case Middleware:preprocess(RawRequest) of {stop, Response} -> - {stop, Response}; + {stop, Response, RawRequest}; + {stop, Response, Request} -> + {stop, Response, Request}; Request -> apply_preprocess_middlewares(Request, Rest) end. @@ -190,25 +198,12 @@ apply_preprocess_middlewares(RawRequest, [Middleware | Rest]) -> apply_postprocess_middlewares(_Request, Response, []) -> Response; apply_postprocess_middlewares(Request, RawResponse, [Middleware | Rest]) -> - Response = Middleware:postprocess(Request, RawResponse), - apply_postprocess_middlewares(Request, Response, Rest). - --spec chain_conditions(FunCalls, Operator) -> Result when - FunCalls :: [erl_syntax:syntaxTree()], - Operator :: 'andalso', - Result :: erl_syntax:syntaxTree(). -chain_conditions(FunCalls, 'andalso' = Operator) -> - chain_conditions(FunCalls, Operator, erl_syntax:atom(true)). - -chain_conditions([], _Operator, Acc) -> - Acc; -chain_conditions([FunCall | Rest], Operator, Acc) -> - NewAcc = erl_syntax:infix_expr( - Acc, - erl_syntax:operator(Operator), - FunCall - ), - chain_conditions(Rest, Operator, NewAcc). + case Middleware:postprocess(Request, RawResponse) of + {Response, NewRequest} -> + apply_postprocess_middlewares(NewRequest, Response, Rest); + Response -> + apply_postprocess_middlewares(Request, Response, Rest) + end. -spec handle_ast(API, Opts) -> Result when API :: erf:api(), @@ -258,7 +253,7 @@ handle_ast(API, #{callback := Callback} = Opts) -> end, Parameters ), - RequestBody = maps:get(request_body, Operation), + Request = maps:get(request, Operation), PathParametersAST = erl_syntax:list( lists:map( @@ -282,18 +277,35 @@ handle_ast(API, #{callback := Callback} = Opts) -> ), IsValidRequestAST = is_valid_request( Parameters, - RequestBody + Request ), erl_syntax:clause( [ - erl_syntax:tuple([ - Path, - Method, - erl_syntax:variable('QueryParameters'), - erl_syntax:variable('Headers'), - erl_syntax:variable('Body') - ]) + erl_syntax:match_expr( + erl_syntax:variable('Request'), + erl_syntax:map_expr( + none, + [ + erl_syntax:map_field_exact( + erl_syntax:atom(path), + Path + ), + erl_syntax:map_field_exact( + erl_syntax:atom(method), + Method + ), + erl_syntax:map_field_exact( + erl_syntax:atom(query_parameters), + erl_syntax:variable('QueryParameters') + ), + erl_syntax:map_field_exact( + erl_syntax:atom(body), + erl_syntax:variable('Body') + ) + ] + ) + ) ], none, [ @@ -323,16 +335,28 @@ handle_ast(API, #{callback := Callback} = Opts) -> ) ), [ - erl_syntax:variable('PathParameters'), - erl_syntax:variable('QueryParameters'), - erl_syntax:variable('Headers'), - erl_syntax:variable('Body') + erl_syntax:map_expr( + erl_syntax:variable('Request'), + [ + erl_syntax:map_field_assoc( + erl_syntax:atom('path_parameters'), + erl_syntax:variable( + 'PathParameters' + ) + ) + ] + ) ] ) ] ), erl_syntax:clause( - [erl_syntax:atom(false)], + [ + erl_syntax:tuple([ + erl_syntax:atom(false), + erl_syntax:variable('_Reason') + ]) + ], none, [ erl_syntax:tuple( @@ -354,13 +378,22 @@ handle_ast(API, #{callback := Callback} = Opts) -> NotAllowedMethod = erl_syntax:clause( [ - erl_syntax:tuple([ - Path, - erl_syntax:variable('_Method'), - erl_syntax:variable('_QueryParameters'), - erl_syntax:variable('_Headers'), - erl_syntax:variable('_Body') - ]) + erl_syntax:match_expr( + erl_syntax:variable('Request'), + erl_syntax:map_expr( + none, + [ + erl_syntax:map_field_exact( + erl_syntax:atom(path), + Path + ), + erl_syntax:map_field_exact( + erl_syntax:atom(method), + erl_syntax:variable('_Method') + ) + ] + ) + ) ], none, [ @@ -456,13 +489,35 @@ handle_ast(API, #{callback := Callback} = Opts) -> end, erl_syntax:clause( [ - erl_syntax:tuple([ - PatternPathAST, - erl_syntax:atom(get), - erl_syntax:variable('_QueryParameters'), - erl_syntax:variable('_Headers'), - erl_syntax:variable('_Body') - ]) + erl_syntax:map_expr( + none, + [ + erl_syntax:map_field_exact( + erl_syntax:atom(path), + PatternPathAST + ), + erl_syntax:map_field_exact( + erl_syntax:atom(method), + erl_syntax:atom(get) + ), + erl_syntax:map_field_exact( + erl_syntax:atom(query_parameters), + erl_syntax:variable('_QueryParameters') + ), + erl_syntax:map_field_exact( + erl_syntax:atom(headers), + erl_syntax:variable('_Headers') + ), + erl_syntax:map_field_exact( + erl_syntax:atom(body), + erl_syntax:variable('_Body') + ), + erl_syntax:map_field_exact( + erl_syntax:atom(peer), + erl_syntax:variable('_Peer') + ) + ] + ) ], none, [ @@ -501,7 +556,9 @@ handle_ast(API, #{callback := Callback} = Opts) -> ), NotFoundClause = erl_syntax:clause( - [erl_syntax:variable('_Req')], + [ + erl_syntax:variable('_Req') + ], none, [ erl_syntax:tuple( @@ -518,100 +575,152 @@ handle_ast(API, #{callback := Callback} = Opts) -> RESTClauses ++ StaticClauses ++ [NotFoundClause] ). --spec is_valid_request(Parameters, RequestBody) -> Result when +-spec is_valid_request(Parameters, Request) -> Result when Parameters :: [erf_parser:parameter()], - RequestBody :: erf_parser:ref(), + Request :: erf_parser:request(), Result :: erl_syntax:syntaxTree(). -is_valid_request(RawParameters, RequestBody) -> - Body = - case RequestBody of - undefined -> - erl_syntax:atom(true); - _RequestBody -> - RequestBodyModule = erlang:binary_to_atom(erf_util:to_snake_case(RequestBody)), - erl_syntax:application( - erl_syntax:atom(RequestBodyModule), - erl_syntax:atom(is_valid), - [erl_syntax:variable('Body')] +is_valid_request(RawParameters, Request) -> + RawRequestBody = maps:get(body, Request), + RequestBodyRef = maps:get(ref, RawRequestBody), + RequestBodyModule = + erlang:binary_to_atom(erf_util:to_snake_case(RequestBodyRef)), + RequestBodyIsValid = + erl_syntax:application( + erl_syntax:atom(RequestBodyModule), + erl_syntax:atom(is_valid), + [erl_syntax:variable('Body')] + ), + RequestBody = + case maps:get(required, RawRequestBody) of + true -> + RequestBodyIsValid; + false -> + erl_syntax:infix_expr( + erl_syntax:infix_expr( + erl_syntax:variable('Body'), + erl_syntax:operator('=:='), + erl_syntax:atom(undefined) + ), + erl_syntax:operator('orelse'), + RequestBodyIsValid ) end, - Parameters = lists:filtermap( - fun(Parameter) -> - ParameterModule = erlang:binary_to_atom(maps:get(ref, Parameter)), - ParameterName = maps:get(name, Parameter), - ParameterType = maps:get(type, Parameter), - case ParameterType of - header -> - { - true, - erl_syntax:application( - erl_syntax:atom(ParameterModule), - erl_syntax:atom(is_valid), - [ - erl_syntax:application( - erl_syntax:atom(proplists), - erl_syntax:atom(get_value), - [ - erl_syntax:binary([ - erl_syntax:binary_field( - erl_syntax:string( - erlang:binary_to_list(ParameterName) - ) + FilteredParameters = + lists:filtermap( + fun(Parameter) -> + ParameterModule = erlang:binary_to_atom(maps:get(ref, Parameter)), + ParameterName = maps:get(name, Parameter), + ParameterType = maps:get(type, Parameter), + case ParameterType of + header -> + GetParameter = + erl_syntax:application( + erl_syntax:atom(proplists), + erl_syntax:atom(get_value), + [ + erl_syntax:binary([ + erl_syntax:binary_field( + erl_syntax:string( + erlang:binary_to_list(ParameterName) ) - ]), - erl_syntax:variable('Headers') - ] - ) - ] - ) - }; - cookie -> - %% TODO: implement - false; - path -> - { - true, - erl_syntax:application( - erl_syntax:atom(ParameterModule), - erl_syntax:atom(is_valid), - [ - erl_syntax:variable( - erlang:binary_to_atom( - erf_util:to_pascal_case(ParameterName) - ) + ) + ]), + erl_syntax:variable('Headers') + ] + ), + ParameterRequired = maps:get(required, Parameter), + {true, #{ + module => ParameterModule, + get => GetParameter, + required => ParameterRequired + }}; + cookie -> + %% TODO: implement + false; + path -> + GetParameter = + erl_syntax:variable( + erlang:binary_to_atom( + erf_util:to_pascal_case(ParameterName) ) - ] - ) - }; - query -> - { - true, - erl_syntax:application( - erl_syntax:atom(ParameterModule), - erl_syntax:atom(is_valid), - [ - erl_syntax:application( - erl_syntax:atom(proplists), - erl_syntax:atom(get_value), - [ - erl_syntax:binary([ - erl_syntax:binary_field( - erl_syntax:string( - erlang:binary_to_list(ParameterName) - ) + ), + {true, #{ + module => ParameterModule, + get => GetParameter, + required => true + }}; + query -> + GetParameter = + erl_syntax:application( + erl_syntax:atom(proplists), + erl_syntax:atom(get_value), + [ + erl_syntax:binary([ + erl_syntax:binary_field( + erl_syntax:string( + erlang:binary_to_list(ParameterName) ) - ]), - erl_syntax:variable('QueryParameters') - ] - ) - ] + ) + ]), + erl_syntax:variable('QueryParameters') + ] + ), + ParameterRequired = maps:get(required, Parameter), + {true, #{ + module => ParameterModule, + get => GetParameter, + required => ParameterRequired + }} + end + end, + RawParameters + ), + Parameters = + lists:map( + fun(#{module := ParameterModule, get := GetParameter, required := ParameterRequired}) -> + IsValidParameter = + erl_syntax:application( + erl_syntax:atom(ParameterModule), + erl_syntax:atom(is_valid), + [GetParameter] + ), + OptionalParameter = + erl_syntax:infix_expr( + GetParameter, + erl_syntax:operator('=:='), + erl_syntax:atom(undefined) + ), + case ParameterRequired of + true -> + IsValidParameter; + false -> + erl_syntax:infix_expr( + OptionalParameter, + erl_syntax:operator('orelse'), + IsValidParameter ) - } - end - end, - RawParameters - ), - chain_conditions([Body | Parameters], 'andalso'). + end + end, + FilteredParameters + ), + erl_syntax:application( + erl_syntax:atom('ndto_validation'), + erl_syntax:atom('andalso'), + [ + erl_syntax:list([ + erl_syntax:tuple([ + erl_syntax:fun_expr([ + erl_syntax:clause( + none, + [Condition] + ) + ]), + erl_syntax:list([]) + ]) + || Condition <- [RequestBody | Parameters] + ]) + ] + ). -spec load_binary(ModuleName, Bin) -> Result when ModuleName :: atom(), @@ -635,7 +744,9 @@ load_binary(ModuleName, Bin) -> Response :: erf:response(), Resp :: elli_handler:result(). postprocess( - {_ReqPath, _ReqMethod, _ReqQueryParameters, ReqHeaders, _ReqBody}, + #{ + headers := ReqHeaders + } = _Request, {Status, Headers, {file, File}} ) -> % File responses are handled by elli_sendfile @@ -646,33 +757,66 @@ postprocess( ), {Status, Headers, {file, File, Range}}; postprocess(_Request, {Status, RawHeaders, RawBody}) -> - {Headers, Body} = - case proplists:get_value(<<"content-type">>, RawHeaders, undefined) of - undefined -> - { - [{<<"content-type">>, <<"application/json">>} | RawHeaders], - njson:encode(RawBody) - }; - _Otherwise -> - case RawBody of - undefined -> - {RawHeaders, <<>>}; - _RawBody -> - {RawHeaders, RawBody} - end - end, - {Status, Headers, Body}. + ContentTypeHeader = string:casefold(<<"content-type">>), + case proplists:get_value(ContentTypeHeader, RawHeaders, undefined) of + undefined -> + case njson:encode(RawBody) of + {ok, EncodedBody} -> + Headers = [{ContentTypeHeader, <<"application/json">>} | RawHeaders], + {Status, Headers, EncodedBody}; + {error, _Reason} -> + % TODO: handle error + {500, [{ContentTypeHeader, <<"text/plain">>}], <<"Internal Server Error">>} + end; + _Otherwise -> + {Status, RawHeaders, RawBody} + end. --spec preprocess(Req) -> Request when +-spec preprocess(Req) -> Result when Req :: elli:req(), - Request :: erf:request(). + Result :: {ok, Request} | {error, Reason}, + Request :: erf:request(), + Reason :: term(). preprocess(Req) -> Path = elli_request:path(Req), Method = preprocess_method(elli_request:method(Req)), QueryParameters = elli_request:get_args_decoded(Req), Headers = elli_request:headers(Req), - Body = njson:decode(elli_request:body(Req)), - {Path, Method, QueryParameters, Headers, Body}. + Peer = elli_request:peer(Req), + ContentTypeHeader = string:casefold(<<"content-type">>), + RawBody = + case elli_request:body(Req) of + <<>> -> + undefined; + ElliBody -> + ElliBody + end, + + case proplists:get_value(ContentTypeHeader, Headers, undefined) of + <<"application/json">> -> + case njson:decode(RawBody) of + {ok, Body} -> + {ok, #{ + path => Path, + method => Method, + query_parameters => QueryParameters, + headers => Headers, + body => Body, + peer => Peer + }}; + {error, Reason} -> + {error, {cannot_decode_body, Reason}} + end; + _ContentType -> + {ok, #{ + path => Path, + method => Method, + query_parameters => QueryParameters, + headers => Headers, + body => RawBody, + peer => Peer + }} + end. -spec preprocess_method(ElliMethod) -> Result when ElliMethod :: elli:http_method(), diff --git a/test/erf_SUITE.erl b/test/erf_SUITE.erl index 3e90907..d5db7d9 100644 --- a/test/erf_SUITE.erl +++ b/test/erf_SUITE.erl @@ -69,14 +69,14 @@ foo(_Conf) -> meck:expect( erf_callback, get_foo, - fun(_PathParameters, _QueryParameters, _Headers, _Body) -> + fun(_Request) -> {200, [], <<"bar">>} end ), meck:expect( erf_callback, create_foo, - fun(_PathParameters, _QueryParameters, _Headers, _Body) -> + fun(_Request) -> {201, [], <<"bar">>} end ), @@ -142,15 +142,15 @@ middlewares(_Conf) -> meck:expect( erf_preprocess_middleware, preprocess, - fun({[_Version | Path], Method, QueryParameters, Headers, Body}) -> - {[<<"1">> | Path], Method, QueryParameters, Headers, Body} + fun(#{path := [_Version | Path]} = Request) -> + Request#{path => [<<"1">> | Path]} end ), meck:expect( erf_preprocess_stop_middleware, preprocess, fun - ({_Path, trace, _QueryParameters, _Headers, _Body}) -> + (#{method := trace} = _Request) -> {stop, {403, [], undefined}}; (Req) -> Req @@ -159,7 +159,7 @@ middlewares(_Conf) -> meck:expect( erf_callback, get_foo, - fun([{<<"version">>, <<"1">>}], _QueryParameters, _Headers, _Body) -> + fun(#{path_parameters := [{<<"version">>, <<"1">>}]} = _Request) -> {200, [], <<"bar">>} end ), @@ -337,7 +337,7 @@ reload_conf(_Conf) -> meck:expect( erf_callback, get_foo, - fun(_PathParameters, _QueryParameters, _Headers, _Body) -> + fun(_Request) -> {200, [], <<"bar">>} end ), @@ -345,7 +345,7 @@ reload_conf(_Conf) -> meck:expect( erf_callback_2, get_foo, - fun(_PathParameters, _QueryParameters, _Headers, _Body) -> + fun(_Request) -> {200, [], <<"baz">>} end ), diff --git a/test/erf_oas_3_0_SUITE.erl b/test/erf_parser_oas_3_0_SUITE.erl similarity index 54% rename from test/erf_oas_3_0_SUITE.erl rename to test/erf_parser_oas_3_0_SUITE.erl index 5defcc8..3cecea1 100644 --- a/test/erf_oas_3_0_SUITE.erl +++ b/test/erf_parser_oas_3_0_SUITE.erl @@ -11,7 +11,7 @@ %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. --module(erf_oas_3_0_SUITE). +-module(erf_parser_oas_3_0_SUITE). %%% INCLUDE FILES -include_lib("stdlib/include/assert.hrl"). @@ -72,38 +72,61 @@ petstore(_Conf) -> code:priv_dir(erf) ++ "/oas/3.0/examples/petstore.json" ), - {ok, PetstoreAPI} = erf_parser:parse(PetstoreOAS, erf_oas_3_0), + {ok, PetstoreAPI} = erf_parser:parse(PetstoreOAS, erf_parser_oas_3_0), ?assertMatch( #{ name := <<"Swagger Petstore">>, version := <<"1.0.0">>, schemas := #{ + <<"petstore_error">> := #{ + properties := + #{ + <<"code">> := #{type := integer}, + <<"message">> := #{type := string} + }, + required := [<<"code">>, <<"message">>], + type := object + }, + <<"petstore_pet">> := #{ + properties := + #{ + <<"id">> := #{type := integer}, + <<"name">> := #{type := string}, + <<"tag">> := #{type := string} + }, + required := [<<"id">>, <<"name">>], + type := object + }, + <<"petstore_pets">> := #{ + items := #{ref := <<"petstore_pet">>}, + max_items := 100, + type := array + }, <<"petstore_list_pets_limit">> := #{ - <<"type">> := <<"integer">>, - <<"maximum">> := 100 + type := integer, + maximum := 100 }, - <<"petstore_list_pets_request_body">> := undefined, + <<"petstore_list_pets_request_body">> := true, <<"petstore_list_pets_response_body_200">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Pets">>}] + any_of := [#{ref := <<"petstore_pets">>}] }, <<"petstore_list_pets_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] }, - <<"petstore_create_pets_request_body">> := undefined, - <<"petstore_create_pets_response_body_201">> := undefined, + <<"petstore_create_pets_request_body">> := true, + <<"petstore_create_pets_response_body_201">> := true, <<"petstore_create_pets_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] }, <<"petstore_show_pet_by_id_pet_id">> := #{ - <<"type">> := <<"string">>, - <<"nullable">> := false + type := string }, - <<"petstore_show_pet_by_id_request_body">> := undefined, + <<"petstore_show_pet_by_id_request_body">> := true, <<"petstore_show_pet_by_id_response_body_200">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Pet">>}] + any_of := [#{ref := <<"petstore_pet">>}] }, <<"petstore_show_pet_by_id_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] } }, endpoints := [ @@ -118,23 +141,54 @@ petstore(_Conf) -> #{ ref := <<"petstore_list_pets_limit">>, name := <<"limit">>, - type := query + type := query, + required := false } ], - request_body := <<"petstore_list_pets_request_body">>, + request := #{ + body := #{ + ref := <<"petstore_list_pets_request_body">>, + required := false + } + }, responses := #{ - 200 := <<"petstore_list_pets_response_body_200">>, - '*' := <<"petstore_list_pets_response_body_default">> + 200 := #{ + body := #{ + ref := <<"petstore_list_pets_response_body_200">>, + required := false + } + }, + '*' := #{ + body := #{ + ref := <<"petstore_list_pets_response_body_default">>, + required := false + } + } } }, #{ id := <<"create_pets">>, method := post, parameters := [], - request_body := <<"petstore_create_pets_request_body">>, + request := #{ + body := #{ + ref := <<"petstore_create_pets_request_body">>, + required := false + } + }, responses := #{ - 201 := <<"petstore_create_pets_response_body_201">>, - '*' := <<"petstore_create_pets_response_body_default">> + 201 := #{ + body := #{ + ref := <<"petstore_create_pets_response_body_201">>, + required := false + } + }, + '*' := #{ + body := #{ + ref := <<"petstore_create_pets_response_body_default">>, + required := false + } + } } } ] @@ -153,10 +207,25 @@ petstore(_Conf) -> type := path } ], - request_body := <<"petstore_show_pet_by_id_request_body">>, + request := #{ + body := #{ + ref := <<"petstore_show_pet_by_id_request_body">>, + required := false + } + }, responses := #{ - 200 := <<"petstore_show_pet_by_id_response_body_200">>, - '*' := <<"petstore_show_pet_by_id_response_body_default">> + 200 := #{ + body := #{ + ref := <<"petstore_show_pet_by_id_response_body_200">>, + required := false + } + }, + '*' := #{ + body := #{ + ref := <<"petstore_show_pet_by_id_response_body_default">>, + required := false + } + } } } ] @@ -173,26 +242,25 @@ with_refs(_Conf) -> code:lib_dir(erf, test) ++ "/fixtures/with_refs_oas_3_0_spec.json" ), - {ok, WithRefsAPI} = erf_parser:parse(WithRefsOAS, erf_oas_3_0), + {ok, WithRefsAPI} = erf_parser:parse(WithRefsOAS, erf_parser_oas_3_0), ?assertMatch( #{ name := <<"With refs">>, version := <<"1.0.0">>, schemas := #{ <<"common_oas_3_0_spec_enabled">> := #{ - <<"type">> := <<"boolean">> + type := boolean }, <<"common_oas_3_0_spec_version">> := #{ - <<"type">> := <<"string">>, - <<"pattern">> := <<"^[0-9]+$">>, - <<"nullable">> := false - }, - <<"with_refs_oas_3_0_spec_delete_foo_request_body">> := undefined, - <<"with_refs_oas_3_0_spec_delete_foo_response_body_204">> := #{ - <<"$ref">> := <<"common_oas_3_0_spec_NoContent">> + type := string, + pattern := <<"^[0-9]+$">> }, + <<"with_refs_oas_3_0_spec_delete_foo_request_body">> := true, + <<"with_refs_oas_3_0_spec_delete_foo_response_body_204">> := true, <<"with_refs_oas_3_0_spec_delete_foo_response_body_404">> := #{ - <<"$ref">> := <<"common_oas_3_0_spec_NotFound">> + any_of := [ + #{ref := <<"common_oas_3_0_spec_error">>} + ] } } }, @@ -206,8 +274,9 @@ invalid(_Conf) -> code:lib_dir(erf, test) ++ "/fixtures/invalid_oas_3_0_spec.json" ), - {error, {invalid_spec, <<"Invalid OpenAPI Specification 3.0">>}} = erf_parser:parse( - Invalid, erf_oas_3_0 + ?assertMatch( + {error, {invalid_oas_3_0_spec, _Reason}}, + erf_parser:parse(Invalid, erf_parser_oas_3_0) ), ok. diff --git a/test/erf_router_SUITE.erl b/test/erf_router_SUITE.erl index af6670e..840f1ac 100644 --- a/test/erf_router_SUITE.erl +++ b/test/erf_router_SUITE.erl @@ -71,34 +71,23 @@ foo(_Conf) -> version => <<"1.0.0">>, schemas => #{ <<"version_foo_version">> => #{ - <<"type">> => <<"integer">>, - <<"nullable">> => false + type => integer }, - <<"get_foo_request_body">> => undefined, - <<"get_foo_response_body">> => #{ - <<"anyOf">> => [ - #{ - <<"anyOf">> => [ - #{ - <<"description">> => <<"A foo">>, - <<"enum">> => [<<"bar">>, <<"baz">>], - <<"type">> => <<"string">> - } - ] - }, + <<"get_foo_request_body">> => true, + <<"get_foo_response_body_200">> => #{ + any_of => [#{enum => [<<"bar">>, <<"baz">>]}] + }, + <<"get_foo_response_body_default">> => #{ + any_of => [ #{ - <<"anyOf">> => [ - #{ - <<"properties">> => #{ - <<"description">> => #{ - <<"description">> => - <<"An English human-friendly description of the error.">>, - <<"type">> => <<"string">> - } - }, - <<"type">> => <<"object">> + type => object, + properties => #{ + description => #{ + description => + <<"An English human-friendly description of the error.">>, + type => string } - ] + } } ] } @@ -110,7 +99,8 @@ foo(_Conf) -> #{ ref => <<"version_foo_version">>, name => <<"version">>, - type => path + type => path, + required => true } ], operations => [ @@ -118,8 +108,24 @@ foo(_Conf) -> id => <<"get_foo">>, method => get, parameters => [], - request_body => undefined, - response_body => <<"get_foo_response_body">> + request => #{ + body => #{ + ref => <<"get_foo_request_body">>, + required => false + } + }, + responses => #{ + 200 => #{ + body => #{ + ref => <<"get_foo_response_body_200">> + } + }, + '*' => #{ + body => #{ + ref => <<"get_foo_response_body_default">> + } + } + } } ] } @@ -141,32 +147,34 @@ foo(_Conf) -> ] ), - meck:expect(foo_callback, get_foo, fun(_PathParameters, _QueryParameters, _Headers, _Body) -> + meck:expect(foo_callback, get_foo, fun(_Request) -> {200, [], <<"bar">>} end), meck:expect(version_foo_version, is_valid, fun(_Value) -> true end), meck:expect(get_foo_request_body, is_valid, fun(_Value) -> true end), - Req = { - Path = [<<"1">>, <<"foo">>], - _Method = get, - QueryParameters = [], - Headers = [], - Body = <<>> + Req = #{ + path => [<<"1">>, <<"foo">>], + method => get, + query_parameters => [], + headers => [], + body => <<>>, + peer => <<"localhost">> }, ?assertEqual({200, [], <<"bar">>}, Mod:handle(Req)), - meck:expect(version_foo_version, is_valid, fun(_Value) -> false end), + meck:expect(get_foo_request_body, is_valid, fun(_Value) -> {false, reason} end), ?assertEqual({400, [], undefined}, Mod:handle(Req)), - NotAllowedReq = { - Path, - _NotAllowedMethod = post, - QueryParameters, - Headers, - Body + NotAllowedReq = #{ + path => [<<"1">>, <<"foo">>], + method => post, + query_parameters => [], + headers => [], + body => <<>>, + peer => <<"localhost">> }, ?assertEqual({405, [], undefined}, Mod:handle(NotAllowedReq)), diff --git a/test/fixtures/common_oas_3_0_spec.json b/test/fixtures/common_oas_3_0_spec.json index 9eabce2..7656b26 100644 --- a/test/fixtures/common_oas_3_0_spec.json +++ b/test/fixtures/common_oas_3_0_spec.json @@ -56,7 +56,7 @@ } }, "requestBodies": { - "Foo": { + "PostFoo": { "content": { "application/json": { "schema": { diff --git a/test/fixtures/with_refs_oas_3_0_spec.json b/test/fixtures/with_refs_oas_3_0_spec.json index 6598234..d75d010 100644 --- a/test/fixtures/with_refs_oas_3_0_spec.json +++ b/test/fixtures/with_refs_oas_3_0_spec.json @@ -38,7 +38,7 @@ "summary": "Create foo", "operationId": "createFoo", "requestBody": { - "$ref": "common_oas_3_0_spec.json#/components/requestBodies/Foo" + "$ref": "common_oas_3_0_spec.json#/components/requestBodies/PostFoo" }, "responses": { "201": {