├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE.txt ├── Makefile ├── README.md ├── rebar.config ├── rebar3 ├── src ├── openapi_client.erl ├── openapi_collection.erl ├── openapi_handler.app.src ├── openapi_handler.erl ├── openapi_handler_legacy.erl ├── openapi_json.erl └── openapi_schema.erl └── test ├── done_req.yaml ├── fake_petstore.erl ├── flussonic-230127.json ├── multiple-upload.yaml ├── openapi_collection_SUITE.erl ├── openapi_handler_SUITE.erl ├── openapi_schema_SUITE.erl ├── redocly-big-openapi.json ├── redocly-petstore.yaml ├── test_schema.yaml └── test_schema_res.erl /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /rebar.lock 3 | .idea/ 4 | *.iml 5 | *.swp 6 | test-logs/ 7 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - src 3 | - test 4 | 5 | prepare-src: 6 | stage: src 7 | script: 8 | - docker build -t openapi_handler . 9 | 10 | run-test: 11 | stage: test 12 | needs: 13 | - prepare-src 14 | script: 15 | - mkdir -p test-logs 16 | - docker run --rm -v ${PWD}/test-logs:/openapi_handler/test-logs openapi_handler make ci-test 17 | artifacts: 18 | reports: 19 | junit: test-logs/*/junit_report.xml 20 | when: always 21 | name: "${CI_JOB_STAGE}_${BRANCH_NAME}-test" 22 | expire_in: 1 week 23 | paths: 24 | - test-logs 25 | 26 | 27 | run-test-legacy: 28 | stage: test 29 | needs: 30 | - prepare-src 31 | script: 32 | - mkdir -p test-logs-legacy 33 | - docker run --rm -v ${PWD}/test-logs-legacy:/openapi_handler/test-logs openapi_handler make ci-test-legacy 34 | artifacts: 35 | reports: 36 | junit: test-logs-legacy/*/junit_report.xml 37 | when: always 38 | name: "${CI_JOB_STAGE}_${BRANCH_NAME}-test-legacy" 39 | expire_in: 1 week 40 | paths: 41 | - test-logs-legacy 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 AS builder 2 | 3 | RUN apt update && \ 4 | DEBIAN_FRONTEND=noninteractive apt install -y wget gnupg2 geoipupdate git make 5 | 6 | RUN wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb && \ 7 | dpkg -i erlang-solutions_2.0_all.deb && \ 8 | rm -f erlang-solutions_2.0_all.deb 9 | 10 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests esl-erlang=1:24.2.1-1 && apt-get clean 11 | 12 | 13 | WORKDIR /openapi_handler 14 | COPY Makefile rebar* ./ 15 | COPY src ./src 16 | COPY test ./test 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-2023 Erlyvideo, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all ci-test ci-test-legacy clean 2 | 3 | all: 4 | ./rebar3 compile 5 | 6 | ci-test: 7 | ./rebar3 as dev ct --logdir test-logs --readable true 8 | ci-test-legacy: 9 | ./rebar3 as dev_legacy ct --logdir test-logs --readable true 10 | 11 | clean: 12 | rm -rf _build rebar.lock 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OpenAPI 3.1 handler for Cowboy (Erlang/OTP) 2 | ================= 3 | 4 | # What is it? 5 | `openapi_handler` is a library translating [OpenAPI](https://spec.openapis.org/oas/v3.1.0) requests 6 | into native Erlang function calls. It takes schema (compiled single file JSON or YAML), extracts, validates 7 | and converts request parameters to Erlang types. 8 | 9 | # How to use 10 | 11 | ## Load schema 12 | First, you need to compile cowboy routes for your schema. 13 | ```erlang 14 | PetstoreRoutes = openapi_handler:routes(#{ 15 | schema => PetstorePath, % path/to/your/schema.{json,yaml} 16 | prefix => <<"/api/prefix">>, % HTTP path prefix for your API 17 | name => petstore_server_api, % API identifier, must be unique 18 | module => petstore_impl, % A module with functions to call 19 | schema_opts => #{validators => #{}} % (optional) schema processing options (see below) 20 | }), 21 | 22 | cowboy:start_clear(petstore_api_server, [{port, 8000}], 23 | #{env => #{dispatch => cowboy_router:compile([{'_', PetstoreRoutes}])}}), 24 | ``` 25 | 26 | ## Implement callback module 27 | Your callback module needs authorize function: 28 | ```erlang 29 | authorize(#{authorization := AuthorizationHeader, operationId := OperationId, args := OperationParams, 30 | ip := _, name := ApiName, accept := _}) -> 31 | % Any map for success (will appear as auth_context), 32 | % {error, _} on bad auth 33 | #{user_id => 42}. 34 | ``` 35 | 36 | Then, for each `operationId` you want to handle, create a function with that name in the callback module. 37 | For example, assume part of your schema is: 38 | ```yaml 39 | paths: 40 | '/user/{username}': 41 | get: 42 | operationId: getUserByName 43 | parameters: 44 | - name: username 45 | in: path 46 | required: true 47 | schema: 48 | type: string 49 | ``` 50 | 51 | When a client performs `GET /api/prefix/user/Jack`, the corresponding function is called 52 | with a map of parameters as a single argument: 53 | ```erlang 54 | petstore_impl:getUserByName(#{username => <<"Jack">>} = _OperationArgs) -> 55 | #{id => 3418, email => <<"jack@example.com">>}. 56 | ``` 57 | 58 | `OperationArgs` is OperationParams with some added fields: 59 | * `auth_context` -- a term returned by `authorize/1` callback 60 | 61 | 62 | Valid callback return values are: 63 | * `{json, StatusCode, #{} = RespObject}` -- the server will respond with given status and RespObject encoded as JSON in body 64 | * `{json, StatusCode, undefined}` -- the server will respond with given status and no body 65 | * `{error, badrequest | enoent | unavailable}` -- shortcuts for statuses 400, 404, 503 accordingly with minimal status description in body 66 | * `ok` -- status 204 with no body 67 | * `#{} = RespObject` -- shortcut to `{json, 200, RespObject}` 68 | * `<<_/binary>>` -- status 200 and exactly this body 69 | * `{raw, Code, #{} = Headers, <<_/binary>> = Body}` -- the server will respond with given status Code, Headers and Body, Headers MUST include 'Content-Type' header 70 | 71 | ## Make sure return values conform to your schema 72 | If your schema describes `responses`, the callback return value is validated against response schema. 73 | E.g., if you return an atom or a binary where schema requires integer, it will be an error, and response code will be 500. 74 | Also see validation quirks below. 75 | 76 | # Type conversions 77 | | Schema | Erlang | 78 | | ---- | ---- | 79 | | string | binary | 80 | | integer| integer| 81 | | number | number | 82 | | enum | atom | 83 | | oneOf(const) | atom | 84 | | boolean | boolean| 85 | 86 | # Schema processing options 87 | You can customize the way objects are processed. 88 | Available options: 89 | * `#{extra_obj_key => drop}` -- the original object may contain keys not described by schema, just ignore them instead of raising an error 90 | * `#{required_obj_keys => error}` -- raise an error when original object misses some required keys 91 | * `#{validators => #{Format :: atom() => Validator}}` 92 | `Validator :: fun(Value) -> {ok, ConvertedValue} | {error, #{}}` 93 | If you use `format` in your schema, this option allows you to specify how these formats are processed. 94 | Return value may contain converted input (e.g. when you want to be flexible in accepted time formats). 95 | 96 | # Validation quirks 97 | ## `null` vs non-`nullable` 98 | `openapi_handler` allows `undefined` value for non-`nullable` fields and drops these fields. 99 | This behaviour allows writing simple code like `Response = #{key1 => key1_getter(State)}` without 100 | further complex fillering of each key/value pair. 101 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | % No deps by default to prevent conflicts 2 | {deps, [ 3 | ]}. 4 | 5 | {profiles, [ 6 | {dev, [ 7 | {deps, [ 8 | jsx, 9 | yamerl, 10 | {cowboy, "2.9.0"}, 11 | {lhttpc, {git, "https://github.com/erlyvideo/lhttpc.git", {branch, "master"}}}, 12 | redbug 13 | ]}, 14 | {ct_opts, [{ct_hooks, [cth_surefire]}]} 15 | ]}, 16 | {dev_legacy, [ 17 | {deps, [ 18 | jsx, 19 | yamerl, 20 | {cowboy, "1.0.1"}, 21 | {lhttpc, {git, "https://github.com/erlyvideo/lhttpc.git", {branch, "master"}}}, 22 | redbug 23 | ]}, 24 | {erl_opts, [{d,legacy}]}, 25 | {ct_opts, [{ct_hooks, [cth_surefire]}]} 26 | ]} 27 | ]}. 28 | 29 | {overrides, [ 30 | {del, redbug, [{erl_opts, [warnings_as_errors]}, {plugins, [rebar3_hex]}]} 31 | ]}. 32 | -------------------------------------------------------------------------------- /rebar3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flussonic/openapi_handler/9466c5065d1316492534bc177e45dc8cf584bd7f/rebar3 -------------------------------------------------------------------------------- /src/openapi_client.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_client). 2 | -include_lib("kernel/include/logger.hrl"). 3 | 4 | 5 | -export([load/1, call/3, call/4, store/2]). 6 | 7 | 8 | load(#{schema_url := Path0} = State) -> 9 | Path = case iolist_to_binary(Path0) of 10 | <<"file://",U/binary>> -> U; 11 | U -> U 12 | end, 13 | Schema = openapi_handler:read_schema(Path), 14 | #{servers := [#{url := BaseURL}|_]} = Schema, 15 | BaseURI = uri_string:parse(BaseURL), 16 | BasePath = maps:get(path,BaseURI), 17 | 18 | case State of 19 | #{url := URL0} -> 20 | case uri_string:parse(iolist_to_binary(URL0)) of 21 | #{path := <<>>} = URI -> 22 | URI1 = URI#{path => BasePath}, 23 | (maps:remove(url,State))#{schema => Schema, uri => URI1}; 24 | #{} = URI -> 25 | (maps:remove(url,State))#{schema => Schema, uri => URI} 26 | end; 27 | #{} -> 28 | State#{schema => Schema, uri => BaseURI} 29 | end. 30 | 31 | 32 | 33 | store(Name, #{schema := _} = SchemaState) -> 34 | persistent_term:put({openapi,Name}, SchemaState#{schema_name => Name}). 35 | 36 | call(NameOrState, OperationId, Args) when is_atom(NameOrState); is_map(NameOrState) -> 37 | call(NameOrState, OperationId, Args, _Opts = []). 38 | 39 | 40 | call(Name, OperationId, Args, Opts) when is_atom(Name) -> 41 | try persistent_term:get({openapi,Name}) of 42 | Api -> call(Api, OperationId, Args, Opts) 43 | catch 44 | _:_:_ -> {error, not_loaded} 45 | end; 46 | 47 | call(#{schema := Schema, uri := URI} = State, OperationId, Args0, Opts) when is_list(Opts) -> 48 | Args1 = maps:merge(maps:get(default_args, State, #{}), Args0), 49 | % Skip useless flags from openapi_handler 50 | Args = maps:without([agent_ip,auth_context,raw_qs,req,collection_type,schema_name], Args1), 51 | case search_operation(OperationId, Schema) of 52 | undefined -> 53 | {error, no_such_operation}; 54 | #{path := Path, method := Method, responses := Responses} = Op-> 55 | BasePath = maps:get(path, URI), 56 | Path1 = filename:join(BasePath, string:trim(Path,both,"/")), 57 | URI1 = URI#{path => Path1}, 58 | {RequestURI, RequestHeaders, RequestBody} = substitute_args(Op, URI1, Args), 59 | RequestURL = uri_string:recompose(RequestURI), 60 | ?LOG_DEBUG("> ~s ~s\n~p\n~s\n", [Method, RequestURL, RequestHeaders, case Args of 61 | #{raw_body := _} -> <<"raw_file_upload">>; 62 | _ -> RequestBody 63 | end]), 64 | Timeout = proplists:get_value(timeout, Opts, 50000), 65 | Result = case lhttpc:request(RequestURL, Method, RequestHeaders, RequestBody, Timeout) of 66 | {ok, {{Code0,_},ResponseHeaders0,Bin0}} -> 67 | {ok, Code0, [{string:to_lower(K),V} || {K,V} <- ResponseHeaders0], Bin0}; 68 | {error, E0} -> 69 | {error, E0} 70 | end, 71 | 72 | case Result of 73 | {ok, Code,ResponseHeaders,Bin} when is_map_key(Code, Responses) -> 74 | ?LOG_DEBUG("< ~p\n~p\n~s", [Code, ResponseHeaders,Bin]), 75 | check_cors_presence(ResponseHeaders), 76 | ResponseContentType = case proplists:get_value("content-type", ResponseHeaders) of 77 | undefined -> undefined; 78 | CT -> list_to_atom(CT) 79 | end, 80 | ResponseSpec = maps:get(Code, Responses), 81 | ContentMap = maps:get(content, ResponseSpec, #{}), 82 | Response1 = case maps:get(ResponseContentType, ContentMap, undefined) of 83 | #{schema := ResponseSchema} when ResponseContentType == 'application/json' -> 84 | JSON = openapi_json:decode(Bin), 85 | Response = openapi_schema:process(JSON, #{schema => ResponseSchema, whole_schema => Schema}), 86 | Response; 87 | #{} when ResponseContentType == 'text/plain' orelse ResponseContentType == 'text/csv' -> 88 | Bin; 89 | undefined when Code == 204 -> 90 | ok; 91 | _ -> 92 | Bin 93 | end, 94 | case Code of 95 | 200 -> Response1; 96 | 204 -> Response1; 97 | 404 -> {error, enoent}; 98 | _ -> {error, {Code, Response1}} 99 | end; 100 | {ok, 404,ResponseHeaders,Body} -> 101 | ?LOG_INFO("~s ~s -> 404 ~p", [Method, uri_string:recompose(RequestURI), Body]), 102 | check_cors_presence(ResponseHeaders), 103 | {error, enoent}; 104 | {ok, 503,ResponseHeaders,Body} -> 105 | ?LOG_INFO("~s ~s -> 503 ~p", [Method, uri_string:recompose(RequestURI), Body]), 106 | check_cors_presence(ResponseHeaders), 107 | {error, unavailable}; 108 | {ok, 403,ResponseHeaders,_} -> 109 | ?LOG_INFO("~s ~s -> 403", [Method, uri_string:recompose(RequestURI)]), 110 | check_cors_presence(ResponseHeaders), 111 | {error, denied}; 112 | {ok, Code,ResponseHeaders,Bin} -> 113 | check_cors_presence(ResponseHeaders), 114 | Response = case proplists:get_value("content-type", ResponseHeaders) of 115 | "application/json" -> try openapi_json:decode_with_atoms(Bin) 116 | catch _:_ -> Bin end; 117 | _ -> Bin 118 | end, 119 | {error, {Code,Response}}; 120 | {error, E} -> 121 | {error, E} 122 | end 123 | % io:format("call ~p with ~p\n", [Op, Args]) 124 | end; 125 | 126 | 127 | call(#{} = State, OperationId, Args, Opts) -> 128 | case load(State) of 129 | #{} = State1 -> call(State1, OperationId, Args, Opts); 130 | {error, E} -> {error, E} 131 | end. 132 | 133 | 134 | 135 | 136 | search_operation(OperationId, #{paths := Paths}) -> 137 | search_operation_in_paths(atom_to_binary(OperationId,latin1), maps:to_list(Paths)). 138 | 139 | search_operation_in_paths(_OperationId, []) -> 140 | undefined; 141 | 142 | search_operation_in_paths(OperationId, [{Path,Methods}|Paths]) -> 143 | Parameters = maps:get(parameters, Methods, []), 144 | case search_operation_in_methods(OperationId, maps:to_list(maps:remove(parameters,Methods))) of 145 | undefined -> 146 | search_operation_in_paths(OperationId, Paths); 147 | #{} = Op -> 148 | Parameters1 = maps:get(parameters, Op, []), 149 | OpResponses = [{K,V} || {K,V} <- maps:to_list(maps:get(responses, Op, #{})), K =/= default], 150 | Responses = maps:from_list([{list_to_integer(atom_to_list(K)),V} || {K,V} <- OpResponses]), 151 | Op#{parameters => Parameters ++ Parameters1, path => atom_to_binary(Path,latin1), responses => Responses} 152 | end. 153 | 154 | 155 | search_operation_in_methods(OperationId, [{Method,#{operationId := OpId} = Op}|_]) when OperationId == OpId -> 156 | Op#{method => Method}; 157 | 158 | search_operation_in_methods(OperationId, [_|Methods]) -> 159 | search_operation_in_methods(OperationId, Methods); 160 | 161 | search_operation_in_methods(_, []) -> 162 | undefined. 163 | 164 | 165 | 166 | 167 | 168 | substitute_args(#{parameters := Parameters}, #{} = URI, #{} = Args) -> 169 | Query = uri_string:dissect_query(iolist_to_binary(maps:get(query,URI,<<>>))), 170 | substitute_args2(Parameters, URI, Query, [], <<>>, maps:to_list(Args)). 171 | 172 | 173 | substitute_args2(_, URI, Query, Headers, Body, []) -> 174 | {URI#{query => uri_string:compose_query(Query)}, Headers, Body}; 175 | 176 | substitute_args2(Parameters, URI, Query, Headers, _, [{json_body,Value}|Args]) -> 177 | substitute_args2(Parameters, URI, Query, Headers++[{"Content-Type","application/json"}], openapi_json:encode(Value), Args); 178 | 179 | substitute_args2(Parameters, URI, Query, Headers, _, [{raw_body,Value}|Args]) -> 180 | substitute_args2(Parameters, URI, Query, Headers++[{"Content-Type","text/plain"}], Value, Args); 181 | 182 | substitute_args2(Parameters, URI, Query, Headers, Body, [{originator,Value}|Args]) -> 183 | substitute_args2(Parameters, URI, Query, Headers++[{"X-Originator",Value}], Body, Args); 184 | 185 | substitute_args2(Parameters, URI, Query, Headers, Body, [{authorization,Value}|Args]) -> 186 | substitute_args2(Parameters, URI, Query, Headers++[{"Authorization",Value}], Body, Args); 187 | 188 | substitute_args2(Parameters, URI, Query, Headers, Body, [{accept,Value}|Args]) -> 189 | substitute_args2(Parameters, URI, Query, Headers++[{"Accept",Value}], Body, Args); 190 | 191 | substitute_args2(Parameters, URI, Query, Headers, _, [{files,Files}|Args]) -> 192 | Headers1 = Headers ++ [{"Content-Type", "multipart/form-data; boundary=abcde12345"}], 193 | Body = iolist_to_binary([ 194 | lists:map(fun({Name, Bin}) -> [ 195 | "--abcde12345\r\n", 196 | "Content-Disposition: form-data; name=\"file\"; filename=\"",Name,"\"\r\n", 197 | "\r\n", Bin, "\r\n" 198 | ] end, Files), 199 | "--abcde12345--\r\n" 200 | ]), 201 | substitute_args2(Parameters, URI, Query, Headers1, Body, Args); 202 | 203 | substitute_args2(Parameters, URI, Query, Headers, Body, [{Key_,Value}|Args]) -> 204 | Key = atom_to_binary(Key_,latin1), 205 | case lists_mapfind(Key, name, Parameters) of 206 | false -> 207 | substitute_args2(Parameters, URI, lists:keystore(Key,1,Query,{Key,to_b(Value)}), Headers, Body, Args); 208 | #{in := <<"path">>} -> 209 | Path1 = binary:replace(maps:get(path,URI),<<"{",Key/binary,"}">>, cow_qs:urlencode(to_b(Value))), 210 | substitute_args2(Parameters, URI#{path => Path1}, Query, Headers, Body, Args); 211 | #{in := <<"query">>} = Spec -> 212 | Style = maps:get(style, Spec, undefined), 213 | BinValue = case Value of 214 | [_|_] when Style == <<"form">> -> iolist_to_binary(lists:join(<<",">>, [to_b(I) || I <- Value])); 215 | _ -> to_b(Value) 216 | end, 217 | substitute_args2(Parameters, URI, lists:keystore(Key,1,Query,{Key,BinValue}), Headers, Body, Args); 218 | #{in := <<"header">>} -> 219 | substitute_args2(Parameters, URI, Query, Headers++[{Key,to_b(Value)}], Body, Args); 220 | _ -> 221 | substitute_args2(Parameters, URI, Query, Headers, Body, Args) 222 | end. 223 | 224 | 225 | to_b(I) when is_integer(I) -> integer_to_binary(I); 226 | to_b(A) when is_atom(A) -> atom_to_binary(A,latin1); 227 | to_b(X) -> iolist_to_binary(X). 228 | 229 | 230 | check_cors_presence(Headers) -> 231 | case proplists:get_value("access-control-allow-origin", Headers) of 232 | "*" -> 233 | true; 234 | _Absent -> 235 | error([no_cors_headers,Headers]) 236 | end. 237 | 238 | 239 | 240 | lists_mapfind(Value, Key, List) -> 241 | case [V || #{Key := Val} = V <- List, Val == Value] of 242 | [V1|_] -> V1; 243 | _ -> false 244 | end. 245 | -------------------------------------------------------------------------------- /src/openapi_collection.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_collection). 2 | -include_lib("kernel/include/logger.hrl"). 3 | 4 | -export([parse_qs/2]). 5 | -export([qs/1]). 6 | -export([list/2]). 7 | -export([calculate_cursors/5, unwrap_kv/1]). 8 | -export([filter_collection/2]). 9 | 10 | 11 | 12 | add_defaults(Query0) -> 13 | Sort1 = maps:get(sort, Query0, []), 14 | ImplicitSort = {['$position'],asc}, 15 | Sort2 = case Sort1 of 16 | [] -> [ImplicitSort]; 17 | _ -> Sort1 18 | end, 19 | Query0#{sort => Sort2}. 20 | 21 | 22 | 23 | parse_qs(Qs, #{collection_type := TypeName, schema_name := Name} = Opts) -> 24 | Query1 = parse_qs_api(cow_qs:parse_qs(Qs), maps:with([limit],Opts), Opts), 25 | 26 | Query2 = case Query1 of 27 | #{filter := Filter0} -> 28 | case parse_filter(Filter0, Name, TypeName) of 29 | {error, E} -> 30 | {error, E}; 31 | Filter -> 32 | Query1#{filter => Filter} 33 | end; 34 | #{} -> 35 | Query1 36 | end, 37 | Query2. 38 | 39 | 40 | 41 | parse_filter(#{} = Filter0, Name, TypeName) -> 42 | Opts = #{ 43 | type => TypeName, 44 | name => Name, 45 | query => true, 46 | auto_convert => true 47 | }, 48 | case openapi_schema:process(Filter0, Opts) of 49 | #{} = Filter1 -> Filter1; 50 | {error, E} -> {error, E} 51 | end. 52 | 53 | 54 | 55 | 56 | 57 | 58 | parse_qs_api([{<<"cursor">>, Cursor}|Qs], Query, Opts) -> 59 | CursorQuery = parse_qs(base64:decode(Cursor), Opts), 60 | parse_qs_api(Qs, Query#{cursor => CursorQuery}, Opts); 61 | 62 | parse_qs_api([{<<"sort">>, Sort}|Qs], Query, Opts) -> 63 | Sortlist = [case Arg of 64 | <<"-",Arg0/binary>> -> {binary:split(Arg0,<<".">>,[global]), desc}; 65 | _ -> {binary:split(Arg,<<".">>,[global]), asc} 66 | end || Arg <- binary:split(Sort,<<",">>,[global])], 67 | parse_qs_api(Qs, Query#{sort => Sortlist}, Opts); 68 | 69 | parse_qs_api([{<<"limit">>,LimitBin}|Qs], Query, Opts) -> 70 | case string:to_integer(LimitBin) of 71 | {Limit,<<>>} -> 72 | parse_qs_api(Qs, Query#{limit => Limit}, Opts); 73 | _ -> 74 | parse_qs_api(Qs, Query#{parse_error => broken_limit}, Opts) 75 | end; 76 | 77 | parse_qs_api([{<<"select">>,Select}|Qs], Query, Opts) -> 78 | SelectList = [binary:split(Arg,<<".">>,[global]) || Arg <- binary:split(Select,<<",">>,[global])], 79 | Wrap = fun 80 | Wrap([], _) -> 81 | true; 82 | Wrap([Key|List], Map) -> 83 | case Map of 84 | #{Key := #{} = V} -> Map#{Key => Wrap(List, V)}; 85 | _ -> Map#{Key => Wrap(List, #{})} 86 | end 87 | end, 88 | SelectMap = lists:foldl(Wrap, #{}, SelectList), 89 | parse_qs_api(Qs, Query#{select => SelectMap}, Opts); 90 | 91 | parse_qs_api([{<<"$reversed">>,<<"true">>}|Qs], Query, Opts) -> 92 | parse_qs_api(Qs, Query#{reversed => true}, Opts); 93 | 94 | 95 | parse_qs_api([{Key,Value}|Qs], Query, Opts) -> 96 | L1 = size(Key) - 3, 97 | L2 = size(Key) - 4, 98 | L3 = size(Key) - 5, 99 | L4 = size(Key) - 7, 100 | Filter = maps:get(filter, Query, #{}), 101 | {Key1, Value1} = case Key of 102 | <> -> {Key0, #{'$ne' => Value}}; 103 | <> -> {Key0, #{'$gt' => Value}}; 104 | <> -> {Key0, #{'$lt' => Value}}; 105 | <> when Value == <<"null">> -> {Key0, null}; 106 | <> when Value == <<"null">> -> {Key0, not_null}; 107 | <> -> {Key0, #{'$gte' => Value}}; 108 | <> -> {Key0, #{'$lte' => Value}}; 109 | <> -> {Key0, #{'$like' => Value}}; 110 | _ when Value == true -> {Key, [<<"true">>]}; 111 | _ -> {Key, binary:split(Value,<<",">>,[global])} 112 | end, 113 | Key2 = binary:split(Key1, <<".">>, [global]), 114 | Filter1 = deep_set(Key2, Value1, Filter), 115 | parse_qs_api(Qs, Query#{filter => Filter1}, Opts); 116 | 117 | parse_qs_api([], Query, _Opts) -> 118 | Query. 119 | 120 | 121 | deep_set([K], V, Map) -> 122 | case Map of 123 | #{K := #{} = V1} when is_map(V) -> Map#{K => maps:merge(V1,V)}; 124 | _ -> Map#{K => V} 125 | end; 126 | 127 | deep_set([K|List], V, Map) -> 128 | case maps:get(K, Map, #{}) of 129 | #{} = Map1 -> 130 | Map#{K => deep_set(List, V, Map1)}; 131 | _ -> 132 | Map 133 | end. 134 | 135 | 136 | 137 | qs(#{} = Query) -> 138 | QsVals = encode_qs_api2(maps:to_list(Query)), 139 | Text = cow_qs:qs(QsVals), 140 | Text. 141 | 142 | encode_qs_api2([{filter,Filter}|Query]) -> 143 | Enc = fun 144 | Enc({Key,V}) when is_atom(Key) -> Enc({atom_to_binary(Key,latin1),V}); 145 | Enc({Key,#{'$ne' := V}}) -> [{<>, V}]; 146 | Enc({Key,#{'$gt' := V}}) -> [{<>, V}]; 147 | Enc({Key,#{'$lt' := V}}) -> [{<>, V}]; 148 | Enc({Key,#{'$gte' := V}}) -> [{<>, V}]; 149 | Enc({Key,#{'$lte' := V}}) -> [{<>, V}]; 150 | Enc({Key,#{'$like' := V}}) -> [{<>, V}]; 151 | Enc({Key,null}) -> [{<>, <<"null">>}]; 152 | Enc({Key,not_null}) -> [{<>, <<"null">>}]; 153 | Enc({Key,V}) when is_list(V) -> [{Key, iolist_to_binary(lists:join($,,V))}]; 154 | Enc({Key,#{}=V}) -> 155 | [ [{<>,V2} || {K2,V2} <- Enc({K1,V1})] || {K1,V1} <- maps:to_list(V)]; 156 | Enc(#{} = KV) -> 157 | [Enc(KV_) || KV_ <- maps:to_list(KV)] 158 | end, 159 | lists:flatten(Enc(Filter))++ encode_qs_api2(Query); 160 | 161 | encode_qs_api2([{reversed,true}|Query]) -> 162 | [{<<"$reversed">>,<<"true">>}|encode_qs_api2(Query)]; 163 | 164 | encode_qs_api2([{limit,Limit}|Query]) -> 165 | LimitBin = if 166 | is_integer(Limit) -> integer_to_binary(Limit); 167 | is_binary(Limit) -> Limit 168 | end, 169 | [{<<"limit">>,LimitBin}|encode_qs_api2(Query)]; 170 | 171 | encode_qs_api2([{sort,Sort}|Query]) -> 172 | SortBin = lists:join($,, lists:map(fun 173 | ({Key,asc}) -> lists:join($., [to_b(K) || K <- Key]); 174 | ({Key,desc}) -> ["-"] ++ lists:join($., [to_b(K) || K <- Key]) 175 | end, Sort)), 176 | [{<<"sort">>,iolist_to_binary(SortBin)}|encode_qs_api2(Query)]; 177 | 178 | encode_qs_api2([{select,Select}|Query]) -> 179 | Enc = fun 180 | Enc({Key,V}) when is_atom(Key) -> Enc({atom_to_binary(Key,latin1),V}); 181 | Enc({Key,true}) -> [Key]; 182 | Enc({Key,#{}=V}) -> 183 | [ [<> || K2 <- Enc(KV)] || KV <- maps:to_list(V)]; 184 | Enc(#{} = KV) -> 185 | [Enc(KV_) || KV_ <- maps:to_list(KV)] 186 | end, 187 | Select1 = lists:flatten(Enc(Select)), 188 | Select2 = iolist_to_binary(lists:join($,, Select1)), 189 | [{<<"select">>,Select2}|encode_qs_api2(Query)]; 190 | 191 | % encode_qs_api2([{cc,CountCache}|Query]) -> 192 | % [{<<"cc">>,integer_to_binary(CountCache)}|encode_qs_api2(Query)]; 193 | 194 | encode_qs_api2([{cursor,#{} = Cursor}|Query]) -> 195 | [{<<"cursor">>,base64:encode(qs(Cursor))}|encode_qs_api2(Query)]; 196 | 197 | encode_qs_api2([]) -> 198 | []. 199 | 200 | 201 | 202 | to_b(Atom) when is_atom(Atom) -> atom_to_binary(Atom,latin1); 203 | to_b(Int) when is_integer(Int) -> integer_to_binary(Int); 204 | to_b(Bin) when is_binary(Bin) -> Bin. 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | % $$\ $$\ $$\ 215 | % $$ | \__| $$ | 216 | % $$ | $$\ $$$$$$$\ $$$$$$\ 217 | % $$ | $$ |$$ _____|\_$$ _| 218 | % $$ | $$ |\$$$$$$\ $$ | 219 | % $$ | $$ | \____$$\ $$ |$$\ 220 | % $$$$$$$$\ $$ |$$$$$$$ | \$$$$ | 221 | % \________|\__|\_______/ \____/ 222 | 223 | 224 | 225 | 226 | 227 | 228 | list(Collection, #{} = Query0) -> 229 | Query = add_defaults(Query0), 230 | 231 | Timing = maps:get(timing, Query, #{}), 232 | Cursor = maps:get(cursor, Query, #{}), 233 | 234 | T2 = erlang:system_time(milli_seconds), 235 | IndexedCollection = lists:zipwith(fun(P,S) -> S#{'$position' => P} end, lists:seq(0,length(Collection)-1), Collection), 236 | 237 | Reversed = maps:get(reversed, Cursor, false), 238 | SortedCollection = sort_collection(maps:get(sort, Query), IndexedCollection, Reversed), 239 | 240 | T3 = erlang:system_time(milli_seconds), 241 | 242 | {FilteredCollection, _} = filter_collection(maps:get(filter, Query, #{}), SortedCollection), 243 | {CursorFilteredCollection, HasLess} = filter_collection(maps:get(filter, Cursor, #{}), FilteredCollection), 244 | 245 | T4 = erlang:system_time(milli_seconds), 246 | {LimitedCollection, HasMore} = limit_collection(maps:get(limit, Query, undefined), CursorFilteredCollection), 247 | T5 = erlang:system_time(milli_seconds), 248 | SelectedCollection = select_fields(Query, LimitedCollection), 249 | ReversedCollection = case Reversed of 250 | true -> lists:reverse(SelectedCollection); 251 | false -> SelectedCollection 252 | end, 253 | ClearedCollection = [maps:remove('$position',S) || S <- ReversedCollection], 254 | ResultSet = ClearedCollection, 255 | T6 = erlang:system_time(milli_seconds), 256 | 257 | % D = fun(List) -> 258 | % [maps:get(name,N) || N <- List] 259 | % end, 260 | % ct:pal("Filter ~p\n~p ->\n~p\n~p\n\n\n" 261 | % "sorted\n~p\n\n" 262 | % "has_more: ~p\n~p", [Query, D(IndexedCollection), D(FilteredCollection), D(CursorFilteredCollection), 263 | % D(SortedCollection), 264 | % HasMore, D(LimitedCollection)]), 265 | 266 | TotalCount = length(FilteredCollection), 267 | Cursors = calculate_cursors(maps:with([filter,select,sort,limit],Query), ReversedCollection, Reversed, HasMore, HasLess), 268 | CollectionName = maps:get(collection, Query, items), 269 | Cursors#{ 270 | CollectionName => ResultSet, 271 | estimated_count => TotalCount, 272 | timing => Timing#{ 273 | filter => T4-T3, 274 | sort => T3-T2, 275 | limit => T5-T4, 276 | select => T6-T5 277 | } 278 | }. 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | % $$$$$$$$\ $$\ $$\ $$\ 287 | % $$ _____|\__|$$ | $$ | 288 | % $$ | $$\ $$ |$$$$$$\ $$$$$$\ $$$$$$\ 289 | % $$$$$\ $$ |$$ |\_$$ _| $$ __$$\ $$ __$$\ 290 | % $$ __| $$ |$$ | $$ | $$$$$$$$ |$$ | \__| 291 | % $$ | $$ |$$ | $$ |$$\ $$ ____|$$ | 292 | % $$ | $$ |$$ | \$$$$ |\$$$$$$$\ $$ | 293 | % \__| \__|\__| \____/ \_______|\__| 294 | 295 | 296 | 297 | 298 | filter_collection(#{} = Filter, Collection) -> 299 | KV = unwrap_kv(Filter), 300 | {Collection1, HasLess} = filter3(Collection, KV), 301 | {Collection1, HasLess}. 302 | 303 | 304 | filter3([Item|Collection], KV) -> 305 | Collection1 = [S || S <- Collection, filter_match(S,KV)], 306 | case filter_match(Item, KV) of 307 | true -> {[Item|Collection1], false}; 308 | false -> {Collection1, true} 309 | end; 310 | 311 | filter3([], _) -> 312 | {[], false}. 313 | 314 | 315 | filter_match(Item, KV) -> 316 | lists:all(fun(KV1) -> filter2(KV1, Item) end, KV). 317 | 318 | 319 | filter2({Key,Values0}, Item) when is_list(Values0) -> 320 | % make binaries if enum 321 | Values = lists:flatmap(fun 322 | (Bool) when is_boolean(Bool) -> [Bool]; 323 | (V) when is_atom(V) -> [V, atom_to_binary(V)]; 324 | (V) -> [V] 325 | end, Values0), 326 | lists:member(getkey(Key,Item),Values); 327 | 328 | filter2({Key,not_null}, Item) -> 329 | getkey(Key,Item) =/= undefined; 330 | 331 | filter2({Key,null}, Item) -> 332 | getkey(Key,Item) == undefined; 333 | 334 | filter2({Key,#{'$ne' := Value}}, Item) -> 335 | getkey(Key,Item) =/= Value; 336 | 337 | filter2({Key,#{'$gt' := Value}}, Item) -> 338 | V = getkey(Key,Item), 339 | V > Value andalso V =/= undefined; 340 | 341 | filter2({Key,#{'$lt' := Value}}, Item) -> 342 | V = getkey(Key,Item), 343 | V < Value andalso V =/= undefined; 344 | 345 | filter2({Key,#{'$gte' := Value}}, Item) -> 346 | V = getkey(Key,Item), 347 | V >= Value andalso V =/= undefined; 348 | 349 | filter2({Key,#{'$lte' := Value}}, Item) -> 350 | V = getkey(Key,Item), 351 | V =< Value andalso V =/= undefined; 352 | 353 | filter2({Key,#{'$like' := Value}}, Item) -> 354 | case to_b2(getkey(Key,Item)) of 355 | <> when is_binary(Value) -> 356 | case binary:match(Binary, [Value], []) of 357 | nomatch -> false; 358 | _ -> true 359 | end; 360 | _ -> false 361 | end. 362 | 363 | 364 | to_b2(undefined) -> undefined; 365 | to_b2(null) -> null; 366 | to_b2(<>) -> Bin; 367 | to_b2(Atom) when is_atom(Atom) -> atom_to_binary(Atom,latin1); 368 | to_b2(V) -> V. 369 | 370 | % 371 | % One more trick here: 372 | % "stats.bitrate_gt=187" is converted to stats => #{bitrate => #{'$gt' => 187}} 373 | % 374 | % We unwind it into [stats, bitrate] => #{'$gt' => 187} and iterate over the list 375 | % 376 | % TODO: protocol for accessing lists 377 | % 378 | % One more case: 379 | % stats.last_dts_ago_gt=0&stats.last_dts_ago_lt=1000 is transformed to stats => #{last_dts_ago => #{'$gt' => 0,'lt' => 1000}} 380 | % 381 | 382 | unwrap_kv(#{} = Filter) -> 383 | unwrap_kv2(maps:to_list(Filter), []). 384 | 385 | 386 | unwrap_kv2([], _Prefix) -> 387 | []; 388 | 389 | unwrap_kv2([{Key, #{'$ne' := V} = Value}|List], Prefix) -> 390 | [{Prefix ++ [Key], #{'$ne' => V}} | unwrap_kv2([{Key, maps:remove('$ne',Value)}|List], Prefix)]; 391 | 392 | unwrap_kv2([{Key, #{'$gt' := V} = Value}|List], Prefix) -> 393 | [{Prefix ++ [Key], #{'$gt' => V}} | unwrap_kv2([{Key, maps:remove('$gt',Value)}|List], Prefix)]; 394 | 395 | unwrap_kv2([{Key, #{'$gte' := V} = Value}|List], Prefix) -> 396 | [{Prefix ++ [Key], #{'$gte' => V}} | unwrap_kv2([{Key, maps:remove('$gte',Value)}|List], Prefix)]; 397 | 398 | unwrap_kv2([{Key, #{'$lt' := V} = Value}|List], Prefix) -> 399 | [{Prefix ++ [Key], #{'$lt' => V}} | unwrap_kv2([{Key, maps:remove('$lt',Value)}|List], Prefix)]; 400 | 401 | unwrap_kv2([{Key, #{'$lte' := V} = Value}|List], Prefix) -> 402 | [{Prefix ++ [Key], #{'$lte' => V}} | unwrap_kv2([{Key, maps:remove('$lte',Value)}|List], Prefix)]; 403 | 404 | unwrap_kv2([{Key, #{'$like' := V} = Value}|List], Prefix) -> 405 | [{Prefix ++ [Key], #{'$like' => V}} | unwrap_kv2([{Key, maps:remove('$like',Value)}|List], Prefix)]; 406 | 407 | unwrap_kv2([{_,#{} = Value}|List], Prefix) when Value == #{} -> 408 | unwrap_kv2(List, Prefix); 409 | 410 | unwrap_kv2([{Key,#{} = Value}|List], Prefix) -> 411 | unwrap_kv2(maps:to_list(Value), Prefix ++ [Key]) ++ unwrap_kv2(List, Prefix); 412 | 413 | unwrap_kv2([{Key,Value}|List], Prefix) -> 414 | [{Prefix++[Key], Value}|unwrap_kv2(List, Prefix)]. 415 | 416 | 417 | 418 | 419 | getkey([K],S) -> 420 | getkey(K, S); 421 | 422 | getkey([K|List],S) -> 423 | getkey(List, maps_get(K,S)); 424 | 425 | getkey(K,S) -> 426 | maps_get(K,S). 427 | 428 | maps_get(K,S) -> 429 | V1 = case S of 430 | #{K := V} -> 431 | V; 432 | #{} when is_binary(K) -> 433 | maps:get(binary_to_existing_atom(K,latin1),S, undefined); 434 | _ -> 435 | undefined 436 | end, 437 | case V1 of 438 | null -> undefined; 439 | <<"null">> -> undefined; 440 | <<"undefined">> -> undefined; 441 | V1 -> V1 442 | end. 443 | 444 | 445 | 446 | 447 | 448 | setkey([K],S, V) -> 449 | setkey(K, S, V); 450 | 451 | setkey([K|List],S,V) -> 452 | maps_set(K,S,setkey(List, maps_get(K,S), V)); 453 | 454 | setkey(K,S,V) -> 455 | maps_set(K,S,V). 456 | 457 | maps_set(K,S,V) -> 458 | case S of 459 | #{K := _} -> 460 | S#{K => V}; 461 | #{} when is_binary(K) -> 462 | maps:put(binary_to_existing_atom(K,latin1),V,S); 463 | #{} -> 464 | S#{K => V}; 465 | undefined -> 466 | #{K => V} 467 | end. 468 | 469 | 470 | 471 | 472 | % $$$$$$\ $$\ 473 | % $$ __$$\ $$ | 474 | % $$ / \__| $$$$$$\ $$$$$$\ $$$$$$\ 475 | % \$$$$$$\ $$ __$$\ $$ __$$\\_$$ _| 476 | % \____$$\ $$ / $$ |$$ | \__| $$ | 477 | % $$\ $$ |$$ | $$ |$$ | $$ |$$\ 478 | % \$$$$$$ |\$$$$$$ |$$ | \$$$$ | 479 | % \______/ \______/ \__| \____/ 480 | 481 | 482 | sort_collection(Key, Collection, Reversed) -> 483 | Collection1 = lists:sort(fun(S1,S2) -> 484 | Value1 = get_comparator(Key, S1, S2), 485 | Value2 = get_comparator(Key, S2, S1), 486 | case Reversed of 487 | false -> Value1 =< Value2; 488 | true -> Value1 > Value2 489 | end 490 | end, Collection), 491 | Collection1. 492 | 493 | 494 | get_comparator([], _, _) -> 495 | []; 496 | 497 | get_comparator([{Key, asc}|List], S1, S2) -> 498 | [get_comparator_key(Key,S1)| get_comparator(List, S1, S2)]; 499 | 500 | get_comparator([{Key, desc}|List], S1, S2) -> 501 | [get_comparator_key(Key,S2)| get_comparator(List, S1, S2)]. 502 | 503 | 504 | % Explicit type sort 505 | get_comparator_key(Key, S) -> 506 | case getkey(Key, S) of 507 | undefined -> {0,undefined}; 508 | Int when is_integer(Int) -> {1,Int}; 509 | Bin when is_binary(Bin) -> {2,Bin}; 510 | Value -> {3,Value} 511 | end. 512 | 513 | % $$\ $$\ $$\ $$\ 514 | % $$ | \__| \__| $$ | 515 | % $$ | $$\ $$$$$$\$$$$\ $$\ $$$$$$\ 516 | % $$ | $$ |$$ _$$ _$$\ $$ |\_$$ _| 517 | % $$ | $$ |$$ / $$ / $$ |$$ | $$ | 518 | % $$ | $$ |$$ | $$ | $$ |$$ | $$ |$$\ 519 | % $$$$$$$$\ $$ |$$ | $$ | $$ |$$ | \$$$$ | 520 | % \________|\__|\__| \__| \__|\__| \____/ 521 | 522 | 523 | 524 | 525 | 526 | limit_collection(undefined, Collection) -> 527 | {Collection, false}; 528 | 529 | 530 | limit_collection(Count, Collection) when is_integer(Count) andalso Count > 0 -> 531 | List1 = lists:sublist(Collection, Count+1), 532 | HasMore = length(List1) > Count, 533 | {lists:sublist(List1, Count), HasMore}. 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | select_fields(#{select := Fields} = Query, Streams) -> 542 | Streams1 = case Query of 543 | #{schema := Schema, schema_name := SchemaName, collection := CollectionName} -> 544 | #{CollectionName := Streams1_} = openapi_schema:process( 545 | #{CollectionName => Streams}, #{schema => Schema, name => SchemaName, required_obj_keys => drop, access_type => read, explain => [required]}), 546 | Streams1_; 547 | #{} -> 548 | Streams 549 | end, 550 | [selector(Fields#{'$position' => true}, S) || S <- Streams1]; 551 | 552 | 553 | select_fields(_, Streams) -> 554 | Streams. 555 | 556 | 557 | selector(true, S) -> 558 | S; 559 | 560 | selector(Fields, S) -> 561 | RequiredKeys = [binary_to_atom(Key, latin1) || Key <- maps:get(required, maps:get('$explain', S, #{}), [])], 562 | RequiredFields = maps:with(RequiredKeys, S), 563 | S1 = lists:flatmap(fun({K,Nested}) -> 564 | case S of 565 | #{K := V} -> 566 | [{K,selector(Nested,V)}]; 567 | #{} -> 568 | K1 = binary_to_existing_atom(K,latin1), 569 | case S of 570 | #{K1 := V} -> [{K1,selector(Nested,V)}]; 571 | _ -> [{K,undefined}] 572 | end 573 | end 574 | end, maps:to_list(Fields)), 575 | delete_explain(maps:merge(maps:from_list(S1), RequiredFields)). 576 | 577 | 578 | 579 | 580 | calculate_next_cursor(#{sort := Sort}, [_|_] = List) -> 581 | Last = lists:last(List), 582 | Filter1 = lists:foldl(fun 583 | ({Sort1,asc}, F) -> 584 | case getkey(Sort1,Last) of 585 | undefined -> F; 586 | Val -> setkey(Sort1, F, #{'$gt' => to_b(Val)}) 587 | end; 588 | ({Sort1,desc}, F) -> 589 | case getkey(Sort1,Last) of 590 | undefined -> F; 591 | Val -> setkey(Sort1, F, #{'$lt' => to_b(Val)}) 592 | end 593 | end, #{}, Sort), 594 | #{filter => Filter1}; 595 | 596 | calculate_next_cursor(_, []) -> 597 | undefined. 598 | 599 | 600 | 601 | 602 | calculate_prev_cursor(#{sort := [_|_] = Sort}, [First|_]) -> 603 | Filter1 = lists:foldl(fun 604 | ({Sort1,asc}, F) -> 605 | case getkey(Sort1,First) of 606 | undefined -> F; 607 | Val -> setkey(Sort1, F, #{'$lt' => to_b(Val)}) 608 | end; 609 | ({Sort1,desc}, F) -> 610 | case getkey(Sort1,First) of 611 | undefined -> F; 612 | Val -> setkey(Sort1, F, #{'$gt' => to_b(Val)}) 613 | end 614 | end, #{}, Sort), 615 | #{filter => Filter1, reversed => true}; 616 | 617 | calculate_prev_cursor(_, []) -> 618 | undefined. 619 | 620 | 621 | 622 | 623 | 624 | calculate_cursors(Query, List, Reversed, HasMore, HasLess) -> 625 | Next64 = case calculate_next_cursor(Query, List) of 626 | _ when not Reversed and not HasMore -> undefined; 627 | _ when Reversed and not HasLess -> undefined; 628 | undefined -> undefined; 629 | Next -> base64:encode(qs(Next)) 630 | end, 631 | Prev64 = case calculate_prev_cursor(Query, List) of 632 | _ when not Reversed and not HasLess -> undefined; 633 | _ when Reversed and not HasMore -> undefined; 634 | undefined -> undefined; 635 | Prev -> base64:encode(qs(Prev)) 636 | end, 637 | #{ 638 | next => Next64, 639 | prev => Prev64 640 | }. 641 | 642 | 643 | 644 | 645 | delete_explain(Elem) when is_list(Elem) -> 646 | [delete_explain(ElemItem) || ElemItem <- Elem]; 647 | delete_explain(Elem) when is_map(Elem) -> 648 | maps:fold(fun(Key, Value, Acc) -> Acc#{Key => delete_explain(Value)} end, #{}, maps:without(['$explain'], Elem)); 649 | delete_explain(Elem) -> 650 | Elem. 651 | 652 | 653 | -------------------------------------------------------------------------------- /src/openapi_handler.app.src: -------------------------------------------------------------------------------- 1 | {application, openapi_handler, 2 | [{description, "OpenAPI 3.0 Cowboy handler"}, 3 | {vsn, "22.11.0"}, % YY.MM.rev -- clear date instead of semver, better for continious development 4 | {registered, []}, 5 | {applications, 6 | [kernel, 7 | stdlib 8 | ]}, 9 | {env,[]}, 10 | {modules, []}, 11 | 12 | {licenses, ["MIT"]}, 13 | {links, []} 14 | ]}. 15 | -------------------------------------------------------------------------------- /src/openapi_handler.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_handler). 2 | -include_lib("kernel/include/logger.hrl"). 3 | 4 | 5 | -export([init/2, handle/2, terminate/3]). 6 | -export([routes/1, load_schema/2, choose_module/0]). 7 | -export([read_schema/1]). 8 | -export([routes_sort/1]). % for tests 9 | 10 | % For compatibility with legacy Cowboy. Called by openapi_handler_legacy. 11 | -export([do_init/5, do_handle/3]). 12 | 13 | 14 | routes(#{schema := SchemaPath, module := Module, name := Name, prefix := Prefix} = Config) -> 15 | #{} = Schema = load_schema(SchemaPath, Name), 16 | SchemaOpts = get_schema_opts(Config), 17 | HandlerModule = choose_module(), 18 | 19 | #{paths := Paths} = Schema, 20 | Routes = lists:map(fun({Path,PathSpec}) -> 21 | <<"/", _/binary>> = CowboyPath = re:replace(atom_to_list(Path), "{([^\\}]+)}", ":\\1",[global,{return,binary}]), 22 | Parameters = maps:get(parameters, PathSpec, []), 23 | PathSpec1 = maps:filtermap( 24 | fun(Method, Operation) -> prepare_operation_fm(Method, Operation, Parameters) end, 25 | maps:remove(parameters,PathSpec)), 26 | 27 | % It is too bad to pass all this stuff through cowboy options because it starts suffering 28 | % from GC on big state. Either ETS, either persistent_term, either compilation of custom code 29 | persistent_term:put({openapi_handler_route,Name,CowboyPath}, PathSpec1#{name => Name, module => Module, schema_opts => SchemaOpts}), 30 | {<>, HandlerModule, {Name,CowboyPath}} 31 | end, maps:to_list(Paths)), 32 | % After sorting, bindings (starting with ":") must be after constant path segments, so generic routes only work after specific ones. 33 | % E.g. "/api/users/admin" must be before "/api/users/:id" 34 | % Thus special sorting function 35 | routes_sort(Routes). 36 | 37 | choose_module() -> 38 | try 39 | % Check if cowboy uses modern Req (map) 40 | 1234 = cowboy_req:port(#{port => 1234}), 41 | ?MODULE 42 | catch 43 | error:{badrecord,_}:_ -> 44 | % cowboy uses record for Req, use legacy wrapper 45 | openapi_handler_legacy 46 | end. 47 | 48 | 49 | prepare_operation_fm(_M, #{operationId := OperationId_} = Operation, Parameters) -> 50 | Op1 = maps:remove(description,Operation), 51 | OperationId = binary_to_atom(OperationId_,latin1), 52 | Params1 = Parameters ++ maps:get(parameters, Op1, []), 53 | Op2 = Op1#{operationId => OperationId, parameters => Params1}, 54 | Op3 = case Op2 of 55 | #{'x-collection-name' := CollectionName, 'x-collection-type' := CollectionType} -> 56 | Op2#{ 57 | 'x-collection-name' => binary_to_atom(CollectionName,latin1), 58 | 'x-collection-type' := binary_to_atom(CollectionType,latin1) 59 | }; 60 | #{} -> 61 | Op2 62 | end, 63 | Responses0 = maps:to_list(maps:get(responses, Operation, #{})), 64 | Responses = [{list_to_integer(atom_to_list(Code)),maps:remove(description,CodeSpec)} || 65 | {Code,CodeSpec} <- Responses0, 66 | Code /= default], 67 | {true, Op3#{responses => maps:from_list(Responses)}}; 68 | prepare_operation_fm(_, #{}, _) -> 69 | % Skip operations with no operationId 70 | false. 71 | 72 | routes_sort(Routes) -> 73 | lists:sort(fun path_sort/2, Routes). 74 | 75 | path_sort({Path1,_,_},{Path2,_,_}) -> 76 | Path1 >= Path2. 77 | 78 | 79 | 80 | load_schema(#{} = Schema, Name) -> 81 | openapi_schema:load_schema(Schema, Name); 82 | 83 | load_schema(SchemaPath, Name) when is_list(SchemaPath) orelse is_binary(SchemaPath) -> 84 | DecodedSchema = read_schema(SchemaPath), 85 | load_schema(DecodedSchema, Name). 86 | 87 | read_schema(SchemaPath) -> 88 | Bin = case file:read_file(SchemaPath) of 89 | {ok, Bin_} -> Bin_; 90 | {error, E} -> error({E,SchemaPath}) 91 | end, 92 | Format = case filename:extension(iolist_to_binary(SchemaPath)) of 93 | <<".yaml">> -> yaml; 94 | <<".yml">> -> yaml; 95 | <<".json">> -> json; 96 | _ -> json 97 | end, 98 | DecodedSchema = case Format of 99 | json -> 100 | openapi_json:decode_with_atoms(Bin); 101 | yaml -> 102 | application:ensure_all_started(yamerl), 103 | [Decoded0] = yamerl:decode(Bin, [{erlang_atom_autodetection, false}, {map_node_format, map}, {str_node_as_binary, true}]), 104 | map_keys_to_atom(Decoded0) 105 | end, 106 | DecodedSchema. 107 | 108 | 109 | %% yamerl lacks an option to make all map keys atom, but keep values binary. So, we need this converter. 110 | map_keys_to_atom(#{} = Map) -> 111 | maps:fold(fun(K, V, Acc) -> Acc#{binary_to_atom(K, utf8) => map_keys_to_atom(V)} end, #{}, Map); 112 | map_keys_to_atom(List) when is_list(List) -> 113 | [map_keys_to_atom(E) || E <- List]; 114 | map_keys_to_atom(Value) -> 115 | Value. 116 | 117 | 118 | 119 | 120 | 121 | 122 | init(Req, {Name, CowboyPath}) -> 123 | do_init(Req, Name, CowboyPath, cowboy_req, #{}). 124 | 125 | do_init(Req, Name, CowboyPath, Mod_cowboy_req, Compat) -> 126 | #{module := Module, name := Name, schema_opts := SchemaOpts} = Spec = persistent_term:get({openapi_handler_route, Name, CowboyPath}), 127 | Method_ = Mod_cowboy_req:method(Req), 128 | Method = case Method_ of 129 | <<"POST">> -> post; 130 | <<"GET">> -> get; 131 | <<"DELETE">> -> delete; 132 | <<"PUT">> -> put; 133 | <<"OPTIONS">> -> options; 134 | _ -> undefined 135 | end, 136 | Operation = maps:get(Method, Spec, undefined), 137 | Originator = Mod_cowboy_req:header(<<"x-originator">>, Req), 138 | Authorization = Mod_cowboy_req:header(<<"authorization">>, Req), 139 | 140 | % For compatibility with legacy Cowboy 141 | _ok = maps:get(ok, Compat, ok), 142 | NoHandle = maps:get(no_handle, Compat, false), 143 | 144 | case Operation of 145 | undefined when Method == options -> 146 | Req3 = Mod_cowboy_req:reply(200, cors_headers(), <<>>, Req), 147 | {_ok, Req3, undefined}; 148 | undefined -> 149 | Req3 = Mod_cowboy_req:reply(405, 150 | json_headers(), 151 | [openapi_json:encode(#{error => <<"unknown_operation">>}),"\n"], Req), 152 | {_ok, Req3, undefined}; 153 | #{} -> 154 | {Args, Req3} = collect_parameters(Operation, Req, Name, SchemaOpts, Mod_cowboy_req), 155 | case Args of 156 | {error, E} -> 157 | Req4 = Mod_cowboy_req:reply(400, 158 | json_headers(), 159 | [openapi_json:encode(E#{while => parsing_parameters}),"\n"], Req3), 160 | {_ok, Req4, undefined}; 161 | #{} -> 162 | Accept = case Mod_cowboy_req:header(<<"accept">>, Req3, <<"application/json">>) of 163 | <<"text/plain",_/binary>> -> text; 164 | <<"text/csv",_/binary>> -> csv; 165 | <<"*/*">> -> any; 166 | <<"application/json">> -> json; 167 | Other -> Other 168 | end, 169 | Ip = fetch_ip_address(Req, Mod_cowboy_req), 170 | Operation1 = Operation#{ 171 | module => Module, 172 | args => Args, 173 | name => Name, 174 | ip => Ip, 175 | accept => Accept, 176 | originator => Originator, 177 | authorization => Authorization, 178 | '$cowboy_req' => Req 179 | }, 180 | case Module:authorize(Operation1) of 181 | #{} = AuthContext when NoHandle -> 182 | {ok, Req3, Operation1#{auth_context => AuthContext}}; 183 | #{} = AuthContext -> 184 | handle(Req, Operation1#{auth_context => AuthContext}); 185 | {error, denied} -> 186 | Req4 = Mod_cowboy_req:reply(403, json_headers(), [openapi_json:encode(#{error => authorization_failed}),"\n"], Req3), 187 | {_ok, Req4, undefined} 188 | end 189 | end 190 | end. 191 | 192 | 193 | collect_parameters(#{parameters := Parameters} = Spec, Req, ApiName, SchemaOpts, Mod_cowboy_req) -> 194 | Qs = Mod_cowboy_req:qs(Req), 195 | QsVals = Mod_cowboy_req:parse_qs(Req), 196 | Bindings = Mod_cowboy_req:bindings(Req), 197 | Headers = Mod_cowboy_req:headers(Req), 198 | ContentType = case maps:get(<<"content-type">>, Headers, undefined) of 199 | <<"application/json",_/binary>> -> 'application/json'; 200 | <<"text/json",_/binary>> -> 'application/json'; 201 | <<"text/plain",_/binary>> -> 'text/plain'; 202 | _ -> 'application/octet-stream' 203 | end, 204 | Args = lists:foldl(fun 205 | (_, {error, E}) -> 206 | {error, E}; 207 | (#{in := In, name := Name, schema := Schema} = ParamSpec, Acc) -> 208 | Required = maps:get(required, ParamSpec, false), 209 | Key = case In of 210 | <<"header">> -> 211 | Name1 = binary:replace(Name, <<"X-">>, <<>>), 212 | Name2 = binary:replace(Name1, <<"-">>, <<"_">>, [global]), 213 | Name3 = cowboy_bstr:to_lower(Name2), 214 | binary_to_atom(Name3, latin1); 215 | _ -> 216 | binary_to_atom(Name,latin1) % It is OK here to make binary_to_atom 217 | end, 218 | Value1 = case In of 219 | <<"path">> -> maps:get(Key, Bindings, undefined); 220 | <<"query">> -> proplists:get_value(Name, QsVals); 221 | <<"header">> -> maps:get(cowboy_bstr:to_lower(Name), Headers, undefined) 222 | end, 223 | Value2 = case Schema of 224 | #{default := Default} when Value1 == undefined -> Default; 225 | _ -> Value1 226 | end, 227 | case Value2 of 228 | undefined when Required -> 229 | {error, #{missing_required => Name}}; 230 | undefined -> 231 | Acc; 232 | _ -> 233 | case openapi_schema:process(Value2, #{schema => Schema}) of 234 | {error, ParseError} -> 235 | {error, ParseError#{name => Name, input => Value1}}; 236 | Value3 -> 237 | Acc#{Key => Value3} 238 | end 239 | end 240 | end, #{raw_qs => Qs}, Parameters), 241 | case Args of 242 | {error, _} -> 243 | {Args, Req}; 244 | #{} -> 245 | case Spec of 246 | #{requestBody := #{content := #{'application/json' := #{schema := BodySchema}}}} when 247 | ContentType == 'application/json' -> 248 | {ok, TextBody, Req4} = Mod_cowboy_req:read_body(Req), 249 | Body = openapi_json:decode(TextBody), 250 | case Body of 251 | _ when TextBody == <<>> -> 252 | {Args, Req4}; 253 | {error, DecodeError} -> 254 | {{error, #{error => DecodeError, name => request_body}}, Req4}; 255 | _ -> 256 | case openapi_schema:process(Body, maps:merge(#{schema => BodySchema, patch => true, name => ApiName, array_convert => false, extra_obj_key => error, required_obj_keys => drop, access_type => write}, SchemaOpts)) of 257 | {error, ParseError_} -> 258 | ParseError = maps:without([encoded], ParseError_), 259 | {{error, ParseError#{name => request_body, input1 => Body}}, Req4}; 260 | Value1 -> 261 | Args1 = Args#{json_body => Value1}, 262 | {Args1, Req4} 263 | end 264 | end; 265 | #{requestBody := #{content := #{'text/plain' := #{schema := #{type := <<"string">>}}}}} when 266 | ContentType == 'text/plain' -> 267 | {ok, TextBody, Req4} = Mod_cowboy_req:read_body(Req), 268 | Args1 = Args#{raw_body => TextBody}, 269 | {Args1, Req4}; 270 | #{requestBody := #{content := #{'*/*' := #{schema := #{format := <<"binary">>}}}}} -> 271 | Args1 = Args#{req => Req}, 272 | {Args1, Req}; 273 | #{requestBody := #{content := #{'multipart/form-data' := #{schema := #{ 274 | type := <<"object">>, properties := #{file := #{ 275 | type := <<"array">>, items := #{ 276 | type := <<"string">>, format := <<"binary">>}}}}}}}} -> 277 | {ok, Files, Req1} = read_multipart_files(Mod_cowboy_req, Req), 278 | Args1 = Args#{files => Files, req => Req1}, 279 | {Args1, Req1}; 280 | #{} -> 281 | {Args, Req} 282 | end 283 | end. 284 | 285 | 286 | read_multipart_files(cowboy_req, Req) -> 287 | do_read_multipart_files(Req, []); 288 | read_multipart_files(Mod_cowboy_req, Req) -> 289 | Mod_cowboy_req:read_multipart_files(Req). 290 | 291 | do_read_multipart_files(Req0, Files) -> 292 | case cowboy_req:read_part(Req0) of 293 | {ok, Headers, Req1} -> 294 | {file, _FieldName, Filename, _CType} = cow_multipart:form_data(Headers), 295 | {Bin, Req2} = read_multipart_file(Req1, <<>>), 296 | do_read_multipart_files(Req2, [{Filename, Bin}| Files]); 297 | {done, Req1} -> 298 | {ok, Files, Req1} 299 | end. 300 | 301 | read_multipart_file(Req0, Bin) -> 302 | case cowboy_req:read_part_body(Req0) of 303 | {ok, LastBodyChunk, Req} -> {<>, Req}; 304 | {more, BodyChunk, Req} -> read_multipart_file(Req, <>) 305 | end. 306 | 307 | 308 | 309 | 310 | handle(Req, #{} = Request) -> 311 | do_handle(Req, #{} = Request, cowboy_req). 312 | 313 | do_handle(Req, #{module := _, ip := _} = Request, Mod_cowboy_req) -> 314 | T1 = erlang:system_time(micro_seconds), 315 | Response = handle_request(Request), 316 | % T2 = erlang:system_time(micro_seconds), 317 | {Code2, Headers, PreparedResponse} = handle_response(Response, Request), 318 | T3 = erlang:system_time(micro_seconds), 319 | catch do_log_call(Code2, Headers, PreparedResponse, Request#{time => T3-T1}, Mod_cowboy_req), 320 | Req2 = case Code2 of 321 | done -> PreparedResponse; % HACK to bypass request here 322 | 204 -> Mod_cowboy_req:reply(Code2, cors_headers(), [], Req); 323 | _ -> gzip_and_reply(Code2, Headers, PreparedResponse, Req, Mod_cowboy_req) 324 | end, 325 | {ok, Req2, undefined}. 326 | 327 | % Add some response info and log the handled api call 328 | do_log_call(done, _Headers, PreparedResponse, #{module := Module} = Request, Mod_cowboy_req) -> 329 | ContentType = catch Mod_cowboy_req:resp_header(<<"content-type">>, PreparedResponse, undefined), 330 | ContentLength = catch Mod_cowboy_req:resp_header(<<"content-length">>, PreparedResponse, undefined), 331 | % Cowboy does not store sent status code 332 | Module:log_call(Request#{code => undefined, content_type => ContentType, content_length => ContentLength}); 333 | do_log_call(Status, Headers, PreparedResponse, #{module := Module} = Request, _Mod_cowboy_req) -> 334 | ContentType = maps:get(<<"content-type">>, Headers, undefined), 335 | ContentLength = iolist_size(PreparedResponse), 336 | Module:log_call(Request#{code => Status, content_type => ContentType, content_length => ContentLength}). 337 | 338 | % User code itself works with the request and changes its state, 339 | % for example, when receiving a large request body 340 | handle_response({done, Req}, _) -> 341 | {done, undefined, Req}; 342 | 343 | handle_response({ContentType, Code, Response}, Request) -> 344 | handle_response({ContentType, Code, #{}, Response}, Request); 345 | 346 | handle_response({ContentType_, Code, Headers, Response}, #{responses := Responses, name := Name, module := Module} = Request) -> 347 | ContentType = check_accept_type(ContentType_, is_binary(Response)), 348 | case Responses of 349 | #{Code := #{content := #{'application/json' := #{schema := Schema}}}} when (ContentType == json orelse ContentType == <<"application/json">>) -> 350 | case openapi_schema:process(Response, #{schema => Schema, name => Name, required_obj_keys => drop, access_type => read}) of 351 | {error, Error} -> 352 | {500, maps:merge(Headers, json_headers()), [openapi_json:encode(Error),"\n"]}; 353 | TransformedResponse -> 354 | Postprocessed = case erlang:function_exported(Module, postprocess, 2) of 355 | true -> Module:postprocess(TransformedResponse, Request); 356 | false -> TransformedResponse 357 | end, 358 | {Code, maps:merge(Headers, json_headers()), [openapi_json:encode(Postprocessed),"\n"]} 359 | end; 360 | #{} when is_binary(Response) -> 361 | {Code, maps:merge(Headers, text_headers(ContentType)), Response}; 362 | #{} -> 363 | {Code, maps:merge(Headers, json_headers()), [openapi_json:encode(Response),"\n"]} 364 | end. 365 | 366 | 367 | gzip_and_reply(Code, Headers, Body, Req, Mod_cowboy_req) when Code >= 200 andalso Code < 300-> 368 | AcceptEncoding = Mod_cowboy_req:parse_header(<<"accept-encoding">>, Req), 369 | AcceptGzip = if is_list(AcceptEncoding) -> lists:keymember(<<"gzip">>, 1, AcceptEncoding); 370 | true -> false 371 | end, 372 | 373 | Gzipping = AcceptGzip == true, 374 | case Gzipping of 375 | true when is_map(Headers) -> 376 | Body1 = zlib:gzip(Body), 377 | Headers1 = Headers#{<<"content-encoding">> => <<"gzip">>}, 378 | Mod_cowboy_req:reply(Code, Headers1, Body1, Req); 379 | false -> 380 | Mod_cowboy_req:reply(Code, Headers, Body, Req) 381 | end; 382 | 383 | gzip_and_reply(Code, Headers, Body, Req, Mod_cowboy_req) -> 384 | Mod_cowboy_req:reply(Code, Headers, Body, Req). 385 | 386 | 387 | fetch_ip_address(Req, Mod_cowboy_req) -> 388 | case Mod_cowboy_req:header(<<"x-real-ip">>, Req) of 389 | Ip when is_binary(Ip) -> Ip; 390 | _ -> 391 | case Mod_cowboy_req:header(<<"cf-connecting-ip">>, Req) of 392 | Ip when is_binary(Ip) -> Ip; 393 | _ -> 394 | {PeerAddr,_} = Mod_cowboy_req:peer(Req), 395 | Ip = list_to_binary(inet_parse:ntoa(PeerAddr)), 396 | Ip 397 | end 398 | end. 399 | 400 | 401 | json_headers() -> 402 | (cors_headers())#{<<"content-type">> => <<"application/json">>}. 403 | 404 | text_headers(text) -> 405 | (cors_headers())#{<<"content-type">> => <<"text/plain">>}; 406 | text_headers(csv) -> 407 | (cors_headers())#{<<"content-type">> => <<"text/csv">>}; 408 | text_headers(ContentType) -> 409 | (cors_headers())#{<<"content-type">> => ContentType}. 410 | 411 | 412 | cors_headers() -> 413 | #{<<"access-control-allow-origin">> => <<"*">>, 414 | <<"access-control-allow-methods">> => <<"GET, PUT, DELETE, OPTIONS">>, 415 | <<"access-control-expose-headers">> => <<"*">>, 416 | <<"access-control-allow-headers">> => <<"*">>, 417 | <<"access-control-allow-private-network">> => <<"true">> 418 | }. 419 | 420 | 421 | handle_request(#{module := Module, operationId := OperationId, args := Args, accept := Accept, auth_context := AuthContext, responses := Responses, 422 | 'x-collection-name' := CollectionName, '$cowboy_req' := CowboyReq} = OpenAPI) -> 423 | #{raw_qs := Qs} = Args, 424 | Type = maps:get('x-collection-type', OpenAPI), 425 | Name = maps:get(name, OpenAPI), 426 | case openapi_collection:parse_qs(Qs, #{limit => 100, collection_type => Type, schema_name => Name}) of 427 | {error, E} -> 428 | {json, 400, E#{while => parsing_query}}; 429 | #{} = RawQuery -> 430 | Args1 = Args#{ 431 | auth_context => AuthContext, 432 | collection_type => Type, 433 | schema_name => Name, 434 | '$cowboy_req' => CowboyReq 435 | }, 436 | Query = maps:merge(Args1, RawQuery), 437 | T1 = erlang:system_time(milli_seconds), 438 | try Module:OperationId(Query) of 439 | {json, Code, Response} -> 440 | {json, Code, Response}; 441 | {error, {Code, #{} = Response}} when is_integer(Code) -> 442 | {json, Code, Response}; 443 | {error, badrequest} -> 444 | {json, 400, #{error => bad_request}}; 445 | {error, enoent} -> 446 | {json, 404, #{error => not_found}}; 447 | {error, unavailable} -> 448 | {json, 503, #{error => unavailable}}; 449 | #{CollectionName := FullList} = R0 when is_list(FullList) -> 450 | #{responses := #{200 := #{content := #{'application/json' := #{schema := Schema}}}}} = OpenAPI, 451 | T2 = erlang:system_time(milli_seconds), 452 | Delta = maps:without([CollectionName], R0), 453 | R = openapi_collection:list(FullList, Query#{timing => #{load => T2-T1}, collection => CollectionName, schema => Schema, schema_name => Name}), 454 | {json, 200, maps:merge(Delta, R)}; 455 | {raw, Code, #{} = Headers, <<_/binary>> = Body} -> 456 | handle_raw_response(Accept, OperationId, Responses, {raw, Code, Headers, Body}) 457 | catch 458 | error:undef:ST -> 459 | case ST of 460 | [{Module,OperationId,_,_}|_] -> 461 | {json, 501, #{error => not_implemented}}; 462 | _ -> 463 | ?LOG_ALERT(#{class => error, error => undef, stacktrace => ST}), 464 | Response = #{error => crashed}, 465 | {json, 500, Response} 466 | end; 467 | C:E:ST -> 468 | ?LOG_ALERT(#{class => C, error => E, stacktrace => ST}), 469 | Response = #{error => crashed}, 470 | {json, 500, Response} 471 | end 472 | end; 473 | 474 | handle_request(#{module := Module, operationId := OperationId, args := Args, accept := Accept, auth_context := AuthContext, ip := Ip, responses := Responses, '$cowboy_req' := CowboyReq}) -> 475 | try Module:OperationId(Args#{auth_context => AuthContext, agent_ip => Ip, '$cowboy_req' => CowboyReq}) of 476 | {error, badrequest} -> 477 | {json, 400, #{error => bad_request}}; 478 | {error, enoent} -> 479 | {json, 404, #{error => not_found}}; 480 | {error, unavailable} -> 481 | {json, 503, #{error => unavailable}}; 482 | {error, {Code, #{} = Response}} when is_integer(Code) -> 483 | {json, Code, Response}; 484 | ok -> 485 | {json, 204, undefined}; 486 | {json, Code, Response} -> 487 | {json, Code, Response}; 488 | #{} = Response -> 489 | {json, 200, Response}; 490 | <<_/binary>> = Response -> 491 | Accept1 = check_accept_type(Accept, true), 492 | case is_binary_content_required(Responses, 200, Accept1) of 493 | true -> {Accept1, 200, Response}; 494 | _ -> 495 | ?LOG_ALERT(#{operationId => OperationId, invalid_response => Response, accept => Accept}), 496 | {json, 500, #{error => invalid_response}} 497 | end; 498 | {done, Req} -> 499 | {done, Req}; 500 | {raw, Code, #{} = Headers, <<_/binary>> = Body} -> 501 | handle_raw_response(Accept, OperationId, Responses, {raw, Code, Headers, Body}); 502 | Else -> 503 | ?LOG_ALERT(#{operationId => OperationId, invalid_response => Else, accept => Accept}), 504 | {json, 500, #{error => invalid_response}} 505 | catch 506 | C:E:ST -> 507 | ?LOG_ALERT(#{class => C, error => E, stacktrace => ST}), 508 | case ST of 509 | [{Module,OperationId,_,_}|_] when E == undef -> 510 | {json, 501, #{error => not_implemented}}; 511 | _ -> 512 | {json, 500, #{error => crashed}} 513 | end 514 | end. 515 | 516 | 517 | is_binary_content_required(Responses, Code, ContentType_) -> 518 | ContentType = binary_to_atom(content_type_bin(ContentType_), latin1), 519 | case Responses of 520 | #{Code := #{content := #{ContentType := #{schema := #{type := <<"string">>}}}}} -> true; 521 | #{Code := #{content := #{ContentType := #{schema := #{type := string}}}}} -> true; 522 | #{Code := #{content := #{ContentType := #{schema := #{type := _Some}}}}} -> false; 523 | _ -> undefined % No response with given ContentType is described for this Code 524 | end. 525 | 526 | 527 | handle_raw_response(Accept, OperationId, Responses, {raw, Code, Headers, Body}) when is_binary(Body) -> 528 | ContentType = find_content_type_header(Headers), 529 | case (is_binary_content_required(Responses, Code, ContentType) == true) andalso 530 | (Accept == any orelse 531 | is_tuple(binary:match(content_type_bin(Accept), ContentType)) orelse 532 | is_tuple(binary:match(content_type_bin(Accept), <<"*/*">>))) 533 | of 534 | true -> {ContentType, Code, Headers, Body}; 535 | false -> 536 | ?LOG_ALERT(#{operationId => OperationId, invalid_response => {raw, Code, Headers, Body}, accept => Accept}), 537 | {json, 500, #{error => invalid_response}} 538 | end. 539 | 540 | 541 | content_type_bin(text) -> <<"text/plain">>; 542 | content_type_bin(csv) -> <<"text/csv">>; 543 | content_type_bin(json) -> <<"application/json">>; 544 | content_type_bin(Bin) when is_binary(Bin) -> Bin. 545 | 546 | 547 | check_accept_type(any, false = _IsBodyBinary) -> json; 548 | check_accept_type(any, true = _IsBodyBinary) -> text; 549 | check_accept_type(Accept, _) -> Accept. 550 | 551 | 552 | find_content_type_header(Headers) when is_map(Headers)-> 553 | ContentTypes = [maps:get(HeaderName, Headers) || HeaderName <- maps:keys(Headers), string:equal(HeaderName, <<"content-type">>, true)], 554 | case ContentTypes of 555 | [ContentType|_] -> ContentType; 556 | [] -> undefined 557 | end. 558 | 559 | 560 | terminate(_,_,_) -> 561 | ok. 562 | 563 | get_schema_opts(#{schema_opts := #{} = SchemaOpts}) -> 564 | maps:map(fun 565 | (extra_obj_key,Flag) when Flag == drop; Flag == error -> ok; 566 | (K,V) -> error({bad_schema_opt,K,V}) 567 | end, SchemaOpts), 568 | SchemaOpts; 569 | get_schema_opts(#{schema_opts := _}) -> 570 | error(bad_schema_opts); 571 | get_schema_opts(#{}) -> 572 | #{}. 573 | -------------------------------------------------------------------------------- /src/openapi_handler_legacy.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_handler_legacy). 2 | 3 | -export([init/3, handle/2, terminate/3]). 4 | 5 | -export([method/1, peer/1, qs/1, parse_qs/1, bindings/1]). 6 | -export([read_body/1, reply/4, headers/1, header/2, header/3, parse_header/2]). 7 | -export([read_multipart_files/1]). 8 | 9 | init(_, Req, {Name, CowboyPath}) -> 10 | openapi_handler:do_init(Req, Name, CowboyPath, ?MODULE, #{ok => shutdown, no_handle => true}). 11 | 12 | handle(Req, #{} = Request) -> 13 | openapi_handler:do_handle(Req, Request, ?MODULE). 14 | 15 | terminate(_,_,_) -> 16 | ok. 17 | 18 | 19 | %% Cowboy compat wrapper. Provides Cowboy 2.9 API for Cowboy 1.0 20 | method(Req) -> 21 | {Method, _Req1} = cowboy_req:method(Req), 22 | Method. 23 | 24 | peer(Req) -> 25 | {Peer, _Req1} = cowboy_req:peer(Req), 26 | Peer. 27 | 28 | qs(Req) -> 29 | {QS, _Req1} = cowboy_req:qs(Req), 30 | QS. 31 | 32 | parse_qs(Req) -> 33 | try 34 | cow_qs:parse_qs(qs(Req)) 35 | catch _:_:Stacktrace -> 36 | erlang:raise(exit, {request_error, qs, 37 | 'Malformed query string; application/x-www-form-urlencoded expected.' 38 | }, Stacktrace) 39 | end. 40 | 41 | bindings(Req) -> 42 | {Bindings, _Req1} = cowboy_req:bindings(Req), 43 | maps:from_list(Bindings). 44 | 45 | read_body(Req) -> 46 | cowboy_req:body(Req). 47 | 48 | reply(Code, Headers, Body, Req) -> 49 | {ok, Req1} = cowboy_req:reply(Code, maps:to_list(Headers), Body, Req), 50 | Req1. 51 | 52 | headers(Req) -> 53 | {Headers, _Req1} = cowboy_req:headers(Req), 54 | maps:from_list(Headers). 55 | 56 | header(Name, Req) -> 57 | {Value, _Req1} = cowboy_req:header(Name, Req), 58 | Value. 59 | 60 | header(Name, Req, Default) -> 61 | {Value, _Req1} = cowboy_req:header(Name, Req, Default), 62 | Value. 63 | 64 | parse_header(Name, Req) -> 65 | {ok, Value, _Req1} = cowboy_req:parse_header(Name, Req), 66 | Value. 67 | 68 | 69 | 70 | read_multipart_files(Req) -> 71 | % This is a copy of openapi_handler:read_multipart_files/1 with 72 | % cowboy_req:part/1, cowboy_req:part_body/1 and different number of cow_multipart:form_data/1 73 | % results. 74 | % Despite other methods are cowboy_req methods in this module, it is much simpler to keep such 75 | % method instead of multiple call mocks to read_part/1 and read_part_body/1 in tests 76 | do_read_multipart_files(Req, []). 77 | 78 | do_read_multipart_files(Req0, Files) -> 79 | case cowboy_req:part(Req0) of 80 | {ok, Headers, Req1} -> 81 | {file, _FieldName, Filename, _CType, _TE} = cow_multipart:form_data(Headers), 82 | {Bin, Req2} = read_multipart_file(Req1, <<>>), 83 | do_read_multipart_files(Req2, [{Filename, Bin}| Files]); 84 | {done, Req1} -> 85 | {ok, Files, Req1} 86 | end. 87 | 88 | read_multipart_file(Req0, Bin) -> 89 | case cowboy_req:part_body(Req0) of 90 | {ok, LastBodyChunk, Req} -> {<>, Req}; 91 | {more, BodyChunk, Req} -> read_multipart_file(Req, <>) 92 | end. 93 | -------------------------------------------------------------------------------- /src/openapi_json.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_json). 2 | 3 | -export([encode/1]). 4 | -export([decode/1, decode_with_atoms/1]). 5 | 6 | -if(?OTP_RELEASE >= 27). 7 | 8 | decode(Bin) -> 9 | try 10 | json:decode(Bin) 11 | catch 12 | _:_:_ -> {error, broken_json} 13 | end. 14 | 15 | json_push_atom(Key, Value, Acc) -> 16 | [{binary_to_atom(Key), Value} | Acc]. 17 | 18 | decode_with_atoms(Bin) -> 19 | {Object, ok, <<>>} = json:decode(Bin, ok, #{object_push => fun json_push_atom/3}), 20 | Object. 21 | 22 | encode(Source) -> 23 | try 24 | iolist_to_binary(json:encode(Source)) 25 | catch 26 | _:_:_ -> iolist_to_binary(json:encode(#{error => error_in_json_encoder, input => iolist_to_binary(io_lib:format("~p",[Source]))})) 27 | end. 28 | 29 | -else. %% ?OTP_RELEASE < 27 30 | 31 | decode(Bin) -> 32 | try jsx:decode(Bin,[return_maps]) 33 | catch 34 | _:_:_ -> {error, broken_json} 35 | end. 36 | 37 | decode_with_atoms(Bin) -> 38 | jsx:decode(Bin,[return_maps,{labels,atom}]). 39 | 40 | encode(Source) -> 41 | try jsx:encode(Source) 42 | catch 43 | _:_:_ -> jsx:encode(#{error => error_in_json_encoder, input => iolist_to_binary(io_lib:format("~p",[Source]))}) 44 | end. 45 | 46 | -endif. %% ?OTP_RELEASE 47 | -------------------------------------------------------------------------------- /src/openapi_schema.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_schema). 2 | -include_lib("kernel/include/logger.hrl"). 3 | 4 | -export([load_schema/2, type/2]). 5 | -export([process/2]). 6 | 7 | 8 | -define(IS_SCALAR_TYPE(Scalar), ( 9 | Scalar == <<"integer">> orelse 10 | Scalar == <<"number">> orelse 11 | Scalar == <<"string">> orelse 12 | Scalar == <<"boolean">> 13 | )). 14 | 15 | -define(IS_SCALAR(Schema),( 16 | (is_map_key(type,Schema) andalso ?IS_SCALAR_TYPE(map_get(type,Schema))) orelse 17 | is_map_key(const,Schema) 18 | )). 19 | 20 | -define(AVAILABLE_EXPLAIN_KEYS, [required]). 21 | 22 | 23 | -spec load_schema(Schema :: map(), Name :: atom()) -> Schema :: map(). 24 | load_schema(Schema, Name) -> 25 | #{components := #{schemas := Schemas}} = Schema, 26 | [persistent_term:put({openapi_handler_schema,Name,atom_to_binary(Type,latin1)}, prepare_type(TypeSchema)) || 27 | {Type,TypeSchema} <- maps:to_list(Schemas)], 28 | persistent_term:put({openapi_handler_schema,Name},Schema), 29 | Schema. 30 | 31 | -spec type(SchemaName :: atom(), TypeName :: atom()) -> #{}. 32 | type(SchemaName, TypeName) -> 33 | persistent_term:get({openapi_handler_schema, SchemaName, atom_to_binary(TypeName)}). 34 | 35 | 36 | process(Input, #{} = Opts) -> 37 | maps:map(fun 38 | (schema,_) -> ok; 39 | (whole_schema,#{}) -> ok; 40 | (type,T) when is_atom(T) -> ok; 41 | (name,_) -> ok; 42 | (array_convert,Flag) when Flag == true; Flag == false -> ok; 43 | (auto_convert,Flag) when Flag == true; Flag == false -> ok; 44 | (validators,#{} = V) -> V; 45 | (query,Flag) when Flag == true; Flag == false -> ok; 46 | (apply_defaults,Flag) when Flag == true; Flag == false -> ok; 47 | (patch,Flag) when Flag == true; Flag == false -> ok; 48 | (extra_obj_key,Flag) when Flag == drop; Flag == error -> ok; 49 | (required_obj_keys,Flag) when Flag == drop; Flag == error -> ok; 50 | (access_type,Flag) when Flag == read; Flag == write -> ok; 51 | (explain,FlagList) -> check_explain_keys(FlagList); 52 | (K,V) -> error({unknown_option,K,V}) 53 | end, Opts), 54 | Schema = case Opts of 55 | #{schema := Schema_} -> Schema_; 56 | #{type := Type} -> #{'$ref' => <<"#/components/schemas/", (atom_to_binary(Type,latin1))/binary>>}; 57 | _ -> error(not_specified_schema) 58 | end, 59 | DefaultArrayConvert = case maps:get(auto_convert, Opts, true) of 60 | true -> true; 61 | false -> false 62 | end, 63 | DefaultOpts = #{ 64 | query => false, 65 | patch => false, 66 | array_convert => DefaultArrayConvert, 67 | auto_convert => true, 68 | extra_obj_key => drop, 69 | required_obj_keys => drop, 70 | access_type => read 71 | }, 72 | Validators = maps:merge(default_validators(), maps:get(validators, Opts, #{})), 73 | FinalOpts = (maps:merge(DefaultOpts,Opts))#{validators => Validators}, 74 | case encode3(Schema, FinalOpts, Input, []) of 75 | {error, Error} -> 76 | {error, Error}; 77 | R -> 78 | R 79 | end. 80 | 81 | 82 | 83 | prepare_type(#{allOf := Types} = Type0) -> 84 | Type0#{allOf := [prepare_type(T) || T <- Types]}; 85 | prepare_type(#{anyOf := Types} = Type0) -> 86 | Type0#{anyOf := [prepare_type(T) || T <- Types]}; 87 | prepare_type(#{oneOf := Types} = Type0) -> 88 | Type0#{oneOf := [prepare_type(T) || T <- Types]}; 89 | prepare_type(#{type := <<"object">>, properties := Props} = Type0) -> 90 | Type0#{properties => maps:map(fun(_, T) -> prepare_type(T) end, Props)}; 91 | prepare_type(#{} = Type0) -> 92 | % Convert format name to atom. This matches validators syntax 93 | Type1 = case Type0 of 94 | #{format := BinFormat} when is_binary(BinFormat) -> 95 | Type0#{format := binary_to_atom(BinFormat)}; 96 | #{} -> 97 | Type0 98 | end, 99 | % precompile patterns 100 | Type2 = case Type1 of 101 | #{pattern := Pattern} -> 102 | {ok, MP} = re:compile(Pattern, []), 103 | Type1#{pattern := MP}; 104 | #{} -> 105 | Type1 106 | end, 107 | Type2. 108 | 109 | 110 | % FIXME: Decide whether default formats should be supported by this library. 111 | % - JSON Schema Validation formats (https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-7.3) 112 | % - OAS (https://spec.openapis.org/oas/v3.1.0.html#data-types) 113 | % JSON schema defines many non-trivial formats (e.g. RFC 3339 date-time, idn-email, iri-reference, etc.), 114 | % and supporting all of them properly may require lots of maintenance work. 115 | % Formats added by OAS are quite simple though. 116 | default_validators() -> 117 | #{}. 118 | 119 | 120 | encode3(_, #{query := true}, not_null, _) -> 121 | not_null; 122 | 123 | encode3(_, #{query := true}, null, _) -> 124 | undefined; 125 | 126 | encode3(#{nullable := true}, _, undefined, _) -> 127 | undefined; 128 | 129 | encode3(#{nullable := true}, _, null, _) -> 130 | undefined; 131 | 132 | encode3(#{}, #{patch := true}, null, _) -> 133 | undefined; 134 | 135 | encode3(Schema, #{} = Opts, Null, Path) when Null == null orelse Null == undefined -> 136 | error(#{error => must_not_get_here, path => Path, null => Null, opts => Opts, schema => Schema}); 137 | 138 | encode3(#{'$ref' := <<"#/components/schemas/",Ref/binary>>}, #{} = Opts, Input, Path) -> 139 | TypeName = binary_to_atom(Ref,latin1), % It is ok, because this is a limited and trusted schema 140 | Type = case Opts of 141 | #{whole_schema := #{components := #{schemas := #{TypeName := T}}}} -> T; 142 | #{name := Name} -> persistent_term:get({openapi_handler_schema,Name,Ref}) 143 | end, 144 | encode3(Type, Opts, Input, Path ++ [Ref]); 145 | 146 | encode3(#{allOf := [Choice]}, Opts, Input, Path) -> 147 | encode3(Choice, Opts, Input, Path ++ [0]); 148 | 149 | encode3(#{allOf := Choices}, Opts, Input, Path) -> 150 | Encoded = lists:foldl(fun 151 | (_, {error, _} = E) -> 152 | E; 153 | ({N,Choice}, Obj) -> 154 | case encode3(Choice, Opts, Input, Path ++ [N]) of 155 | {error, #{extra_keys := _Extrakeys, encoded := Obj1}} -> 156 | merge_objects(Opts, Choice, [Obj, Obj1]); % keep processing, all choices have to be processed for allOf 157 | {error, E} -> 158 | {error, E}; 159 | #{} = Obj1 -> 160 | merge_objects(Opts, Choice, [Obj, Obj1]) 161 | end 162 | end, #{}, lists:zip(lists:seq(0,length(Choices)-1),Choices)), 163 | check_extra_keys(Input, Encoded, Opts); 164 | 165 | encode3(#{anyOf := Choices}, Opts, Input, Path) -> 166 | Count = length(Choices), 167 | F = fun 168 | F([], LastError) -> 169 | {error, LastError}; 170 | F([Choice|List], _) -> 171 | case encode3(Choice, Opts, Input, Path ++ [Count - length(List) - 1]) of 172 | {error, #{extra_keys := _Extrakeys, encoded := Encoded}} -> 173 | % TODO: check anyOf semantics, add explicit tests for that 174 | Encoded; 175 | {error, Error} -> 176 | F(List, Error); 177 | EncodedItem -> 178 | EncodedItem 179 | end 180 | end, 181 | Encoded = F(Choices, #{error => unmatched_anyOf, path => Path}), 182 | check_extra_keys(Input, Encoded, Opts); 183 | 184 | encode3(#{oneOf := Types, discriminator := #{propertyName := DKey, mapping := DMap}}, Opts, Input, Path) -> 185 | % If possible, get the discriminator value as atom (for lookup in mapping) 186 | ADKey = binary_to_atom(DKey), 187 | DefaultFun = fun(Input_) -> 188 | case Input_ of 189 | #{DKey := DValue} when is_atom(DValue) -> DValue; 190 | #{ADKey := DValue} when is_atom(DValue) -> DValue; 191 | #{DKey := DValue} when is_binary(DValue) -> 192 | try binary_to_existing_atom(DValue) catch error:badarg -> DValue end; 193 | #{ADKey := DValue} when is_binary(DValue) -> 194 | try binary_to_existing_atom(DValue) catch error:badarg -> DValue end; 195 | #{} -> undefined 196 | end 197 | end, 198 | ADvalue1 = DefaultFun(Input), 199 | ADvalue2 = case ADvalue1 of 200 | undefined -> 201 | Try = encode3(hd(Types), Opts#{apply_defaults => true}, Input, Path), 202 | DefaultFun(Try); 203 | _ -> 204 | ADvalue1 205 | end, 206 | DChoice = maps:get(ADvalue2, DMap, undefined), 207 | case {ADvalue2, DChoice} of 208 | {undefined, _} -> 209 | {error, #{error => discriminator_missing, path => Path, propertyName => DKey}}; 210 | {_, undefined} -> 211 | {error, #{error => discriminator_unmapped, path => Path, propertyName => DKey, value => ADvalue2}}; 212 | {_, _} -> 213 | encode3(#{'$ref' => DChoice}, Opts, Input, Path) 214 | end; 215 | 216 | encode3(#{oneOf := Choices}, Opts, Input, Path) -> 217 | EncodedList = lists:map(fun({Choice,I}) -> 218 | case encode3(Choice, Opts, Input, Path ++ [I]) of 219 | {error, #{extra_keys := _Extrakeys, encoded := Encoded}} -> 220 | % Wrong oneOf choice. Will try other ones and check results 221 | Encoded; 222 | {error, E} -> 223 | {error, E}; 224 | V -> {ok, V} 225 | end 226 | end, lists:zip(Choices,lists:seq(0,length(Choices)-1))), 227 | %% If there are several valid results, choose non-empty one 228 | case [M || {ok, #{} = M} <- EncodedList, map_size(M) > 0] of 229 | [Encoded|_] -> Encoded; 230 | _ -> 231 | case [V || {ok, V} <- EncodedList] of 232 | [Encoded|_] -> Encoded; 233 | _ -> hd(EncodedList) 234 | end 235 | end; 236 | 237 | encode3(#{type := <<"object">>, maxItems := MaxItems}, #{}, #{} = Input, Path) when map_size(Input) > MaxItems -> 238 | {error, #{error => too_many_items, detail => map_size(Input), path => Path}}; 239 | 240 | encode3(#{type := <<"object">>, minItems := MinItems}, #{}, #{} = Input, Path) when map_size(Input) < MinItems -> 241 | {error, #{error => too_few_items, detail => map_size(Input), path => Path}}; 242 | 243 | encode3(#{type := <<"object">>, properties := Properties} = Schema, #{query := Query} = Opts, #{} = Input, Path) -> 244 | Artificial = #{ 245 | '$position' => #{type => <<"integer">>}, 246 | '$reset' => #{type => <<"boolean">>}, 247 | '$index' => #{type => <<"integer">>}, 248 | '$delete' => #{type => <<"boolean">>} 249 | }, 250 | Encoded = maps:fold(fun 251 | (_, _, {error, _} = E) -> 252 | E; 253 | (Field, #{} = Prop, Obj) -> 254 | FieldBin = atom_to_binary(Field,latin1), 255 | 256 | RequiredKeys = get_required_keys(Schema, Opts), 257 | IsReadOnly = maps:get(readOnly, Prop, false), 258 | IsPrimary = maps:get('x-primary-key', Prop, false), 259 | IsRequired = (lists:member(FieldBin, RequiredKeys) orelse IsPrimary), 260 | IsWriteAccess = maps:get(access_type, Opts, read) == write, 261 | 262 | ExtractedValue = case Input of 263 | #{Field := Value_} -> 264 | {ok, Value_}; 265 | #{FieldBin := Value_} -> 266 | {ok, Value_}; 267 | #{} -> 268 | undefined; 269 | _ -> 270 | error(#{input => Input, field => Field, prop => Prop, obj => Obj, path => Path}) 271 | end, 272 | ApplyDefaults = maps:get(apply_defaults, Opts, false), 273 | Default = case Prop of 274 | #{default := DefaultValue} when ApplyDefaults -> #{Field => DefaultValue}; 275 | _ -> #{} 276 | end, 277 | 278 | NullableProp = case Prop of 279 | #{nullable := true} -> 280 | true; 281 | #{oneOf := OneOf} -> 282 | lists:any(fun(#{type := <<"null">>}) -> true; (_) -> false end, OneOf); 283 | #{} -> 284 | false 285 | end, 286 | Patching = maps:get(patch, Opts, undefined) == true, 287 | UpdatedObj = case ExtractedValue of 288 | {ok, NullFlag} when Query andalso (NullFlag == null orelse NullFlag == not_null) -> 289 | Obj#{Field => NullFlag}; 290 | 291 | % Silently drop undefined values for non-nullable fields 292 | {ok, Null} when (Null == null orelse Null == undefined) andalso 293 | not NullableProp andalso not Patching -> 294 | Obj; 295 | % Silently drop read only fields with write access 296 | {ok, _Value} when IsWriteAccess andalso IsReadOnly andalso (not IsRequired) -> 297 | Obj; 298 | {ok, Value} -> 299 | case encode3(Prop#{nullable => NullableProp}, Opts, Value, Path ++ [Field]) of 300 | {error, _} = E -> 301 | E; 302 | Value1 when Query andalso (is_number(Value1) orelse is_atom(Value1) orelse is_binary(Value1)) -> 303 | Obj#{Field => maps:get(Field,Obj,[]) ++ [Value1]}; 304 | Value1 -> 305 | Obj#{Field => Value1} 306 | end; 307 | undefined -> 308 | maps:merge(Default,Obj) 309 | end, 310 | UpdatedObj 311 | end, #{}, maps:merge(Artificial,Properties)), 312 | Encoded1 = case check_required_keys(Encoded, Schema, Opts) of 313 | {error, E} -> {error, E}; 314 | Encoded -> check_extra_keys(Input, Encoded, Opts) 315 | end, 316 | merge_objects(Opts, Schema, [Encoded1]); 317 | 318 | 319 | encode3(#{type := <<"object">>}, _Opts, #{} = Input, _Path) -> 320 | Input; 321 | 322 | encode3(#{type := <<"object">>}, _Opts, Input, Path) -> 323 | {error, #{error => not_object, path => Path, input => Input}}; 324 | 325 | encode3(#{type := <<"array">>, maxItems := MaxItems}, _Opts, Input, Path) when is_list(Input) andalso length(Input) > MaxItems -> 326 | {error, #{error => too_many_items, path => Path, detail => length(Input)}}; 327 | encode3(#{type := <<"array">>, minItems := MinItems}, _Opts, Input, Path) when is_list(Input) andalso length(Input) < MinItems -> 328 | {error, #{error => too_few_items, path => Path, detail => length(Input)}}; 329 | 330 | encode3(#{type := <<"array">>, items := ItemSpec}, Opts, Input, Path) when is_list(Input) -> 331 | NullableItems = maps:get(nullable, ItemSpec, undefined) == true, 332 | Count = length(Input), 333 | Encoded = lists:foldr(fun 334 | (_,{error, E}) -> 335 | {error, E}; 336 | (Null, Acc) when (Null == null orelse Null == undefined) andalso not NullableItems -> 337 | {error, #{error => null_in_array_of_non_nullable, path => Path ++ [Count - length(Acc)], input => Null}}; 338 | (Item, Acc) -> 339 | case encode3(ItemSpec, Opts, Item, Path ++ [Count - length(Acc)]) of 340 | {error, E} -> {error, E}; 341 | EncodedItem -> [EncodedItem|Acc] 342 | end 343 | end, [], Input), 344 | Encoded; 345 | 346 | encode3(#{type := <<"array">>} = Spec, #{auto_convert := true, array_convert := true} = O, Input, Path) when is_binary(Input) -> 347 | encode3(Spec, O, binary:split(Input, <<",">>, [global]), Path); 348 | 349 | encode3(#{type := <<"array">>, items := _ItemSpec}, _Opts, Input, Path) when not is_list(Input) -> 350 | {error, #{error => not_array, path => Path, input => Input}}; 351 | 352 | encode3(#{type := <<"string">>}, #{query := true}, #{} = Input, _Path) -> 353 | Input; 354 | 355 | encode3(#{} = Schema, #{query := true} = Opts, #{} = Input, Path) when ?IS_SCALAR(Schema) -> 356 | Encoded = lists:foldl(fun 357 | (_,{error,E}) -> 358 | {error,E}; 359 | ({Compare,StrVal}, Acc) -> 360 | case encode3(Schema, Opts, StrVal, Path ++ [Compare]) of 361 | {error, E} -> 362 | {error, E}; 363 | Val1 -> 364 | Acc ++ [{Compare,Val1}] 365 | end 366 | end, [], maps:to_list(Input)), 367 | case Encoded of 368 | {error, _} -> Encoded; 369 | _ -> maps:from_list(Encoded) 370 | end; 371 | 372 | encode3(#{} = Schema, #{query := true} = Opts, [<<_/binary>>|_] = Input, Path) when ?IS_SCALAR(Schema) -> 373 | lists:foldl(fun 374 | (_, {error, E}) -> 375 | {error, E}; 376 | (OneInput, Acc) -> 377 | case encode3(Schema, Opts, OneInput, Path) of 378 | {error, E} -> {error, E}; 379 | Value -> Acc ++ [Value] 380 | end 381 | end, [], Input); 382 | 383 | 384 | encode3(#{type := <<"integer">>} = Schema, #{auto_convert := Convert}, Input, Path) -> 385 | case Input of 386 | _ when is_integer(Input) -> encode_number(Schema, Input, Path); 387 | _ when is_binary(Input) andalso Convert == true -> 388 | case string:to_integer(Input) of 389 | {IntValue, <<>>} -> encode_number(Schema, IntValue, Path); 390 | _ -> {error, #{error => not_integer, path => Path, input => Input}} 391 | end; 392 | _ -> {error, #{error => not_integer, path => Path, input => Input}} 393 | end; 394 | 395 | encode3(#{type := <<"number">>} = Schema, #{auto_convert := Convert}, Input, Path) -> 396 | case Input of 397 | _ when is_integer(Input) orelse is_float(Input) -> encode_number(Schema, Input, Path); 398 | _ when is_binary(Input) andalso Convert == true -> 399 | case string:to_integer(Input) of 400 | {IntValue, <<>>} -> 401 | encode_number(Schema, IntValue, Path); 402 | _ -> 403 | case string:to_float(Input) of 404 | {FloatValue,<<>>} -> encode_number(Schema, FloatValue, Path); 405 | _ -> {error, #{error => not_integer, path => Path, input => Input}} 406 | end 407 | end; 408 | _ -> {error, #{error => not_number, path => Path, input => Input}} 409 | end; 410 | 411 | encode3(#{const := Value}, #{auto_convert := Convert}, Input, Path) when is_atom(Input) orelse is_binary(Input) orelse is_integer(Input) -> 412 | case Input of 413 | <> when Convert -> 414 | binary_to_atom(Value,latin1); 415 | Value -> 416 | Input; 417 | _ when is_integer(Value) -> 418 | case integer_to_binary(Value) of 419 | Input -> Value; 420 | _ -> {error, #{error => not_const3, path => Path, input => Input, value => Value}} 421 | end; 422 | _ when is_atom(Input) andalso Convert == true -> 423 | case atom_to_binary(Input,latin1) of 424 | Value -> Input; 425 | _ -> {error, #{error => not_const1, path => Path, input => Input}} 426 | end; 427 | _ -> {error, #{error => not_const2, path => Path, input => Input, value => Value}} 428 | end; 429 | 430 | encode3(#{enum := Choices, type := <<"string">>}, #{auto_convert := Convert}, Input, Path) -> 431 | InputValue = case Input of 432 | _ when is_binary(Input) -> Input; 433 | _ when is_atom(Input) -> atom_to_binary(Input, latin1); 434 | _ -> {error, #{error => not_string, path => Path}} 435 | end, 436 | case lists:member(InputValue, Choices) of 437 | true when is_binary(Input) andalso Convert -> binary_to_atom(Input,latin1); 438 | true when is_atom(Input) -> Input; 439 | false -> {error, #{unknown_enum_option => Input, path => Path, available => Choices}} 440 | end; 441 | 442 | encode3(#{type := <<"string">>} = Spec, #{auto_convert := Convert} = Options, Input, Path) -> 443 | {Input1, InputForValidation} = case Input of 444 | _ when is_binary(Input) -> {Input, Input}; 445 | _ when is_atom(Input) andalso Convert -> {atom_to_binary(Input), atom_to_binary(Input)}; 446 | _ when is_atom(Input) -> {Input, atom_to_binary(Input)}; 447 | _ -> {{error, #{error => not_string, path => Path, input => Input}}, undefined} 448 | end, 449 | case Input1 of 450 | {error, _} -> 451 | Input1; 452 | _ -> 453 | case Spec of 454 | #{minLength := MinLength} when size(InputForValidation) < MinLength -> 455 | {error, #{error => too_short, path => Path, input => Input, min_length => MinLength}}; 456 | #{maxLength := MaxLength} when size(InputForValidation) > MaxLength -> 457 | {error, #{error => too_long, path => Path, input => Input, max_length => MaxLength}}; 458 | #{} -> 459 | Format = maps:get(format, Spec, undefined), 460 | Validators = maps:get(validators, Options), 461 | FormatChecked = validate_string_format(InputForValidation, Format, maps:get(Format, Validators, undefined), Convert), 462 | PatternChecked = validate_string_pattern(FormatChecked, maps:get(pattern, Spec, undefined)), 463 | case PatternChecked of 464 | {error, Error} -> 465 | {error, Error#{path => Path, input => Input1}}; 466 | <<_/binary>> when Convert -> 467 | PatternChecked; 468 | <<_/binary>> -> 469 | Input1 470 | end 471 | end 472 | end; 473 | 474 | 475 | encode3(#{type := <<"boolean">>}, #{auto_convert := Convert}, Input, Path) -> 476 | case Input of 477 | true -> true; 478 | false -> false; 479 | <<"true">> when Convert -> true; 480 | <<"false">> when Convert -> false; 481 | _ -> {error, #{error => not_boolean, path => Path, input => Input}} 482 | end; 483 | 484 | encode3(#{type := <<"null">>}, #{}, Input, Path) -> 485 | case Input of 486 | null -> undefined; 487 | undefined -> undefined; 488 | _ -> {error, #{error => not_null, path => Path, input => Input}} 489 | end; 490 | 491 | encode3(#{type := [_| _] = Types} = Schema, Opts, Input, Path) -> 492 | encode3_multi_types(Types, Schema, Opts, Input, Path). 493 | 494 | encode3_multi_types([], _Schema, _Opts, Input, Path) -> 495 | {error, #{error => invalid_type, path => Path, input => Input}}; 496 | encode3_multi_types([Type| Types], Schema, Opts, Input, Path) -> 497 | case encode3(Schema#{type => Type}, Opts, Input, Path) of 498 | {error, _ } -> encode3_multi_types(Types, Schema, Opts, Input, Path); 499 | Encoded -> Encoded 500 | end. 501 | 502 | 503 | encode_number(#{minimum := Min}, Input, Path) when Input < Min -> 504 | {error, #{error => input_out_of_range, path => Path, input => Input, minimum => Min}}; 505 | 506 | encode_number(#{maximum := Max}, Input, Path) when Input > Max -> 507 | {error, #{error => input_out_of_range, path => Path, input => Input, maximum => Max}}; 508 | 509 | encode_number(_Schema, Input, _Path) -> 510 | Input. 511 | 512 | 513 | validate_string_format(Input, _, undefined, _) -> 514 | Input; 515 | validate_string_format(Input, Format, Validator, Convert) -> 516 | case Validator(Input) of 517 | {ok, Converted} when (not Convert) andalso Converted /= Input -> 518 | % The value is mostly ok, but needs to be converted 519 | {error, #{error => needs_convertation, format => Format}}; 520 | {ok, Input2} -> 521 | Input2; 522 | {error, FmtError} -> 523 | Err0 = #{error => wrong_format, format => Format}, 524 | {error, maps:merge(Err0, FmtError)} 525 | end. 526 | 527 | validate_string_pattern({error, _} = Error, _) -> 528 | Error; 529 | validate_string_pattern(Input, undefined) -> 530 | Input; 531 | validate_string_pattern(Input, RegExp) -> 532 | case re:run(Input, RegExp) of 533 | {match, _} -> 534 | Input; 535 | nomatch -> 536 | {error, #{error => nomatch_pattern, pattern => RegExp}} 537 | end. 538 | 539 | 540 | check_required_keys(#{} = Encoded, #{} = Schema, #{required_obj_keys := error} = Opts) -> 541 | Required = get_required_keys(Schema, Opts), 542 | case Required -- maps:keys(add_binary_keys(Encoded)) of 543 | [] -> Encoded; 544 | Missing -> {error, #{missing_required => Missing}} 545 | end; 546 | check_required_keys(Encoded, _Schema, _Opts) -> 547 | Encoded. 548 | 549 | get_required_keys(#{required := [_| _] = Required, properties := Properties}, #{access_type := Access}) -> 550 | Properties1 = add_binary_keys(Properties), 551 | lists:filter(fun(P) -> 552 | case Properties1 of 553 | #{P := #{readOnly := true}} when Access == write -> false; 554 | #{P := #{writeOnly := true}} when Access == read -> false; 555 | #{P := _} -> true; 556 | _ -> false 557 | end 558 | end, Required); 559 | get_required_keys(_Schema, _Opts) -> 560 | []. 561 | 562 | 563 | check_extra_keys(Input, Encoded, #{extra_obj_key := error} = Opts) when is_map(Input) andalso is_map(Encoded) -> 564 | ExtraKeys = maps:keys(Input) -- maps:keys(add_binary_keys(Encoded)), 565 | case ExtraKeys of 566 | ['$explain'] when #{explain => [required]} == Opts -> Encoded; 567 | [_|_] -> {error, #{extra_keys => ExtraKeys, encoded => Encoded}}; 568 | _ -> Encoded 569 | end; 570 | 571 | check_extra_keys(_Input, Encoded, _Opts) -> 572 | Encoded. 573 | 574 | 575 | add_binary_keys(Map) -> 576 | maps:fold(fun 577 | (K, V, Acc) when is_atom(K) -> Acc#{atom_to_binary(K) => V}; 578 | (_K, _V, Acc) -> Acc 579 | end, Map, Map). 580 | 581 | 582 | merge_objects(Opts, Schema, Objs) -> 583 | ExplainMap = case Opts of 584 | #{explain := [required]} -> 585 | RequiredKeys = lists:foldl(fun 586 | ({error, _}, Acc) -> Acc; 587 | (Obj, Acc) -> lists:merge(maps:get(required, maps:get('$explain', Obj, #{}), []), Acc) 588 | end, get_required_keys(Schema, Opts), Objs), 589 | #{'$explain' => #{required => RequiredKeys}}; 590 | _ -> #{} 591 | end, 592 | lists:foldl(fun 593 | (Obj, Acc) when is_map(Obj) andalso is_map(Acc) -> maps:merge(Obj, Acc); 594 | ({error, E}, _) -> {error, E}; 595 | (_, {error, E}) -> {error, E} 596 | end, ExplainMap, Objs). 597 | 598 | check_explain_keys(Keys) when is_list(Keys) -> 599 | [error({unknown_option,explain,Key}) || Key <- Keys, lists:member(Key, ?AVAILABLE_EXPLAIN_KEYS) =/= true], 600 | ok; 601 | 602 | check_explain_keys(Keys) -> 603 | error({unknown_option,explain,Keys}). 604 | -------------------------------------------------------------------------------- /test/done_req.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: Sample API 4 | version: 0.0.1 5 | 6 | servers: 7 | - url: http://api.example.com/v1 8 | 9 | components: 10 | schemas: 11 | Error: 12 | type: object 13 | properties: 14 | code: 15 | type: integer 16 | message: 17 | type: string 18 | 19 | securitySchemes: 20 | BasicAuth: 21 | type: http 22 | scheme: basic 23 | 24 | security: 25 | - BasicAuth: [] 26 | 27 | paths: 28 | /putFile: 29 | put: 30 | operationId: putFile 31 | summary: Upload file 32 | responses: 33 | '200': 34 | description: Ok 35 | requestBody: 36 | content: 37 | '*/*': 38 | schema: 39 | type: string 40 | format: binary 41 | -------------------------------------------------------------------------------- /test/fake_petstore.erl: -------------------------------------------------------------------------------- 1 | -module(fake_petstore). 2 | -compile([nowarn_export_all, export_all]). 3 | 4 | 5 | 6 | authorize(_) -> 7 | #{auth => yes_please}. 8 | 9 | 10 | % Simple callback with no parameters 11 | logoutUser(_) -> 12 | #{say => goodbye}. 13 | 14 | % A parameter in path. Schema says id is integer, so it is converted even if handler returns it as a binary 15 | getUserByName(#{username := <<"John">>}) -> 16 | #{username => <<"John">>, id => <<"2384572">>, pet => undefined}; 17 | 18 | getUserByName(#{username := <<"Jack">>}) -> 19 | #{username => <<"Jack">>, id => <<"238457234857">>}. 20 | 21 | updateUser(#{username := <<"Mary">>, json_body := Body}) -> 22 | ExcessFieldsMap = maps:without([id, pet, username, firstName, lastName, email, password, phone, userStatus, image, addresses, items], Body), 23 | 0 == maps:size(ExcessFieldsMap) orelse ct:fail("There is excess field in map: ~p", [ExcessFieldsMap]), 24 | case Body of 25 | #{firstName := undefined} -> #{username => <<"Mary">>, id => 15}; 26 | _ -> {json, 400, #{error => must_erase_firstName, body => Body}} 27 | end. 28 | 29 | % A parameter in a query string 30 | findPetsByStatus(#{status := [pending,sold]}) -> 31 | {json, 200, [#{name => <<"Dingo">>, photoUrls => 1}]}. 32 | 33 | % Object in a JSON body 34 | placeOrder(#{json_body := #{petId := 7214, status := placed} = Order}) -> 35 | Order#{quantity => 31}. 36 | 37 | 38 | % This responses simulates response of openapi_client:call 39 | getInventory(#{}) -> 40 | {error, {501, #{error => <<"not_implemented">>}}}. 41 | -------------------------------------------------------------------------------- /test/multiple-upload.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: Sample API 4 | version: 0.0.1 5 | 6 | servers: 7 | - url: http://api.example.com/v1 8 | 9 | components: 10 | schemas: 11 | Error: 12 | type: object 13 | properties: 14 | code: 15 | type: integer 16 | message: 17 | type: string 18 | 19 | securitySchemes: 20 | BasicAuth: 21 | type: http 22 | scheme: basic 23 | 24 | security: 25 | - BasicAuth: [] 26 | 27 | paths: 28 | /uploadFiles: 29 | post: 30 | operationId: uploadFiles 31 | summary: Upload via multipart requests 32 | responses: 33 | '200': 34 | description: Ok 35 | requestBody: 36 | content: 37 | multipart/form-data: 38 | schema: 39 | type: object 40 | properties: 41 | file: 42 | type: array 43 | items: 44 | type: string 45 | format: binary 46 | -------------------------------------------------------------------------------- /test/openapi_collection_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_collection_SUITE). 2 | -compile([nowarn_export_all, export_all]). 3 | 4 | all() -> 5 | [ 6 | {group, filter}, 7 | {group,query} 8 | ]. 9 | 10 | groups() -> 11 | [ 12 | {filter, [filter_enum_types]}, 13 | {query, [], [ % parallel 14 | param_no_value, 15 | filter_eq, 16 | filter_like, 17 | filter_gt, 18 | filter_gte, 19 | filter_lt, 20 | filter_lte, 21 | filter_gt_lt, 22 | filter_is_not_null, 23 | next_cursor, 24 | next_cursor_with_sort_doesnt_have_position, 25 | prev_cursor, 26 | encode_filter, 27 | encode_select 28 | ]} 29 | ]. 30 | 31 | 32 | init_per_suite(Config) -> 33 | SchemaPath = code:lib_dir(openapi_handler, test) ++ "/flussonic-230127.json", 34 | openapi_handler:load_schema(SchemaPath, test_openapi), 35 | Config. 36 | 37 | end_per_suite(Config) -> 38 | Config. 39 | 40 | 41 | qs(Proplist) -> 42 | openapi_collection:parse_qs(cow_qs:qs(Proplist), #{limit => 100, collection_type => stream_config, schema_name => test_openapi}). 43 | 44 | q(Proplist) -> 45 | openapi_collection:list(dataset(), qs(Proplist)). 46 | 47 | encode_qs(#{} = Query) -> 48 | openapi_collection:qs(Query). 49 | 50 | 51 | filter_enum_types(_) -> 52 | Dataset = [ 53 | #{id => 1, key1 => testvalue}, 54 | #{id => 2, key1 => <<"testvalue">>}, 55 | #{id => 3, key1 => othervalue}, 56 | #{id => 3, key1 => <<"othervalue">>}, 57 | #{id => 4, key2 => testvalue} 58 | ], 59 | #{estimated_count := 2, items := [#{id := 1}, #{id := 2}]} = openapi_collection:list(Dataset, #{filter => #{key1 => [testvalue]}}), 60 | ok. 61 | 62 | 63 | param_no_value(_) -> 64 | openapi_collection:parse_qs(<<"named_by&stats.media_info.title">>, #{limit => 100, collection_type => stream_config, schema_name => test_openapi}), 65 | ok. 66 | 67 | filter_eq(_) -> 68 | % #{estimated_count := 7} = q([{<<"named_by">>,<<"config">>}]), 69 | #{estimated_count := 1} = q([{<<"stats.media_info.title">>,<<"C03">>}]), 70 | ok. 71 | 72 | filter_like(_) -> 73 | % #{items := [#{name := <<"c/08">>},#{name := <<"c/09">>}]} = q([{<<"named_by_like">>,<<"remote">>}]), 74 | #{items := [#{name := <<"c/03">>}]} = q([{<<"stats.media_info.title_like">>,<<"C03">>}]), 75 | ok. 76 | 77 | 78 | 79 | filter_gt(_) -> 80 | #{items := [#{name := <<"c/09">>}]} = q([{<<"name_gt">>,<<"c/08">>}]), 81 | #{items := [#{name := <<"c/09">>}]} = q([{<<"stats.opened_at_gt">>,<<"1631102874142">>}]), 82 | #{items := [#{name := <<"c/08">>}]} = q([{<<"stats.client_count_gt">>,<<"4">>}]), 83 | ok. 84 | 85 | 86 | 87 | filter_gte(_) -> 88 | #{items := [#{name := <<"c/08">>},#{name := <<"c/09">>}]} = q([{<<"name_gte">>,<<"c/08">>}]), 89 | #{items := [#{name := <<"c/08">>},#{name := <<"c/09">>}]} = q([{<<"stats.opened_at_gte">>,<<"1631102874142">>}]), 90 | #{items := [#{name := <<"c/08">>}]} = q([{<<"stats.client_count_gte">>,<<"5">>}]), 91 | ok. 92 | 93 | 94 | filter_lt(_) -> 95 | #{items := [#{name := <<"c/01">>}]} = q([{<<"name_lt">>,<<"c/02">>}]), 96 | #{items := [#{name := <<"c/01">>}]} = q([{<<"stats.opened_at_lt">>,<<"1631102868070">>}]), 97 | ok. 98 | 99 | 100 | 101 | filter_lte(_) -> 102 | #{items := [#{name := <<"c/01">>},#{name := <<"c/02">>}]} = q([{<<"name_lte">>,<<"c/02">>}]), 103 | #{items := [#{name := <<"c/01">>},#{name := <<"c/02">>}]} = q([{<<"stats.opened_at_lte">>,<<"1631102868070">>}]), 104 | ok. 105 | 106 | 107 | 108 | filter_gt_lt(_) -> 109 | #{items := [#{name := <<"c/05">>}]} = q([{<<"name_gt">>,<<"c/04">>},{<<"name_lt">>,<<"c/06">>}]), 110 | #{items := [#{name := <<"c/05">>}]} = q([{<<"stats.opened_at_gt">>,<<"1631102868079">>},{<<"stats.opened_at_lt">>,<<"1631102868089">>}]), 111 | ok. 112 | 113 | filter_is_not_null(_) -> 114 | #{items := [#{name := <<"c/02">>}]} = q([{<<"dvr_is_not">>,<<"null">>}]), 115 | ok. 116 | 117 | 118 | next_cursor(_) -> 119 | #{next := <>, items := [#{name := <<"c/01">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>}]), 120 | #{next := <>, items := [#{name := <<"c/04">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>},{<<"cursor">>,Next1}]), 121 | #{next := undefined, items := [#{name := <<"c/07">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>},{<<"cursor">>,Next2}]), 122 | ok. 123 | 124 | next_cursor_with_sort_doesnt_have_position(_) -> 125 | #{next := <>, items := [#{name := <<"c/01">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>}]), 126 | Q = cow_qs:parse_qs(base64:decode(Next)), 127 | [{<<"name_gt">>,<<"c/03">>}] = Q, 128 | ok. 129 | 130 | prev_cursor(_) -> 131 | #{prev := undefined, next := <>, items := [#{name := <<"c/01">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>}]), 132 | #{prev := <>, items := [#{name := <<"c/04">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>},{<<"cursor">>,Next1}]), 133 | #{prev := undefined, items := [#{name := <<"c/01">>}|_]} = q([{<<"limit">>,<<"3">>},{<<"sort">>,<<"name">>},{<<"cursor">>,Prev1}]), 134 | ok. 135 | 136 | 137 | encode_filter(_) -> 138 | <<"drv_is_not=null">> = encode_qs(#{filter => #{<<"drv">> => not_null}}), 139 | <<"transcoder_is=null">> = encode_qs(#{filter => #{<<"transcoder">> => null}}), 140 | 141 | <<"name_ne=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$ne' => <<"abc">>}}}), 142 | <<"name_gt=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$gt' => <<"abc">>}}}), 143 | <<"name_lt=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$lt' => <<"abc">>}}}), 144 | <<"name_gte=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$gte' => <<"abc">>}}}), 145 | <<"name_lte=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$lte' => <<"abc">>}}}), 146 | <<"name_like=abc">> = encode_qs(#{filter => #{<<"name">> => #{'$like' => <<"abc">>}}}), 147 | 148 | <<"parameter=abc%2Cdef">> = encode_qs(#{filter => #{<<"parameter">> => [<<"abc">>,<<"def">>]}}), 149 | 150 | <<"parameter.drv_is_not=null¶meter.transcoder_is=null">> = encode_qs(#{filter => #{<<"parameter">> => #{<<"drv">> => not_null, <<"transcoder">> => null}}}), 151 | 152 | ok. 153 | 154 | encode_select(_) -> 155 | <<"select=foo.bar">> = encode_qs(#{select => #{foo => #{bar => true}}}), 156 | 157 | <<"select=", Select1/binary>> = encode_qs(#{select => #{moo => true, foo => #{bar => true, baz => true}}}), 158 | [<<"foo.bar">>,<<"foo.baz">>,<<"moo">>] = lists:sort(binary:split(cow_qs:urldecode(Select1), <<",">>, [global])), 159 | 160 | ok. 161 | 162 | 163 | 164 | 165 | 166 | dataset() -> 167 | [ 168 | #{name => <<"c/01">>,named_by => config,position => 0, 169 | segment_duration => 1000,static => true, 170 | stats => 171 | #{alive => true,bitrate => 137,bufferings => 0, 172 | bytes_in => 174858,bytes_out => 179,client_count => 0, 173 | dvr_enabled => false,dvr_only => false, 174 | dvr_replication_running => false, 175 | id => <<"6138a794-0ea4-485b-8ec5-fef6e6e0abf9">>, 176 | input_bitrate => 137,input_error_rate => 0, 177 | last_access_at => 1631102868058, 178 | last_dts => 1631102874487.0117,last_dts_at => 1631102874487, 179 | lifetime => 6400.01171875, 180 | media_info => 181 | #{tracks => 182 | [#{bitrate => 83,codec => h264,content => video, 183 | fps => 24.0,height => 160,language => <<"eng">>, 184 | last_gop => 48,level => <<"3.0">>, 185 | pix_fmt => yuv420p,pixel_height => 160, 186 | pixel_width => 240,profile => <<"Baseline">>, 187 | sar_height => 1,sar_width => 1, 188 | track_id => <<"v1">>,width => 240}, 189 | #{bitrate => 54,channels => 2,codec => aac, 190 | content => audio,language => <<"eng">>, 191 | sample_rate => 48000,track_id => <<"a1">>}]}, 192 | opened_at => 1631102868058,out_bandwidth => 1, 193 | output_bitrate => 137,publish_enabled => false, 194 | remote => false,retry_count => 0,running => true, 195 | running_transcoder => false, 196 | source_id => <<"6138a794-0ed8-4630-8e6a-5cdb2d942746">>, 197 | start_running_at => 1631102868058, 198 | transcoder_overloaded => false,ts_delay => 665, 199 | url => <<"file://vod/bunny.mp4">>}, 200 | urls => [#{url => <<"file://vod/bunny.mp4">>}]}, 201 | #{name => <<"c/02">>,named_by => config,position => 1, 202 | segment_duration => 1000,static => true, 203 | dvr => #{root => <<"/storage">>}, 204 | stats => 205 | #{alive => true,bitrate => 137,bufferings => 0, 206 | bytes_in => 174858,bytes_out => 179,client_count => 0, 207 | dvr_enabled => false,dvr_only => false, 208 | dvr_replication_running => false, 209 | id => <<"6138a794-11ba-4f69-bbb0-57f79f9d9edb">>, 210 | input_bitrate => 137,input_error_rate => 0, 211 | last_access_at => 1631102874135, 212 | last_dts => 1631102874492.0117,last_dts_at => 1631102874493, 213 | lifetime => 6400.01171875, 214 | media_info => 215 | #{tracks => 216 | [#{bitrate => 83,codec => h264,content => video, 217 | fps => 24.0,height => 160,language => <<"eng">>, 218 | last_gop => 48,level => <<"3.0">>, 219 | pix_fmt => yuv420p,pixel_height => 160, 220 | pixel_width => 240,profile => <<"Baseline">>, 221 | sar_height => 1,sar_width => 1, 222 | track_id => <<"v1">>,width => 240}, 223 | #{bitrate => 54,channels => 2,codec => aac, 224 | content => audio,language => <<"eng">>, 225 | sample_rate => 48000,track_id => <<"a1">>}]}, 226 | opened_at => 1631102868070,out_bandwidth => 1, 227 | output_bitrate => 137,publish_enabled => false, 228 | remote => false,retry_count => 0,running => true, 229 | running_transcoder => false, 230 | source_id => <<"6138a794-11f0-4e63-a146-33fb8750259a">>, 231 | start_running_at => 1631102868070, 232 | transcoder_overloaded => false,ts_delay => 659, 233 | url => <<"file://vod/bunny.mp4">>}, 234 | urls => [#{url => <<"file://vod/bunny.mp4">>}]}, 235 | #{name => <<"c/03">>,named_by => config,position => 2, 236 | segment_duration => 1000,static => true, 237 | stats => 238 | #{alive => true,bitrate => 137,bufferings => 0, 239 | bytes_in => 174858,bytes_out => 179,client_count => 0, 240 | dvr_enabled => false,dvr_only => false, 241 | dvr_replication_running => false, 242 | id => <<"6138a794-12a2-460f-98ca-b815f2bca329">>, 243 | input_bitrate => 137,input_error_rate => 0, 244 | last_access_at => 1631102874138, 245 | last_dts => 1631102874498.0117,last_dts_at => 1631102874498, 246 | lifetime => 6400.01171875, 247 | media_info => 248 | #{title => <<"C03">>, 249 | tracks => 250 | [#{bitrate => 83,codec => h264,content => video, 251 | fps => 24.0,height => 160,language => <<"eng">>, 252 | last_gop => 48,level => <<"3.0">>, 253 | pix_fmt => yuv420p,pixel_height => 160, 254 | pixel_width => 240,profile => <<"Baseline">>, 255 | sar_height => 1,sar_width => 1, 256 | track_id => <<"v1">>,width => 240}, 257 | #{bitrate => 54,channels => 2,codec => aac, 258 | content => audio,language => <<"eng">>, 259 | sample_rate => 48000,track_id => <<"a1">>}]}, 260 | opened_at => 1631102868074,out_bandwidth => 1, 261 | output_bitrate => 137,publish_enabled => false, 262 | remote => false,retry_count => 0,running => true, 263 | running_transcoder => false, 264 | source_id => <<"6138a794-133a-4721-a518-bafd670bcc46">>, 265 | start_running_at => 1631102868074, 266 | transcoder_overloaded => false,ts_delay => 654, 267 | url => <<"file://vod/bunny.mp4">>}, 268 | title => <<"C03">>, 269 | urls => [#{url => <<"file://vod/bunny.mp4">>}]}, 270 | #{name => <<"c/04">>,named_by => config,position => 3, 271 | static => true, 272 | stats => 273 | #{alive => false,bufferings => 0,bytes_in => 0,bytes_out => 0, 274 | client_count => 0,dvr_enabled => false,dvr_only => false, 275 | dvr_replication_running => false, 276 | id => <<"6138a794-13da-4fce-afab-195ff156f6c5">>, 277 | input_error_rate => 0,last_access_at => 1631102868079, 278 | lifetime => 0,opened_at => 1631102868079,out_bandwidth => 0, 279 | publish_enabled => false,remote => false,running => true, 280 | running_transcoder => false, 281 | start_running_at => 1631102868079, 282 | transcoder_overloaded => false}, 283 | title => <<"C04">>}, 284 | #{name => <<"c/05">>,named_by => config,position => 4, 285 | static => true, 286 | stats => 287 | #{alive => false,bufferings => 0,bytes_in => 0,bytes_out => 0, 288 | client_count => 0,dvr_enabled => false,dvr_only => false, 289 | dvr_replication_running => false, 290 | id => <<"6138a794-151e-4c0a-bc3e-bc0ec2bbecc5">>, 291 | input_error_rate => 0,last_access_at => 1631102868084, 292 | lifetime => 0,opened_at => 1631102868084,out_bandwidth => 0, 293 | publish_enabled => false,remote => false,running => true, 294 | running_transcoder => false, 295 | start_running_at => 1631102868084, 296 | transcoder_overloaded => false}, 297 | title => <<"C05">>}, 298 | #{name => <<"c/06">>,named_by => config,position => 5, 299 | static => true, 300 | stats => 301 | #{alive => false,bufferings => 0,bytes_in => 0,bytes_out => 0, 302 | client_count => 0,dvr_enabled => false,dvr_only => false, 303 | dvr_replication_running => false, 304 | id => <<"6138a794-1666-4e22-879a-5e44a4811a2a">>, 305 | input_error_rate => 0,last_access_at => 1631102868089, 306 | lifetime => 0,opened_at => 1631102868089,out_bandwidth => 0, 307 | publish_enabled => false,remote => false,running => true, 308 | running_transcoder => false, 309 | start_running_at => 1631102868089, 310 | transcoder_overloaded => false}, 311 | title => <<"C06">>}, 312 | #{name => <<"c/07">>,named_by => config,position => 6, 313 | static => true, 314 | stats => 315 | #{alive => false,bufferings => 0,bytes_in => 0,bytes_out => 0, 316 | client_count => 0,dvr_enabled => false,dvr_only => false, 317 | dvr_replication_running => false, 318 | id => <<"6138a794-1814-49b2-86b4-2abb9e169cd1">>, 319 | input_error_rate => 0,last_access_at => 1631102868096, 320 | lifetime => 0,opened_at => 1631102868096,out_bandwidth => 0, 321 | publish_enabled => false,remote => false,running => true, 322 | running_transcoder => false, 323 | start_running_at => 1631102868096, 324 | transcoder_overloaded => false}, 325 | title => <<"C07">>}, 326 | #{cluster_key => <<"key1">>,groups => [],name => <<"c/08">>, 327 | named_by => remote,remote => true,section => stream, 328 | segment_duration => 1000,source_hostname => <<"src1.local">>, 329 | static => true, 330 | stats => 331 | #{alive => true,bitrate => 137,bufferings => 0, 332 | bytes_in => 98691,bytes_out => 179,client_count => 5, 333 | dvr_enabled => false,dvr_only => false, 334 | dvr_replication_running => false, 335 | id => <<"6138a79a-239b-4902-a2dd-d8d10417bd69">>, 336 | input_bitrate => 137,input_error_rate => 0, 337 | last_access_at => 1631102874142, 338 | last_dts => 1631102874133.6877,last_dts_at => 1631102874153, 339 | lifetime => 5994.6875, 340 | media_info => 341 | #{duration => 2.0e3,title => <<"C08">>, 342 | tracks => 343 | [#{bitrate => 83,codec => h264,content => video, 344 | fps => 24.0,height => 160,language => <<"eng">>, 345 | last_gop => 48,level => <<"3.0">>, 346 | pix_fmt => yuv420p,pixel_height => 160, 347 | pixel_width => 240,profile => <<"Baseline">>, 348 | sar_height => 1,sar_width => 1, 349 | track_id => <<"v1">>,width => 240}, 350 | #{bitrate => 54,channels => 2,codec => aac, 351 | content => audio,language => <<"eng">>, 352 | sample_rate => 48000,track_id => <<"a1">>}]}, 353 | opened_at => 1631102874142,out_bandwidth => 0, 354 | output_bitrate => 137,publish_enabled => false, 355 | remote => true,retry_count => 0,running => true, 356 | running_transcoder => false, 357 | source_hostname => <<"http://src1.local:6020/c/08">>, 358 | source_id => <<"6138a79a-2439-4d9e-9ec6-58baf4bbcc14">>, 359 | start_running_at => 1631102874142, 360 | transcoder_overloaded => false,ts_delay => 999, 361 | url => <<"m4f://src1.local:6020/c/08">>}, 362 | title => <<"C08">>, 363 | urls => 364 | [#{cluster_key => <<"key1">>, 365 | url => <<"m4f://src1.local:6020/c/08">>}]}, 366 | #{cluster_key => <<"key1">>,groups => [],name => <<"c/09">>, 367 | named_by => remote,remote => true,section => stream, 368 | segment_duration => 1000,source_hostname => <<"src1.local">>, 369 | static => true, 370 | stats => 371 | #{alive => true,bitrate => 137,bufferings => 0, 372 | bytes_in => 98691,bytes_out => 179,client_count => null, 373 | dvr_enabled => false,dvr_only => false, 374 | dvr_replication_running => false, 375 | id => <<"6138a79a-a254-4742-a6bc-477cb557ece8">>, 376 | input_bitrate => 137,input_error_rate => 0, 377 | last_access_at => 1631102874649, 378 | last_dts => 1631102874164.6875,last_dts_at => 1631102874661, 379 | lifetime => 5994.6875, 380 | media_info => 381 | #{duration => 2.0e3,title => <<"C09">>, 382 | tracks => 383 | [#{bitrate => 83,codec => h264,content => video, 384 | fps => 24.0,height => 160,language => <<"eng">>, 385 | last_gop => 48,level => <<"3.0">>, 386 | pix_fmt => yuv420p,pixel_height => 160, 387 | pixel_width => 240,profile => <<"Baseline">>, 388 | sar_height => 1,sar_width => 1, 389 | track_id => <<"v1">>,width => 240}, 390 | #{bitrate => 54,channels => 2,codec => aac, 391 | content => audio,language => <<"eng">>, 392 | sample_rate => 48000,track_id => <<"a1">>}]}, 393 | opened_at => 1631102874649,out_bandwidth => 0, 394 | output_bitrate => 137,publish_enabled => false, 395 | remote => true,retry_count => 0,running => true, 396 | running_transcoder => false, 397 | source_hostname => <<"http://src1.local:6020/c/09">>, 398 | source_id => <<"6138a79a-a2d8-4ac8-978b-fc180e2f1970">>, 399 | start_running_at => 1631102874649, 400 | transcoder_overloaded => false,ts_delay => 491, 401 | url => <<"m4f://src1.local:6020/c/09">>}, 402 | title => <<"C09">>, 403 | urls => 404 | [#{cluster_key => <<"key1">>, 405 | url => <<"m4f://src1.local:6020/c/09">>}]} 406 | ]. 407 | 408 | -------------------------------------------------------------------------------- /test/openapi_handler_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_handler_SUITE). 2 | -compile([nowarn_export_all, export_all]). 3 | 4 | 5 | all() -> 6 | [{group, routes}, {group, handling}]. 7 | 8 | groups() -> 9 | [ 10 | {routes, [yaml_routes, json_routes]}, 11 | {handling, [ 12 | trivial, 13 | non_existing_api, 14 | not_implemented, 15 | path_parameters, 16 | json_body_parameters, 17 | broken_json, 18 | query_string_parameters, 19 | multiple_file_upload, 20 | json_array_error, 21 | json_array_ok, 22 | undefined_in_non_nullable, 23 | erase_value_with_null, 24 | error_response, 25 | done_request, 26 | autorize_handler_args, 27 | non_exist_key, 28 | non_exist_key_drop, 29 | check_xml_content_responses, 30 | check_json_content_responses, 31 | check_text_content_responses, 32 | check_nonsense_content_responses, 33 | required_keys_filter, 34 | select_not_filters_required_keys, 35 | unavailable_error 36 | ]} 37 | ]. 38 | 39 | -ifdef(legacy). 40 | start_http(Routes, ApiName) -> 41 | cowboy:start_http(ApiName, 1, [{port, 0}], 42 | [{env, [{dispatch, cowboy_router:compile([{'_', Routes}])}]}]). 43 | -else. 44 | start_http(Routes, ApiName) -> 45 | cowboy:start_clear(ApiName, [{port, 0}], 46 | #{env => #{dispatch => cowboy_router:compile([{'_', Routes}])}}). 47 | -endif. 48 | 49 | 50 | init_per_suite(Config) -> 51 | {ok, _} = application:ensure_all_started(cowboy), 52 | {ok, _} = application:ensure_all_started(lhttpc), 53 | 54 | PetstorePath = filename:join(code:lib_dir(openapi_handler),"test/redocly-petstore.yaml"), 55 | TestSchemaPath = filename:join(code:lib_dir(openapi_handler),"test/test_schema.yaml"), 56 | PetstoreRoutes = openapi_handler:routes(#{ 57 | schema => PetstorePath, 58 | prefix => <<"/test/yml">>, 59 | name => petstore_server_api, 60 | module => fake_petstore 61 | }), 62 | PetstoreWithSchemaOptsRoutes = openapi_handler:routes(#{ 63 | schema => PetstorePath, 64 | prefix => <<"/test/yml">>, 65 | name => petstore_with_schema_opts_server_api, 66 | module => fake_petstore, 67 | schema_opts => #{extra_obj_key => drop} 68 | }), 69 | TestSchemaRoutes = openapi_handler:routes(#{ 70 | schema => TestSchemaPath, 71 | prefix => <<"/test/yml">>, 72 | name => test_schema_api, 73 | module => test_schema_res 74 | }), 75 | {ok, _} = application:ensure_all_started(cowboy), 76 | start_http(PetstoreRoutes, petstore_api_server), 77 | start_http(PetstoreWithSchemaOptsRoutes, petstore_with_schema_opts_api_server), 78 | start_http(TestSchemaRoutes, test_schema_server), 79 | PetstorePort = ranch:get_port(petstore_api_server), 80 | PetstoreWithSchemaOptsPort = ranch:get_port(petstore_with_schema_opts_api_server), 81 | TestSchemaPort = ranch:get_port(test_schema_server), 82 | 83 | PetstoreApi = openapi_client:load(#{ 84 | schema_url => PetstorePath, 85 | url => <<"http://127.0.0.1:",(integer_to_binary(PetstorePort))/binary,"/test/yml">> 86 | }), 87 | PetstoreWithSchemaOptsApi = openapi_client:load(#{ 88 | schema_url => PetstorePath, 89 | url => <<"http://127.0.0.1:",(integer_to_binary(PetstoreWithSchemaOptsPort))/binary,"/test/yml">> 90 | }), 91 | TestSchemaApi = openapi_client:load(#{ 92 | schema_url => TestSchemaPath, 93 | url => <<"http://127.0.0.1:",(integer_to_binary(TestSchemaPort))/binary,"/test/yml">> 94 | }), 95 | openapi_client:store(petstore_api, PetstoreApi), 96 | openapi_client:store(petstore_with_schema_opts_api, PetstoreWithSchemaOptsApi), 97 | openapi_client:store(test_schema_api, TestSchemaApi), 98 | Config. 99 | 100 | end_per_suite(Config) -> 101 | Config. 102 | 103 | % From Cowboy documentation: 104 | % > Finally, each path contains matching rules for the path along with optional constraints, 105 | % > and gives us the handler module to be used along with its initial state. 106 | % > 107 | % > Path1 = {PathMatch, Handler, InitialState}. 108 | % 109 | % Here, in routes group we ensure openapi_handler:routes/1 returns a valid list 110 | % of Cowboy matching rules in proper order (longer path first) 111 | yaml_routes(_) -> 112 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/redocly-petstore.yaml", 113 | Routes = openapi_handler:routes(#{schema => SchemaPath, module => ?MODULE, name => petstore_yaml, prefix => <<"/test/yml">>}), 114 | [{<<"/test/yml/user/logout">>,_,{petstore_yaml,<<"/user/logout">>}}, 115 | _, _, _, 116 | {<<"/test/yml/user/:username">>,_,{petstore_yaml,<<"/user/:username">>}}, 117 | {<<"/test/yml/user">>,_,{petstore_yaml,<<"/user">>}} 118 | |_] = Routes, 119 | ok. 120 | 121 | json_routes(_) -> 122 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/redocly-big-openapi.json", 123 | Routes = openapi_handler:routes(#{schema => SchemaPath, module => ?MODULE, name => rebilly_json, prefix => <<"/test/json">>}), 124 | [{<<"/test/json/websites/:id/webhook">>,_, {rebilly_json,<<"/websites/:id/webhook">>}}, 125 | {<<"/test/json/websites/:id">>,_, {rebilly_json,<<"/websites/:id">>}}, 126 | {<<"/test/json/websites">>,_, {rebilly_json,<<"/websites">>}}, 127 | {<<"/test/json/webhooks/:id">>,_, {rebilly_json,<<"/webhooks/:id">>}}, 128 | {<<"/test/json/webhooks">>,_, {rebilly_json,<<"/webhooks">>}} 129 | |_] = Routes, 130 | ok. 131 | 132 | 133 | 134 | 135 | authorize(#{'$cowboy_req' := _}) -> #{auth => yes_please}; 136 | authorize(_) -> ct:fail("There is no '$cowboy_req' in the authorize request arguments"). 137 | postprocess(JSON, _) -> JSON. 138 | log_call(CallInfo) -> 139 | (whereis(openapi_handler_SUITE_log_call_server) /= undefined) andalso (openapi_handler_SUITE_log_call_server ! {log_call, ?MODULE, CallInfo}). 140 | 141 | 142 | trivial(_) -> 143 | % Here we get error with code 200 because no response is described and we cannot 144 | % reliably tell user that server response is just an undescribed something 145 | {error, {200,#{say := <<"goodbye">>}}} = openapi_client:call(petstore_api,logoutUser, #{}), 146 | ok. 147 | 148 | non_existing_api(_) -> 149 | {error, not_loaded} = openapi_client:call(non_existing_api,some_method,#{}). 150 | 151 | not_implemented(_) -> 152 | {error, {501, #{error := <<"not_implemented">>}}} = 153 | openapi_client:call(petstore_api, createUsersWithArrayInput, #{}), 154 | ok. 155 | 156 | 157 | path_parameters(_) -> 158 | #{id := 238457234857} = openapi_client:call(petstore_api,getUserByName,#{username => <<"Jack">>}), 159 | ok. 160 | 161 | 162 | undefined_in_non_nullable(_) -> 163 | User = #{id := 2384572} = openapi_client:call(petstore_api,getUserByName,#{username => <<"John">>}), 164 | [id,username] = lists:sort(maps:keys(User)), 165 | ok. 166 | 167 | 168 | erase_value_with_null(_) -> 169 | Args = #{username => <<"Mary">>, json_body => #{firstName => null}}, 170 | User = #{id := 15} = openapi_client:call(petstore_api,updateUser,Args), 171 | [id,username] = lists:sort(maps:keys(User)), 172 | ok. 173 | 174 | 175 | non_exist_key(_) -> 176 | Args = #{username => <<"Mary">>, json_body => #{firstName => <<"Anna">>, non_exist => some}}, 177 | {error,{400,Res}} = openapi_client:call(petstore_api,updateUser,Args), 178 | 179 | #{<<"extra_keys">> := [<<"non_exist">>], 180 | <<"input1">> := #{<<"firstName">> := <<"Anna">>,<<"non_exist">> := <<"some">>}, 181 | <<"name">> := <<"request_body">>, 182 | <<"while">> := <<"parsing_parameters">>} = openapi_json:decode(Res), 183 | ok. 184 | 185 | non_exist_key_drop(_) -> 186 | Args = #{username => <<"Mary">>, json_body => #{firstName => null, non_exist_key => some}}, 187 | #{id := 15} = openapi_client:call(petstore_with_schema_opts_api,updateUser,Args), 188 | 189 | ok. 190 | 191 | 192 | json_body_parameters(_) -> 193 | Order0 = #{id => 1000011, petId => 7214, shipDate => <<"20221103-221700">>, status => placed, requestId => <<"testrequestid">>}, 194 | #{quantity := 31} = 195 | openapi_client:call(petstore_api, placeOrder, #{json_body => Order0}), 196 | ok. 197 | 198 | broken_json(_) -> 199 | Port = integer_to_list(ranch:get_port(petstore_api_server)), 200 | JSON = "{\"key\":\"value\"]}", 201 | {ok, {{400,_},Headers,Body}} = lhttpc:request("http://127.0.0.1:"++Port++"/test/yml/store/order", post, 202 | [{"Content-Type", "application/json"}], JSON, 5000), 203 | "application/json" = proplists:get_value("Content-Type", Headers), 204 | #{<<"error">> := <<"broken_json">>} = openapi_json:decode(Body), 205 | ok. 206 | 207 | 208 | query_string_parameters(_) -> 209 | [#{name := <<"Dingo">>}] = 210 | openapi_client:call(petstore_api, findPetsByStatus, #{status => [pending,sold]}), 211 | ok. 212 | 213 | 214 | fake_request(Name, Method, Path, Extra) -> 215 | StreamId = {Method, Path}, 216 | Headers = case maps:get(body, Extra, <<>>) of 217 | <<_, _/binary>> -> #{<<"content-type">> => <<"application/json">>, <<"accept">> => <<"application/json">>}; 218 | _ -> #{<<"accept">> => <<"application/json">>} 219 | end, 220 | Req1 = Extra#{method => Method, headers => Headers, streamid => StreamId, tester => self()}, 221 | erase(body_read), 222 | {_ok, Req2, ApiRequest} = openapi_handler:do_init(Req1, Name, Path, ?MODULE, #{no_handle => true}), 223 | is_map(ApiRequest) andalso openapi_handler:do_handle(Req2, ApiRequest, ?MODULE), 224 | receive 225 | {StreamId, _, Response} -> 226 | ct:print("Resp ~120p", [Response]), 227 | Response 228 | after 229 | 0 -> {error, no_response} 230 | end. 231 | 232 | 233 | %% Cowboy mock. Provides Cowboy 2.9 API for tests 234 | method(Req) -> maps:get(method, Req, <<"GET">>). 235 | peer(Req) -> maps:get(peer, Req, {{10,62,13,5},4824}). 236 | qs(Req) -> maps:get(qs, Req, <<>>). 237 | parse_qs(Req) -> cow_qs:parse_qs(qs(Req)). 238 | bindings(Req) -> maps:get(bindings, Req, #{}). 239 | headers(Req) -> maps:get(headers, Req, #{}). 240 | header(Name, Req) -> header(Name, Req, undefined). 241 | header(Name, Req, Default) -> maps:get(Name, headers(Req), Default). 242 | parse_header(<<"accept-encoding">>, Req) -> maps:get(accept_encoding, Req, undefined). 243 | resp_header(H, Req) -> resp_header(H, Req, undefined). 244 | resp_header(H, Req, Default) -> maps:get(H, maps:get(resp_headers, Req, #{}), Default). 245 | 246 | read_body(#{streamid := StreamId} = Req) -> 247 | case get(last_body_read) of 248 | StreamId -> ct:print("body_read_twice ~0p", [StreamId]), error(body_read_twice); 249 | _ -> put(last_body_read, StreamId) 250 | end, 251 | Body = maps:get(body, Req, <<>>), 252 | {ok, Body, Req}. 253 | 254 | reply(Code, Headers, Body, #{tester := Tester, streamid := StreamId} = Req) -> 255 | % Ensure all headers are lowercase 256 | NonLowercaseHeaders = maps:filter(fun(K, _) -> string:lowercase(iolist_to_binary(K)) /= K end, Headers), 257 | ZeroMap = #{}, 258 | ZeroMap = NonLowercaseHeaders, 259 | 260 | case get(last_response_sent) of 261 | StreamId -> ct:print("response_sent_twice ~0p", [StreamId]), error(response_sent_twice); 262 | _ -> put(last_response_sent, StreamId) 263 | end, 264 | Tester ! {StreamId, self(), {response, Code, Headers, Body}}, 265 | Req#{resp_headers => Headers, resp_body => Body}. 266 | 267 | 268 | read_multipart_files(Req) -> {ok, maps:get(files, Req, []), Req}. 269 | 270 | 271 | 272 | uploadFiles(#{files := Files}) -> 273 | [{<<"upload_name1.txt">>,<<"11\n">>},{<<"file2.txt">>,<<"22\n">>}] = Files, 274 | #{}. 275 | multiple_file_upload(_) -> 276 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/multiple-upload.yaml", 277 | Routes = openapi_handler:routes(#{schema => SchemaPath, module => ?MODULE, name => mu, prefix => <<"/test/mu">>}), 278 | [{<<"/test/mu/uploadFiles">>, _, {mu, <<"/uploadFiles">>}}] = Routes, 279 | 280 | Req = #{ 281 | files => [{<<"upload_name1.txt">>,<<"11\n">>},{<<"file2.txt">>,<<"22\n">>}] 282 | }, 283 | {response, 200, _, _Res} = fake_request(mu, <<"POST">>, <<"/uploadFiles">>, Req), 284 | ok. 285 | 286 | json_array_error(_) -> 287 | Array = <<"1,2,3">>, 288 | {error,{400, #{error := <<"not_array">> }}} = openapi_client:call(test_schema_api, jsonArray, #{json_body => Array}), 289 | ok. 290 | 291 | json_array_ok(_) -> 292 | Array = [1,2,3], 293 | Res = openapi_client:call(test_schema_api, jsonArray, #{json_body => Array}), 294 | #{<<"json_res">> := <<"1">>} = openapi_json:decode(Res), 295 | ok. 296 | 297 | putFile(#{req := Req, '$cowboy_req' := CowboyReq}) -> 298 | case openapi_handler:choose_module() of 299 | openapi_handler -> 300 | <<"PUT">> = cowboy_req:method(CowboyReq); 301 | openapi_handler_legacy -> 302 | skip 303 | end, 304 | Body = <<"{\"size\":100}">>, 305 | Req1 = reply(200, #{<<"content-length">> => byte_size(Body), <<"content-type">> => <<"application/json">>}, Body, Req), 306 | {done, Req1}. 307 | 308 | done_request(_) -> 309 | register(openapi_handler_SUITE_log_call_server, self()), 310 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/done_req.yaml", 311 | Routes = openapi_handler:routes(#{schema => SchemaPath, module => ?MODULE, name => put, prefix => <<"/test/put">>}), 312 | [{<<"/test/put/putFile">>, _, {put, <<"/putFile">>}}] = Routes, 313 | _ = fake_request(put, <<"PUT">>, <<"/putFile">>, #{}), 314 | receive 315 | {log_call, ?MODULE, #{operationId := putFile} = CallInfo} -> 316 | #{content_type := <<"application/json">>, content_length := 12} = CallInfo 317 | after 1000 -> error(no_log_call) 318 | end, 319 | unregister(openapi_handler_SUITE_log_call_server), 320 | ok. 321 | 322 | autorize_handler_args(_) -> 323 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/done_req.yaml", 324 | Routes = openapi_handler:routes(#{schema => SchemaPath, module => ?MODULE, name => aha, prefix => <<"/test/arf">>}), 325 | [{<<"/test/arf/putFile">>, _, {aha, <<"/putFile">>}}] = Routes, 326 | 327 | {response, 200, _, _Res} = fake_request(aha, <<"PUT">>, <<"/putFile">>, #{}), 328 | ok. 329 | 330 | error_response(_) -> 331 | {error, {501,#{error := <<"not_implemented">>}}} = 332 | openapi_client:call(petstore_api, getInventory, #{}), 333 | ok. 334 | 335 | 336 | % Methods valid_raw_response_value/2 and valid_simple_response_value/2 are helpfull methods for testing handling responses with preassigned content types. 337 | % Openapi_handler checks compatibility of content type and of 'Accept' value. 338 | valid_raw_response_value(<<"random/nonsense">> = _ContentType, _Accept) -> 339 | % Content type for /headersContentType at test_schema.yaml. 340 | % Error should be generated. 341 | {error,{500,#{error => <<"invalid_response">>}}}; 342 | valid_raw_response_value(<<"application/json">> = _ContentType, _Accept) -> 343 | % Content type <<"application/json">> does not suggest binary body. 344 | % Error should be generated. 345 | {error,{500,#{error => <<"invalid_response">>}}}; 346 | valid_raw_response_value(ContentType, <<"*/*">> = _Accept) -> 347 | content(ContentType); 348 | valid_raw_response_value(ContentType, <<"random/before-wildcard; */*">> = _Accept) -> 349 | content(ContentType); 350 | valid_raw_response_value(ContentType, Accept) when ContentType == Accept -> 351 | content(ContentType); 352 | valid_raw_response_value(_, _) -> 353 | {error,{500,#{error => <<"invalid_response">>}}}. 354 | 355 | 356 | % For json simple response 357 | valid_simple_response_value(<<"application/json">> = ContentType, _Accept) -> 358 | content(ContentType); 359 | % For binary simple response 360 | valid_simple_response_value(_ContentType, <<"application/json">> = _Accept) -> 361 | {error,{500,#{error => <<"invalid_response">>}}}; 362 | valid_simple_response_value(_ContentType, <<"random/nonsense">> = _Accept) -> 363 | {error,{500,#{error => <<"invalid_response">>}}}; % <<"random/nonsense">> is not described 364 | valid_simple_response_value(_ContentType, <<"random/before-wildcard; */*">> = _Accept) -> 365 | {error,{500,#{error => <<"invalid_response">>}}}; 366 | valid_simple_response_value(ContentType, _Accept) -> 367 | content(ContentType). 368 | 369 | 370 | 371 | content(ContentType) -> 372 | maps:get(ContentType, #{<<"text/plain">> => <<"OK">>, 373 | <<"application/json">> => #{<<"result">> => <<"OK">>}, 374 | <<"application/xml">> => <<" OK ">>, 375 | <<"random/nonsense">> => <<"Some">> 376 | }). 377 | 378 | 379 | accept_type_list() -> 380 | [<<"text/plain">>, <<"*/*">>, <<"application/xml">>, <<"application/json">>, <<"random/nonsense">>, <<"random/before-wildcard; */*">>]. 381 | 382 | 383 | get_response(ContentType, Accept) -> do_get_response(#{content_type => ContentType, accept => Accept}). 384 | get_response(simple, ContentType, Accept) -> do_get_response(#{response_view => simple, content_type => ContentType, accept => Accept}). 385 | 386 | do_get_response(#{content_type := ContentType} = CallParams) -> 387 | RespView = maps:get(response_view, CallParams, undefined), 388 | register(test_schema_log_call_server, self()), 389 | Result = openapi_client:call(test_schema_api,headersContentType,CallParams), 390 | receive 391 | {log_call, test_schema_res, #{code := OK} = CallInfo} when OK >= 200, OK < 300, RespView /= simple -> 392 | % On successful answer, content should be as callback returned 393 | #{content_type := ContentType, content_length := CLen} = CallInfo, 394 | true = (CLen > 0); 395 | {log_call, test_schema_res, #{code := OK} = CallInfo} when OK >= 200, OK < 300, RespView == simple -> 396 | #{content_type := _, content_length := CLen} = CallInfo, 397 | true = (CLen > 0); 398 | {log_call, test_schema_res, CallInfo} -> 399 | % Some error. It should have JSON description 400 | #{content_type := <<"application/json">>, content_length := CLen} = CallInfo, 401 | true = (CLen > 0) 402 | after 1000 -> error(no_log_call) 403 | end, 404 | unregister(test_schema_log_call_server), 405 | Result. 406 | 407 | 408 | check_xml_content_responses(_) -> 409 | lists:foldl(fun(Accept, _) -> 410 | true = valid_raw_response_value(<<"application/xml">>, Accept) == get_response(<<"application/xml">>, Accept), 411 | true = valid_simple_response_value(<<"application/xml">>, Accept) == get_response(simple, <<"application/xml">>, Accept) end, 412 | [], accept_type_list()), 413 | ok. 414 | 415 | 416 | check_json_content_responses(_) -> 417 | lists:foldl(fun(Accept, _) -> 418 | true = valid_raw_response_value(<<"application/json">>, Accept) == get_response(<<"application/json">>, Accept), 419 | true = valid_simple_response_value(<<"application/json">>, Accept) == get_response(simple, <<"application/json">>, Accept) end, 420 | [], accept_type_list()), 421 | ok. 422 | 423 | 424 | check_text_content_responses(_) -> 425 | lists:foldl(fun(Accept, _) -> 426 | true = valid_raw_response_value(<<"text/plain">>, Accept) == get_response(<<"text/plain">>, Accept), 427 | true = valid_simple_response_value(<<"text/plain">>, Accept) == get_response(simple, <<"text/plain">>, Accept) end, 428 | [], accept_type_list()), 429 | ok. 430 | 431 | 432 | check_nonsense_content_responses(_) -> 433 | lists:foldl(fun(Accept, _) -> 434 | true = valid_raw_response_value(<<"random/nonsense">>, Accept) == get_response(<<"random/nonsense">>, Accept), 435 | true = valid_simple_response_value(<<"random/nonsense">>, Accept) == get_response(simple, <<"random/nonsense">>, Accept) end, 436 | [], accept_type_list()), 437 | ok. 438 | 439 | 440 | required_keys_filter([_| _]) -> {skip, disabled_by_34435}; 441 | required_keys_filter(_) -> 442 | % required properties: p1, p2(r/o=true), p3(w/o=true) 443 | Res1 = openapi_client:call(test_schema_api, saveRequiredFilter, #{json_body => #{ 444 | % no required properties 445 | }}), 446 | {error, {400, #{missing_required := [<<"p1">>, <<"p3">>]}}} = Res1, % p2 filtered out 447 | 448 | Res2 = openapi_client:call(test_schema_api, saveRequiredFilter, #{json_body => #{ 449 | p1 => 1, p2 => 2, p3 => 3 % all required properties provided 450 | }}), 451 | #{p1 := 1, p2 := 2, p3 := 3} = Res2, 452 | 453 | Res3 = openapi_client:call(test_schema_api, saveRequiredFilter, #{json_body => #{ 454 | p1 => 1, p3 => 3 455 | }}), 456 | {error, {500, #{missing_required := [<<"p2">>]}}} = Res3, % no p2 in response 457 | ok. 458 | 459 | 460 | select_not_filters_required_keys(_) -> 461 | #{elements := [Elem1,Elem1]} = openapi_client:call(test_schema_api, selectCollectionFields, 462 | #{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}}), 463 | #{p1 := 1, p2 := 2, p3 := 3, p4 := 4, p5 :=5} = Elem1, 464 | 465 | % p1, p2 are 'readOnly' required keys 466 | #{elements := [Elem2,Elem2]} = openapi_client:call(test_schema_api, selectCollectionFields, 467 | #{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}, select => <<"p3">>}), 468 | #{p1 := 1, p2 := 2, p3 := 3} = Elem2, 469 | undefined = maps:get(p4, Elem2, undefined), 470 | undefined = maps:get(p5, Elem2, undefined), 471 | 472 | % p3 is 'writeOnly' required key 473 | #{elements := [Elem3,Elem3]} = openapi_client:call(test_schema_api, selectCollectionFields, 474 | #{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}, select => <<"p4">>}), 475 | #{p1 := 1, p2 := 2, p4 := 4} = Elem3, 476 | undefined = maps:get(p3, Elem3, undefined), 477 | undefined = maps:get(p5, Elem3, undefined), 478 | 479 | ok. 480 | 481 | 482 | unavailable_error(_) -> 483 | {error,unavailable} = openapi_client:call(test_schema_api, selectCollectionFields, #{json_body => #{unavailable => true}}), 484 | ok. 485 | 486 | -------------------------------------------------------------------------------- /test/openapi_schema_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(openapi_schema_SUITE). 2 | -compile([nowarn_export_all, export_all]). 3 | 4 | all() -> 5 | [ 6 | {group, process}, 7 | {group, introspection} 8 | ]. 9 | 10 | groups() -> 11 | [ 12 | {process, [], [ % parallel 13 | read_default, 14 | extra_keys_error, 15 | extra_keys_drop, 16 | null_in_array, 17 | nullable_by_oneof, 18 | discriminator, 19 | non_object_validate, 20 | regexp_pattern, 21 | external_validators, 22 | min_max_length, 23 | max_items_array, 24 | min_items_array, 25 | max_items_object, 26 | min_items_object, 27 | required_keys, 28 | required_keys_filter, 29 | validate_scalar_as_object, 30 | check_explain, 31 | check_explain_on_error, 32 | one_of_integer_const, 33 | filter_read_only_props 34 | ]}, 35 | {introspection, [], [ 36 | fetch_type 37 | ]} 38 | ]. 39 | 40 | 41 | init_per_suite(Config) -> 42 | SchemaPath = code:lib_dir(openapi_handler) ++ "/test/flussonic-230127.json", 43 | openapi_handler:load_schema(SchemaPath, test_openapi), 44 | BigOpenapiPath = code:lib_dir(openapi_handler) ++ "/test/redocly-big-openapi.json", 45 | openapi_handler:load_schema(BigOpenapiPath, big_openapi), 46 | Config. 47 | 48 | end_per_suite(Config) -> 49 | Config. 50 | 51 | 52 | 53 | read_default(_) -> 54 | Json = #{<<"name">> => <<"read_default">>}, 55 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 56 | #{static := true, name := <<"read_default">>} = 57 | openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true}), 58 | 59 | Stream2 = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema}), 60 | Stream2 = #{name => <<"read_default">>}, 61 | ok. 62 | 63 | 64 | extra_keys_error(_) -> 65 | Json = #{<<"name">> => <<"read_default">>, extra_key1 => <<"abc">>, <<"extrakey2">> => def}, 66 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 67 | {error,#{ 68 | encoded :=#{ 69 | inputs := [], 70 | name := <<"read_default">>, 71 | static := true}, 72 | extra_keys := [extra_key1,<<"extrakey2">>]}} = 73 | openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true, extra_obj_key => error}), 74 | ok. 75 | 76 | 77 | extra_keys_drop(_) -> 78 | Json = #{<<"name">> => <<"read_default">>, extra_key1 => <<"abc">>, <<"extrakey2">> => def}, 79 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 80 | #{inputs := [],name := <<"read_default">>,static := true} = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true}), 81 | ok. 82 | 83 | 84 | null_in_array(_) -> 85 | % When items are not nullable, passing null|undefined as a list element should return an error 86 | {error, #{error := null_in_array_of_non_nullable}} = openapi_schema:process( 87 | [<<"a">>, undefined, <<"b">>], 88 | #{schema => #{type => <<"array">>,items => #{type => <<"string">>}}}), 89 | {error, #{error := null_in_array_of_non_nullable}} = openapi_schema:process( 90 | [<<"a">>, null, <<"b">>], 91 | #{schema => #{type => <<"array">>,items => #{type => <<"string">>}}}), 92 | % When array items are nullable, both null and undefined are transformed with no error 93 | [<<"a">>, undefined, undefined, <<"b">>] = openapi_schema:process( 94 | [<<"a">>, null, undefined, <<"b">>], 95 | #{schema => #{type => <<"array">>,items => #{type => <<"string">>, nullable => true}}}), 96 | ok. 97 | 98 | 99 | nullable_by_oneof(_) -> 100 | % OAS 3.1 supports all JSON types https://spec.openapis.org/oas/v3.1.0.html#data-types 101 | % Also 'nullable' is invalid in OAS 3.1, and oneOf with {type: 'null'} is suggested instead 102 | Props = #{nk => #{oneOf => [#{type => <<"string">>}, #{type => <<"null">>}]}, k2 => #{type => <<"integer">>, default => 42}}, 103 | Schema = #{type => <<"object">>, properties => Props}, 104 | % There was a bug where null value caused extra_keys error 105 | Expect1 = #{nk => undefined}, 106 | Expect1 = openapi_schema:process(#{nk => null}, #{schema => Schema, extra_obj_key => error}), 107 | Expect1s = #{nk => <<"hello">>}, 108 | Expect1s = openapi_schema:process(#{nk => <<"hello">>}, #{schema => Schema, extra_obj_key => error}), 109 | {error, _} = openapi_schema:process(#{nk => 42}, #{schema => Schema, extra_obj_key => error}), 110 | % Normalize the given object with a nulled key as much as possible 111 | Expect2 = #{nk => undefined, k2 => 42}, 112 | Expect2 = openapi_schema:process(#{nk => null}, #{schema => Schema, extra_obj_key => error, apply_defaults => true}), 113 | ok. 114 | 115 | discriminator(_) -> 116 | FooProp = #{dis => #{type => <<"string">>}, k1 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, 117 | BarProp = #{dis => #{type => <<"string">>}, k2 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, 118 | FooType = #{type => <<"object">>, properties => FooProp}, 119 | BarType = #{type => <<"object">>, properties => BarProp}, 120 | DType = #{ 121 | oneOf => [#{'$ref' => <<"#/components/schemas/foo_t">>}, #{'$ref' => <<"#/components/schemas/bar_t">>}], 122 | discriminator => #{propertyName => <<"dis">>, mapping => #{foo => <<"#/components/schemas/foo_t">>, bar => <<"#/components/schemas/bar_t">>}}}, 123 | DSchema = #{components => #{schemas => #{discr_t => DType, foo_t => FooType, bar_t => BarType}}}, 124 | 125 | %% Match type by discriminator 126 | Foo1 = openapi_schema:process(#{dis => <<"foo">>, k1 => 12, k2 => 34}, #{type => discr_t, whole_schema => DSchema}), 127 | [dis, k1] = lists:sort(maps:keys(Foo1)), % k2 is deleted because it is valid only for bar_t 128 | Foo2 = openapi_schema:process(#{<<"dis">> => <<"foo">>, <<"k1">> => 12, <<"k2">> => 34}, #{type => discr_t, whole_schema => DSchema}), 129 | [dis, k1] = lists:sort(maps:keys(Foo2)), % binary keys work well too 130 | Foo3 = openapi_schema:process(#{dis => foo, k1 => 12, k2 => 34}, #{type => discr_t, whole_schema => DSchema}), 131 | [dis, k1] = lists:sort(maps:keys(Foo3)), % atom discriminator value works well 132 | Bar1 = openapi_schema:process(#{dis => <<"bar">>, k1 => 12, k2 => 34}, #{type => discr_t, whole_schema => DSchema}), 133 | [dis, k2] = lists:sort(maps:keys(Bar1)), % k1 is deleted because it is valid only for foo_t 134 | 135 | %% Missing or invalid discriminator should lead an error 136 | {error, #{error := discriminator_missing}} = openapi_schema:process(#{k1 => 12, k2 => 34}, #{type => discr_t, whole_schema => DSchema}), 137 | {error, #{error := discriminator_unmapped}} = openapi_schema:process(#{dis => <<"nonsense">>, k1 => 12, k2 => 34}, #{type => discr_t, whole_schema => DSchema}), 138 | 139 | FooType1 = FooType#{properties := FooProp#{k4 => #{type => <<"integer">>}, dis => #{type => <<"string">>, default => foo}}}, 140 | BarType1 = BarType#{properties := BarProp#{k5 => #{type => <<"integer">>}, dis => #{type => <<"string">>, default => foo}}}, 141 | DSchema1 = #{components => #{schemas => #{discr_t => DType, foo_t => FooType1, bar_t => BarType1}}}, 142 | 143 | Foo5 = openapi_schema:process(#{k1 => 12, k2 => 34, k4 => 56}, #{type => discr_t, whole_schema => DSchema1}), 144 | [k1, k4] = lists:sort(maps:keys(Foo5)), % apply default 145 | 146 | ok. 147 | 148 | 149 | non_object_validate(_) -> 150 | {error, #{error := not_object}} = openapi_schema:process([<<"123">>], #{schema => #{type => <<"object">>}}), 151 | ok. 152 | 153 | regexp_pattern(_) -> 154 | 155 | {error, #{error := nomatch_pattern}} = openapi_schema:process(<<"123">>, #{schema => #{type => <<"string">>, pattern => <<"^[a-z]+$">>}}), 156 | % {error, #{error := not_string}} = openapi_schema:process(abc, #{schema => #{type => <<"string">>, pattern => <<"^[a-z]+$">>}}), 157 | <<"abc">> = openapi_schema:process(abc, #{schema => #{type => <<"string">>, pattern => <<"^[a-z]+$">>}}), 158 | <<"abc">> = openapi_schema:process(<<"abc">>, #{schema => #{type => <<"string">>, pattern => <<"^[a-z]+$">>}}), 159 | <<"abc">> = openapi_schema:process(abc, #{schema => #{type => <<"string">>}}), 160 | 161 | % pattern in loaded schema works well too (regexp is pre-compiled on load) 162 | #{url := <<"http://foobar/">>} = openapi_schema:process( 163 | #{name => <<"aaa">>, url => <<"http://foobar/">>}, #{name => test_openapi, type => event_sink_config}), 164 | {error, #{error := nomatch_pattern}} = openapi_schema:process( 165 | #{name => <<"aaa">>, url => <<"nonsense://foobar/">>}, #{name => test_openapi, type => event_sink_config}), 166 | 167 | ok. 168 | 169 | 170 | no_space_validator(<<" ", _/binary>>) -> 171 | {error, #{detail => leading_space}}; 172 | no_space_validator(Input) -> 173 | {ok, binary:replace(Input, <<" ">>, <<"_">>, [global])}. 174 | 175 | external_validators(_) -> 176 | Schema = #{type => <<"string">>, format => no_space}, 177 | Validators = #{no_space => fun no_space_validator/1}, 178 | % Baseline 179 | <<" ab cd">> = openapi_schema:process(<<" ab cd">>, #{schema => Schema}), 180 | % auto_convert by default 181 | <<"ab_cd">> = openapi_schema:process(<<"ab cd">>, #{schema => Schema, validators => Validators}), 182 | % Disabled auto_convert -- error instead of converted value 183 | {error, Err1} = openapi_schema:process(<<"ab cd">>, #{schema => Schema, validators => Validators, auto_convert => false}), 184 | #{error := needs_convertation, format := no_space} = Err1, 185 | % Proper value passes validation even witht auto_convert disabled 186 | <<"ab_cd">> = openapi_schema:process(<<"ab_cd">>, #{schema => Schema, validators => Validators, auto_convert => false}), 187 | % Improper value 188 | {error, Err2} = openapi_schema:process(<<" ab cd">>, #{schema => Schema, validators => Validators}), 189 | #{error := wrong_format, format := no_space, detail := leading_space} = Err2, 190 | 191 | % format validators work with loaded schema 192 | % big_openapi.digest is a composition of allOf and object, so it needs schema to be properly prepared 193 | #{password := <<"ab_cd">>} = openapi_schema:process( 194 | #{username => <<"Joe">>, password => <<"ab cd">>}, #{name => big_openapi, type => digest, validators => #{password => fun no_space_validator/1}}), 195 | 196 | % format validator and pattern work simultaneously 197 | Schema2 = #{type => <<"string">>, format => no_space, pattern => <<"^[a-z]+$">>}, 198 | {error, Err3} = openapi_schema:process(<<" ab cd">>, #{schema => Schema2, validators => Validators}), 199 | #{error := wrong_format} = Err3, 200 | {error, Err4} = openapi_schema:process(<<"12 cd">>, #{schema => Schema2, validators => Validators}), 201 | #{error := nomatch_pattern} = Err4, 202 | 203 | ok. 204 | 205 | 206 | min_max_length(_) -> 207 | {error, #{error := too_short}} = 208 | openapi_schema:process(<<"123">>, #{schema => #{type => <<"string">>, minLength => 5}}), 209 | {error, #{error := too_long}} = 210 | openapi_schema:process(<<"123">>, #{schema => #{type => <<"string">>, maxLength => 2}}), 211 | ok. 212 | 213 | 214 | max_items_array(_) -> 215 | {error, #{error := too_many_items}} = openapi_schema:process([1,2,3], 216 | #{schema => #{type => <<"array">>, maxItems => 2, items => #{type => <<"integer">>}}}), 217 | ok. 218 | 219 | min_items_array(_) -> 220 | {error, #{error := too_few_items}} = openapi_schema:process([1], 221 | #{schema => #{type => <<"array">>, minItems => 2, items => #{type => <<"integer">>}}}), 222 | ok. 223 | 224 | max_items_object(_) -> 225 | {error, #{error := too_many_items}} = openapi_schema:process(#{a => 1,b => 2, c => 3}, 226 | #{schema => #{type => <<"object">>, maxItems => 2, additionalProperties => #{type => integer}}}), 227 | ok. 228 | 229 | min_items_object(_) -> 230 | {error, #{error := too_few_items}} = openapi_schema:process(#{a => 1}, 231 | #{schema => #{type => <<"object">>, minItems => 2, additionalProperties => #{type => integer}}}), 232 | ok. 233 | 234 | required_keys(_) -> 235 | Json = #{<<"no_name">> => <<"read_default">>}, 236 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 237 | {error, #{missing_required := [<<"name">>]}} = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true, required_obj_keys => error}), 238 | ok. 239 | 240 | 241 | required_keys_filter(_) -> 242 | Schema = #{ 243 | type => <<"object">>, 244 | properties => #{ 245 | p1 => #{type => <<"integer">>}, 246 | p2 => #{type => <<"integer">>}, 247 | p3 => #{type => <<"integer">>, readOnly => true}, 248 | p4 => #{type => <<"integer">>, readOnly => true}, 249 | p5 => #{type => <<"integer">>, writeOnly => true}, 250 | p6 => #{type => <<"integer">>, writeOnly => true}}, 251 | required => [<<"p1">>, <<"p2">>, <<"p3">>, <<"p4">>, <<"p5">>, <<"p6">>]}, 252 | Obj = #{p1 => 1, p3 => 3, <<"p5">> => 5}, 253 | Opts = #{schema => Schema, required_obj_keys => error}, 254 | % required keys with readOnly=true are ignored in requests 255 | {error, #{missing_required := [<<"p2">>, <<"p6">>]}} = openapi_schema:process(Obj, Opts#{access_type => write}), 256 | % required keys with writeOnly=true are ignored in responses 257 | {error, #{missing_required := [<<"p2">>, <<"p4">>]}} = openapi_schema:process(Obj, Opts#{access_type => read}), 258 | % default access_type=read 259 | {error, #{missing_required := [<<"p2">>, <<"p4">>]}} = openapi_schema:process(Obj, Opts), 260 | ok. 261 | 262 | 263 | validate_scalar_as_object(_) -> 264 | Json = #{<<"inputs">> => [<<"udp://239.0.0.1">>]}, 265 | Opts = #{type => stream_config, whole_schema => persistent_term:get({openapi_handler_schema,test_openapi})}, 266 | {error, #{error := not_object, path := _}} = openapi_schema:process(Json, Opts), 267 | ok. 268 | 269 | check_explain(_) -> 270 | Json = #{<<"name">> => <<"read_default">>, extra_key1 => <<"abc">>, <<"extrakey2">> => def, inputs => [#{}, #{}], cluster_ingest => #{}}, 271 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 272 | #{name := <<"read_default">>, 273 | cluster_ingest := #{'$explain' := #{required := []}}, 274 | inputs := [ 275 | #{'$explain' := #{required := [<<"url">>]}}, 276 | #{'$explain' := #{required := [<<"url">>]}}], 277 | '$explain' := #{required := [<<"name">>]}} = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true, explain => [required]}), 278 | ok. 279 | 280 | check_explain_on_error(_) -> 281 | Json = #{<<"name">> => <<"read_default">>, <<"position">> => true, extra_key1 => <<"abc">>, <<"extrakey2">> => def, inputs => [#{}, #{}], cluster_ingest => #{}}, 282 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 283 | {error, #{ 284 | error := not_integer, input := true, path := [<<"stream_config">>, 0, <<"stream_config_specific">>, position]} 285 | } = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true, explain => [required]}), 286 | ok. 287 | 288 | one_of_integer_const(_) -> 289 | Json = #{<<"name">> => <<"one_of_integer_const">>, <<"inputs">> => [#{<<"apts">> => 1}, #{<<"apts">> => <<"3">>}, #{<<"apts">> => <<"video">>}]}, 290 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 291 | #{ 292 | name := <<"one_of_integer_const">>, 293 | inputs := [#{apts := 1}, #{apts := 3}, #{apts := video}] 294 | } = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, apply_defaults => true, explain => [required]}), 295 | ok. 296 | 297 | filter_read_only_props(_) -> 298 | Schema = persistent_term:get({openapi_handler_schema,test_openapi}), 299 | Json = #{ 300 | <<"name">> => <<"stream">>, 301 | <<"stats">> => #{<<"id">> => <<"61893ba6-07b3-431b-b2f7-716ac1643953">>}}, 302 | 303 | #{name := <<"stream">>} = Spec = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema, access_type => write}), 304 | false = maps:is_key(spec,Spec), 305 | #{name := <<"stream">>, stats := #{id := <<"61893ba6-07b3-431b-b2f7-716ac1643953">>}} = openapi_schema:process(Json, #{type => stream_config, whole_schema => Schema}), 306 | ok. 307 | 308 | 309 | 310 | fetch_type(_) -> 311 | #{allOf := _} = openapi_schema:type(test_openapi, stream_config), 312 | ok. 313 | -------------------------------------------------------------------------------- /test/redocly-petstore.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | servers: 3 | - url: //petstore.swagger.io/v2 4 | description: Default server 5 | - url: //petstore.swagger.io/sandbox 6 | description: Sandbox server 7 | info: 8 | description: | 9 | This is a sample server Petstore server. 10 | You can find out more about Swagger at 11 | [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). 12 | For this sample, you can use the api key `special-key` to test the authorization filters. 13 | 14 | # Introduction 15 | This API is documented in **OpenAPI format** and is based on 16 | [Petstore sample](http://petstore.swagger.io/) provided by [swagger.io](http://swagger.io) team. 17 | It was **extended** to illustrate features of [generator-openapi-repo](https://github.com/Rebilly/generator-openapi-repo) 18 | tool and [ReDoc](https://github.com/Redocly/redoc) documentation. In addition to standard 19 | OpenAPI syntax we use a few [vendor extensions](https://github.com/Redocly/redoc/blob/main/docs/redoc-vendor-extensions.md). 20 | 21 | # OpenAPI Specification 22 | This API is documented in **OpenAPI format** and is based on 23 | [Petstore sample](http://petstore.swagger.io/) provided by [swagger.io](http://swagger.io) team. 24 | It was **extended** to illustrate features of [generator-openapi-repo](https://github.com/Rebilly/generator-openapi-repo) 25 | tool and [ReDoc](https://github.com/Redocly/redoc) documentation. In addition to standard 26 | OpenAPI syntax we use a few [vendor extensions](https://github.com/Redocly/redoc/blob/main/docs/redoc-vendor-extensions.md). 27 | 28 | # Cross-Origin Resource Sharing 29 | This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/). 30 | And that allows cross-domain communication from the browser. 31 | All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. 32 | 33 | # Authentication 34 | 35 | Petstore offers two forms of authentication: 36 | - API Key 37 | - OAuth2 38 | OAuth2 - an open protocol to allow secure authorization in a simple 39 | and standard method from web, mobile and desktop applications. 40 | 41 | 42 | 43 | version: 1.0.0 44 | title: Swagger Petstore 45 | summary: My lovely API 46 | termsOfService: 'http://swagger.io/terms/' 47 | contact: 48 | name: API Support 49 | email: apiteam@swagger.io 50 | url: https://github.com/Redocly/redoc 51 | x-logo: 52 | url: 'https://redocly.github.io/redoc/petstore-logo.png' 53 | altText: Petstore logo 54 | license: 55 | name: Apache 2.0 56 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 57 | identifier: Apache 2.0 58 | externalDocs: 59 | description: Find out how to create Github repo for your OpenAPI spec. 60 | url: 'https://github.com/Rebilly/generator-openapi-repo' 61 | tags: 62 | - name: pet 63 | description: Everything about your Pets 64 | - name: store 65 | description: Access to Petstore orders 66 | - name: user 67 | description: Operations about user 68 | - name: webhooks 69 | description: Everything about your Webhooks 70 | - name: pet_model 71 | x-displayName: The Pet Model 72 | description: | 73 | 74 | - name: store_model 75 | x-displayName: The Order Model 76 | description: | 77 | 78 | x-tagGroups: 79 | - name: General 80 | tags: 81 | - pet 82 | - store 83 | - webhooks 84 | - name: User Management 85 | tags: 86 | - user 87 | - name: Models 88 | tags: 89 | - pet_model 90 | - store_model 91 | paths: 92 | /pet: 93 | parameters: 94 | - name: Accept-Language 95 | in: header 96 | description: 'The language you prefer for messages. Supported values are en-AU, en-CA, en-GB, en-US' 97 | example: en-US 98 | required: false 99 | schema: 100 | type: string 101 | default: en-AU 102 | - name: cookieParam 103 | in: cookie 104 | description: Some cookie 105 | required: true 106 | schema: 107 | type: integer 108 | format: int64 109 | post: 110 | tags: 111 | - pet 112 | summary: Add a new pet to the store 113 | description: Add new pet to the store inventory. 114 | operationId: addPet 115 | responses: 116 | '405': 117 | description: Invalid input 118 | security: 119 | - petstore_auth: 120 | - 'write:pets' 121 | - 'read:pets' 122 | x-codeSamples: 123 | - lang: 'C#' 124 | source: | 125 | PetStore.v1.Pet pet = new PetStore.v1.Pet(); 126 | pet.setApiKey("your api key"); 127 | pet.petType = PetStore.v1.Pet.TYPE_DOG; 128 | pet.name = "Rex"; 129 | // set other fields 130 | PetStoreResponse response = pet.create(); 131 | if (response.statusCode == HttpStatusCode.Created) 132 | { 133 | // Successfully created 134 | } 135 | else 136 | { 137 | // Something wrong -- check response for errors 138 | Console.WriteLine(response.getRawResponse()); 139 | } 140 | - lang: PHP 141 | source: | 142 | $form = new \PetStore\Entities\Pet(); 143 | $form->setPetType("Dog"); 144 | $form->setName("Rex"); 145 | // set other fields 146 | try { 147 | $pet = $client->pets()->create($form); 148 | } catch (UnprocessableEntityException $e) { 149 | var_dump($e->getErrors()); 150 | } 151 | requestBody: 152 | $ref: '#/components/requestBodies/Pet' 153 | put: 154 | tags: 155 | - pet 156 | summary: Update an existing pet 157 | description: '' 158 | operationId: updatePet 159 | responses: 160 | '400': 161 | description: Invalid ID supplied 162 | '404': 163 | description: Pet not found 164 | '405': 165 | description: Validation exception 166 | security: 167 | - petstore_auth: 168 | - 'write:pets' 169 | - 'read:pets' 170 | x-codeSamples: 171 | - lang: PHP 172 | source: | 173 | $form = new \PetStore\Entities\Pet(); 174 | $form->setPetId(1); 175 | $form->setPetType("Dog"); 176 | $form->setName("Rex"); 177 | // set other fields 178 | try { 179 | $pet = $client->pets()->update($form); 180 | } catch (UnprocessableEntityException $e) { 181 | var_dump($e->getErrors()); 182 | } 183 | requestBody: 184 | $ref: '#/components/requestBodies/Pet' 185 | delete: 186 | tags: 187 | - pet 188 | summary: OperationId with quotes 189 | operationId: deletePetBy"Id 190 | get: 191 | tags: 192 | - pet 193 | summary: OperationId with backslash 194 | operationId: delete\PetById 195 | '/pet/{petId}': 196 | get: 197 | tags: 198 | - pet 199 | summary: Find pet by ID 200 | description: Returns a single pet 201 | operationId: getPetById 202 | parameters: 203 | - name: petId 204 | in: path 205 | description: ID of pet to return 206 | required: true 207 | deprecated: true 208 | schema: 209 | type: integer 210 | format: int64 211 | responses: 212 | '200': 213 | description: successful operation 214 | content: 215 | application/json: 216 | schema: 217 | $ref: '#/components/schemas/Pet' 218 | application/xml: 219 | schema: 220 | $ref: '#/components/schemas/Pet' 221 | '400': 222 | description: Invalid ID supplied 223 | '404': 224 | description: Pet not found 225 | security: 226 | - api_key: [] 227 | post: 228 | tags: 229 | - pet 230 | summary: Updates a pet in the store with form data 231 | description: '' 232 | operationId: updatePetWithForm 233 | parameters: 234 | - name: petId 235 | in: path 236 | description: ID of pet that needs to be updated 237 | required: true 238 | schema: 239 | type: integer 240 | format: int64 241 | responses: 242 | '405': 243 | description: Invalid input 244 | security: 245 | - petstore_auth: 246 | - 'write:pets' 247 | - 'read:pets' 248 | requestBody: 249 | content: 250 | application/x-www-form-urlencoded: 251 | schema: 252 | type: object 253 | properties: 254 | name: 255 | description: Updated name of the pet 256 | type: string 257 | status: 258 | description: Updated status of the pet 259 | type: string 260 | delete: 261 | tags: 262 | - pet 263 | summary: Deletes a pet 264 | description: '' 265 | operationId: deletePet 266 | parameters: 267 | - name: api_key 268 | in: header 269 | required: false 270 | schema: 271 | type: string 272 | example: 'Bearer ' 273 | - name: petId 274 | in: path 275 | description: Pet id to delete 276 | required: true 277 | schema: 278 | type: integer 279 | format: int64 280 | responses: 281 | '400': 282 | description: Invalid pet value 283 | security: 284 | - petstore_auth: 285 | - 'write:pets' 286 | - 'read:pets' 287 | '/pet/{petId}/uploadImage': 288 | post: 289 | tags: 290 | - pet 291 | summary: uploads an image 292 | description: '' 293 | operationId: uploadFile 294 | parameters: 295 | - name: petId 296 | in: path 297 | description: ID of pet to update 298 | required: true 299 | schema: 300 | type: integer 301 | format: int64 302 | responses: 303 | '200': 304 | description: successful operation 305 | content: 306 | application/json: 307 | schema: 308 | unevaluatedProperties: 309 | type: integer 310 | format: int32 311 | $ref: '#/components/schemas/ApiResponse' 312 | security: 313 | - petstore_auth: 314 | - 'write:pets' 315 | - 'read:pets' 316 | requestBody: 317 | content: 318 | application/octet-stream: 319 | schema: 320 | type: string 321 | format: binary 322 | /pet/findByStatus: 323 | get: 324 | tags: 325 | - pet 326 | summary: Finds Pets by status 327 | description: Multiple status values can be provided with comma separated strings 328 | operationId: findPetsByStatus 329 | parameters: 330 | - name: status 331 | in: query 332 | description: Status values that need to be considered for filter 333 | required: true 334 | style: form 335 | schema: 336 | type: array 337 | minItems: 1 338 | maxItems: 3 339 | items: 340 | type: string 341 | enum: 342 | - available 343 | - pending 344 | - sold 345 | default: available 346 | responses: 347 | '200': 348 | description: successful operation 349 | content: 350 | application/json: 351 | schema: 352 | type: array 353 | items: 354 | $ref: '#/components/schemas/Pet' 355 | application/xml: 356 | schema: 357 | type: array 358 | items: 359 | $ref: '#/components/schemas/Pet' 360 | '400': 361 | description: Invalid status value 362 | security: 363 | - petstore_auth: 364 | - 'write:pets' 365 | - 'read:pets' 366 | /pet/findByTags: 367 | get: 368 | tags: 369 | - pet 370 | summary: Finds Pets by tags 371 | description: >- 372 | Multiple tags can be provided with comma separated strings. Use tag1, 373 | tag2, tag3 for testing. 374 | operationId: findPetsByTags 375 | deprecated: true 376 | parameters: 377 | - name: tags 378 | in: query 379 | description: Tags to filter by 380 | required: true 381 | style: form 382 | schema: 383 | type: array 384 | items: 385 | type: string 386 | responses: 387 | '200': 388 | description: successful operation 389 | content: 390 | application/json: 391 | schema: 392 | type: array 393 | items: 394 | $ref: '#/components/schemas/Pet' 395 | application/xml: 396 | schema: 397 | type: array 398 | items: 399 | $ref: '#/components/schemas/Pet' 400 | '400': 401 | description: Invalid tag value 402 | security: 403 | - petstore_auth: 404 | - 'write:pets' 405 | - 'read:pets' 406 | /store/inventory: 407 | get: 408 | tags: 409 | - store 410 | summary: Returns pet inventories by status 411 | description: Returns a map of status codes to quantities 412 | operationId: getInventory 413 | responses: 414 | '200': 415 | description: successful operation 416 | content: 417 | application/json: 418 | schema: 419 | type: object 420 | additionalProperties: 421 | type: integer 422 | format: int32 423 | security: 424 | - api_key: [] 425 | /store/order: 426 | post: 427 | tags: 428 | - store 429 | summary: Place an order for a pet 430 | description: '' 431 | operationId: placeOrder 432 | responses: 433 | '200': 434 | description: successful operation 435 | content: 436 | application/json: 437 | schema: 438 | $ref: '#/components/schemas/Order' 439 | application/xml: 440 | schema: 441 | $ref: '#/components/schemas/Order' 442 | '400': 443 | description: Invalid Order 444 | content: 445 | application/json: 446 | example: 447 | status: 400 448 | message: 'Invalid Order' 449 | requestBody: 450 | content: 451 | application/json: 452 | schema: 453 | $ref: '#/components/schemas/Order' 454 | description: order placed for purchasing the pet 455 | required: true 456 | '/store/order/{orderId}': 457 | get: 458 | tags: 459 | - store 460 | summary: Find purchase order by ID 461 | description: >- 462 | For valid response try integer IDs with value <= 5 or > 10. Other values 463 | will generated exceptions 464 | operationId: getOrderById 465 | parameters: 466 | - name: orderId 467 | in: path 468 | description: ID of pet that needs to be fetched 469 | required: true 470 | schema: 471 | type: integer 472 | format: int64 473 | minimum: 1 474 | maximum: 5 475 | responses: 476 | '200': 477 | description: successful operation 478 | content: 479 | application/json: 480 | schema: 481 | $ref: '#/components/schemas/Order' 482 | application/xml: 483 | schema: 484 | $ref: '#/components/schemas/Order' 485 | '400': 486 | description: Invalid ID supplied 487 | '404': 488 | description: Order not found 489 | delete: 490 | tags: 491 | - store 492 | summary: Delete purchase order by ID 493 | description: >- 494 | For valid response try integer IDs with value < 1000. Anything above 495 | 1000 or nonintegers will generate API errors 496 | operationId: deleteOrder 497 | parameters: 498 | - name: orderId 499 | in: path 500 | description: ID of the order that needs to be deleted 501 | required: true 502 | schema: 503 | type: string 504 | minimum: 1 505 | responses: 506 | '400': 507 | description: Invalid ID supplied 508 | '404': 509 | description: Order not found 510 | /store/subscribe: 511 | post: 512 | tags: 513 | - store 514 | summary: Subscribe to the Store events 515 | description: Add subscription for a store events 516 | requestBody: 517 | content: 518 | application/json: 519 | schema: 520 | type: object 521 | properties: 522 | callbackUrl: 523 | type: string 524 | format: uri 525 | description: This URL will be called by the server when the desired event will occur 526 | example: https://myserver.com/send/callback/here 527 | eventName: 528 | type: string 529 | description: Event name for the subscription 530 | enum: 531 | - orderInProgress 532 | - orderShipped 533 | - orderDelivered 534 | example: orderInProgress 535 | required: 536 | - callbackUrl 537 | - eventName 538 | responses: 539 | '201': 540 | description: Subscription added 541 | content: 542 | application/json: 543 | schema: 544 | type: object 545 | properties: 546 | subscriptionId: 547 | type: string 548 | example: AAA-123-BBB-456 549 | '200': 550 | description: Successful operation 551 | content: 552 | application/json: 553 | schema: 554 | type: array 555 | maxItems: 999 556 | minItems: 0 557 | items: 558 | type: array 559 | maxItems: 777 560 | minItems: 111 561 | items: 562 | type: number 563 | callbacks: 564 | orderInProgress: 565 | '{$request.body#/callbackUrl}?event={$request.body#/eventName}': 566 | servers: 567 | - url: //callback-url.path-level/v1 568 | description: Path level server 1 569 | - url: //callback-url.path-level/v2 570 | description: Path level server 2 571 | post: 572 | summary: Order in Progress (Summary) 573 | description: A callback triggered every time an Order is updated status to "inProgress" (Description) 574 | externalDocs: 575 | description: Find out more 576 | url: 'https://more-details.com/demo' 577 | requestBody: 578 | content: 579 | application/json: 580 | schema: 581 | type: object 582 | properties: 583 | orderId: 584 | type: string 585 | example: '123' 586 | timestamp: 587 | type: string 588 | format: date-time 589 | example: '2018-10-19T16:46:45Z' 590 | status: 591 | type: string 592 | example: 'inProgress' 593 | application/xml: 594 | schema: 595 | type: object 596 | properties: 597 | orderId: 598 | type: string 599 | example: '123' 600 | example: | 601 | 602 | 603 | 123 604 | inProgress 605 | 2018-10-19T16:46:45Z 606 | 607 | responses: 608 | '200': 609 | description: Callback successfully processed and no retries will be performed 610 | content: 611 | application/json: 612 | schema: 613 | type: object 614 | properties: 615 | someProp: 616 | type: string 617 | example: '123' 618 | '299': 619 | description: Response for cancelling subscription 620 | '500': 621 | description: Callback processing failed and retries will be performed 622 | x-codeSamples: 623 | - lang: 'C#' 624 | source: | 625 | PetStore.v1.Pet pet = new PetStore.v1.Pet(); 626 | pet.setApiKey("your api key"); 627 | pet.petType = PetStore.v1.Pet.TYPE_DOG; 628 | pet.name = "Rex"; 629 | // set other fields 630 | PetStoreResponse response = pet.create(); 631 | if (response.statusCode == HttpStatusCode.Created) 632 | { 633 | // Successfully created 634 | } 635 | else 636 | { 637 | // Something wrong -- check response for errors 638 | Console.WriteLine(response.getRawResponse()); 639 | } 640 | - lang: PHP 641 | source: | 642 | $form = new \PetStore\Entities\Pet(); 643 | $form->setPetType("Dog"); 644 | $form->setName("Rex"); 645 | // set other fields 646 | try { 647 | $pet = $client->pets()->create($form); 648 | } catch (UnprocessableEntityException $e) { 649 | var_dump($e->getErrors()); 650 | } 651 | put: 652 | description: Order in Progress (Only Description) 653 | servers: 654 | - url: //callback-url.operation-level/v1 655 | description: Operation level server 1 (Operation override) 656 | - url: //callback-url.operation-level/v2 657 | description: Operation level server 2 (Operation override) 658 | requestBody: 659 | content: 660 | application/json: 661 | schema: 662 | type: object 663 | properties: 664 | orderId: 665 | type: string 666 | example: '123' 667 | timestamp: 668 | type: string 669 | format: date-time 670 | example: '2018-10-19T16:46:45Z' 671 | status: 672 | type: string 673 | example: 'inProgress' 674 | application/xml: 675 | schema: 676 | type: object 677 | properties: 678 | orderId: 679 | type: string 680 | example: '123' 681 | example: | 682 | 683 | 684 | 123 685 | inProgress 686 | 2018-10-19T16:46:45Z 687 | 688 | responses: 689 | '200': 690 | description: Callback successfully processed and no retries will be performed 691 | content: 692 | application/json: 693 | schema: 694 | type: object 695 | properties: 696 | someProp: 697 | type: string 698 | example: '123' 699 | orderShipped: 700 | '{$request.body#/callbackUrl}?event={$request.body#/eventName}': 701 | post: 702 | description: | 703 | Very long description 704 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 705 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis 706 | nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 707 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu 708 | fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in 709 | culpa qui officia deserunt mollit anim id est laborum. 710 | requestBody: 711 | content: 712 | application/json: 713 | schema: 714 | type: object 715 | properties: 716 | orderId: 717 | type: string 718 | example: '123' 719 | timestamp: 720 | type: string 721 | format: date-time 722 | example: '2018-10-19T16:46:45Z' 723 | estimatedDeliveryDate: 724 | type: string 725 | format: date-time 726 | example: '2018-11-11T16:00:00Z' 727 | responses: 728 | '200': 729 | description: Callback successfully processed and no retries will be performed 730 | orderDelivered: 731 | 'http://notificationServer.com?url={$request.body#/callbackUrl}&event={$request.body#/eventName}': 732 | post: 733 | deprecated: true 734 | summary: Order delivered 735 | description: A callback triggered every time an Order is delivered to the recipient 736 | requestBody: 737 | content: 738 | application/json: 739 | schema: 740 | type: object 741 | properties: 742 | orderId: 743 | type: string 744 | example: '123' 745 | timestamp: 746 | type: string 747 | format: date-time 748 | example: '2018-10-19T16:46:45Z' 749 | responses: 750 | '200': 751 | description: Callback successfully processed and no retries will be performed 752 | /user: 753 | post: 754 | tags: 755 | - user 756 | summary: Create user 757 | description: This can only be done by the logged in user. 758 | operationId: createUser 759 | responses: 760 | default: 761 | description: successful operation 762 | requestBody: 763 | content: 764 | application/json: 765 | schema: 766 | $ref: '#/components/schemas/User' 767 | description: Created user object 768 | required: true 769 | '/user/{username}': 770 | get: 771 | tags: 772 | - user 773 | summary: Get user by user name 774 | description: '' 775 | operationId: getUserByName 776 | parameters: 777 | - name: username 778 | in: path 779 | description: 'The name that needs to be fetched. Use user1 for testing. ' 780 | required: true 781 | schema: 782 | type: string 783 | responses: 784 | '200': 785 | description: successful operation 786 | content: 787 | application/json: 788 | schema: 789 | $ref: '#/components/schemas/User' 790 | application/xml: 791 | schema: 792 | $ref: '#/components/schemas/User' 793 | '400': 794 | description: Invalid username supplied 795 | '404': 796 | description: User not found 797 | put: 798 | tags: 799 | - user 800 | summary: Updated user 801 | description: This can only be done by the logged in user. 802 | operationId: updateUser 803 | parameters: 804 | - name: username 805 | in: path 806 | description: name that need to be deleted 807 | required: true 808 | schema: 809 | type: string 810 | responses: 811 | '400': 812 | description: Invalid user supplied 813 | '404': 814 | description: User not found 815 | '200': 816 | content: 817 | application/json: 818 | schema: 819 | $ref: '#/components/schemas/User' 820 | requestBody: 821 | content: 822 | application/json: 823 | schema: 824 | $ref: '#/components/schemas/User' 825 | description: Updated user object 826 | required: true 827 | delete: 828 | tags: 829 | - user 830 | summary: Delete user 831 | description: This can only be done by the logged in user. 832 | operationId: deleteUser 833 | parameters: 834 | - name: username 835 | in: path 836 | description: The name that needs to be deleted 837 | required: true 838 | schema: 839 | type: string 840 | responses: 841 | '400': 842 | description: Invalid username supplied 843 | '404': 844 | description: User not found 845 | /user/createWithArray: 846 | post: 847 | tags: 848 | - user 849 | summary: Creates list of users with given input array 850 | description: '' 851 | operationId: createUsersWithArrayInput 852 | responses: 853 | default: 854 | description: successful operation 855 | requestBody: 856 | $ref: '#/components/requestBodies/UserArray' 857 | /user/createWithList: 858 | post: 859 | tags: 860 | - user 861 | summary: Creates list of users with given input array 862 | description: '' 863 | operationId: createUsersWithListInput 864 | responses: 865 | default: 866 | description: successful operation 867 | requestBody: 868 | $ref: '#/components/requestBodies/UserArray' 869 | /user/login: 870 | get: 871 | tags: 872 | - user 873 | summary: Logs user into the system 874 | description: '' 875 | operationId: loginUser 876 | parameters: 877 | - name: username 878 | in: query 879 | description: The user name for login 880 | required: true 881 | schema: 882 | type: string 883 | - name: password 884 | in: query 885 | description: The password for login in clear text 886 | required: true 887 | schema: 888 | type: string 889 | responses: 890 | '200': 891 | description: successful operation 892 | headers: 893 | X-Rate-Limit: 894 | description: calls per hour allowed by the user 895 | schema: 896 | type: integer 897 | format: int32 898 | X-Expires-After: 899 | description: date in UTC when token expires 900 | schema: 901 | type: string 902 | format: date-time 903 | content: 904 | application/json: 905 | schema: 906 | type: string 907 | examples: 908 | response: 909 | value: OK 910 | application/xml: 911 | schema: 912 | type: string 913 | examples: 914 | response: 915 | value: OK 916 | text/plain: 917 | examples: 918 | response: 919 | value: OK 920 | '400': 921 | description: Invalid username/password supplied 922 | /user/logout: 923 | get: 924 | tags: 925 | - user 926 | summary: Logs out current logged in user session 927 | description: '' 928 | operationId: logoutUser 929 | responses: 930 | default: 931 | description: successful operation 932 | components: 933 | pathItems: 934 | webhooks: 935 | put: 936 | summary: Get a cat details after update 937 | description: Get a cat details after update 938 | operationId: updatedCat 939 | tags: 940 | - webhooks 941 | requestBody: 942 | description: Information about cat in the system 943 | content: 944 | multipart/form-data: 945 | schema: 946 | $ref: '#/components/schemas/Cat' 947 | responses: 948 | '200': 949 | description: update Cat details 950 | post: 951 | summary: Create new cat 952 | description: Info about new cat 953 | operationId: createdCat 954 | tags: 955 | - webhooks 956 | requestBody: 957 | description: Information about cat in the system 958 | content: 959 | multipart/form-data: 960 | schema: 961 | $ref: '#/components/schemas/Cat' 962 | responses: 963 | '200': 964 | description: create Cat details 965 | schemas: 966 | ApiResponse: 967 | type: object 968 | patternProperties: 969 | ^S_\\w+\\.[1-9]{2,4}$: 970 | description: The measured skill for hunting 971 | if: 972 | x-displayName: fieldName === 'status' 973 | else: 974 | minLength: 1 975 | maxLength: 10 976 | then: 977 | format: url 978 | type: string 979 | enum: 980 | - success 981 | - failed 982 | ^O_\\w+\\.[1-9]{2,4}$: 983 | type: object 984 | properties: 985 | nestedProperty: 986 | type: [string, boolean] 987 | description: The measured skill for hunting 988 | default: lazy 989 | example: adventurous 990 | enum: 991 | - clueless 992 | - lazy 993 | - adventurous 994 | - aggressive 995 | properties: 996 | code: 997 | type: integer 998 | format: int32 999 | type: 1000 | type: string 1001 | message: 1002 | type: string 1003 | Cat: 1004 | description: A representation of a cat 1005 | allOf: 1006 | - $ref: '#/components/schemas/Pet' 1007 | - type: object 1008 | properties: 1009 | huntingSkill: 1010 | type: [string, boolean] 1011 | description: The measured skill for hunting 1012 | default: lazy 1013 | example: adventurous 1014 | enum: 1015 | - clueless 1016 | - lazy 1017 | - adventurous 1018 | - aggressive 1019 | required: 1020 | - huntingSkill 1021 | Category: 1022 | type: object 1023 | properties: 1024 | id: 1025 | description: Category ID 1026 | $ref: '#/components/schemas/Id' 1027 | name: 1028 | description: Category name 1029 | type: string 1030 | minLength: 1 1031 | sub: 1032 | description: Test Sub Category 1033 | type: object 1034 | properties: 1035 | prop1: 1036 | type: string 1037 | description: Dumb Property 1038 | xml: 1039 | name: Category 1040 | Dog: 1041 | description: A representation of a dog 1042 | allOf: 1043 | - $ref: '#/components/schemas/Pet' 1044 | - type: object 1045 | properties: 1046 | packSize: 1047 | type: integer 1048 | format: int32 1049 | description: The size of the pack the dog is from 1050 | default: 1 1051 | minimum: 1 1052 | required: 1053 | - packSize 1054 | HoneyBee: 1055 | description: A representation of a honey bee 1056 | allOf: 1057 | - $ref: '#/components/schemas/Pet' 1058 | - type: object 1059 | properties: 1060 | honeyPerDay: 1061 | type: number 1062 | description: Average amount of honey produced per day in ounces 1063 | example: 3.14 1064 | multipleOf: .01 1065 | required: 1066 | - honeyPerDay 1067 | Id: 1068 | type: integer 1069 | format: int64 1070 | readOnly: true 1071 | Order: 1072 | type: object 1073 | properties: 1074 | id: 1075 | description: Order ID 1076 | $ref: '#/components/schemas/Id' 1077 | petId: 1078 | description: Pet ID 1079 | $ref: '#/components/schemas/Id' 1080 | quantity: 1081 | type: integer 1082 | format: int32 1083 | minimum: 1 1084 | default: 1 1085 | shipDate: 1086 | description: Estimated ship date 1087 | type: string 1088 | format: date-time 1089 | status: 1090 | type: string 1091 | description: Order Status 1092 | enum: 1093 | - placed 1094 | - approved 1095 | - delivered 1096 | complete: 1097 | description: Indicates whenever order was completed or not 1098 | type: boolean 1099 | default: false 1100 | readOnly: true 1101 | requestId: 1102 | description: Unique Request Id 1103 | type: string 1104 | writeOnly: true 1105 | xml: 1106 | name: Order 1107 | Pet: 1108 | type: object 1109 | required: 1110 | - name 1111 | - photoUrls 1112 | discriminator: 1113 | propertyName: petType 1114 | mapping: 1115 | cat: '#/components/schemas/Cat' 1116 | dog: '#/components/schemas/Dog' 1117 | bee: '#/components/schemas/HoneyBee' 1118 | properties: 1119 | id: 1120 | externalDocs: 1121 | description: 'Find more info here' 1122 | url: 'https://example.com' 1123 | description: Pet ID 1124 | $ref: '#/components/schemas/Id' 1125 | category: 1126 | description: Categories this pet belongs to 1127 | $ref: '#/components/schemas/Category' 1128 | name: 1129 | description: The name given to a pet 1130 | type: string 1131 | example: Guru 1132 | photoUrls: 1133 | description: The list of URL to a cute photos featuring pet 1134 | type: [string, integer, 'null'] 1135 | minItems: 1 1136 | maxItems: 10 1137 | xml: 1138 | name: photoUrl 1139 | wrapped: true 1140 | items: 1141 | type: string 1142 | format: url 1143 | if: 1144 | x-displayName: isString 1145 | type: string 1146 | then: 1147 | minItems: 1 1148 | maxItems: 15 1149 | else: 1150 | x-displayName: notString 1151 | type: [integer, 'null'] 1152 | minItems: 1 1153 | maxItems: 20 1154 | friend: 1155 | $ref: '#/components/schemas/Pet' 1156 | tags: 1157 | description: Tags attached to the pet 1158 | type: array 1159 | exclusiveMaximum: 100 1160 | exclusiveMinimum: 0 1161 | xml: 1162 | name: tag 1163 | wrapped: true 1164 | items: 1165 | $ref: '#/components/schemas/Tag' 1166 | status: 1167 | type: string 1168 | description: Pet status in the store 1169 | enum: 1170 | - available 1171 | - pending 1172 | - sold 1173 | default: pending 1174 | petType: 1175 | description: Type of a pet 1176 | type: string 1177 | huntingSkill: 1178 | type: [integer] 1179 | enum: 1180 | - 0 1181 | - 1 1182 | - 2 1183 | xml: 1184 | name: Pet 1185 | Tag: 1186 | type: object 1187 | properties: 1188 | id: 1189 | type: number 1190 | description: Tag ID 1191 | $ref: '#/components/schemas/Id' 1192 | name: 1193 | description: Tag name 1194 | type: string 1195 | minLength: 1 1196 | xml: 1197 | name: Tag 1198 | User: 1199 | type: object 1200 | properties: 1201 | id: 1202 | $ref: '#/components/schemas/Id' 1203 | pet: 1204 | oneOf: 1205 | - $ref: '#/components/schemas/Pet' 1206 | title: Pettie 1207 | - $ref: '#/components/schemas/Tag' 1208 | username: 1209 | description: User supplied username 1210 | type: string 1211 | minLength: 4 1212 | example: John78 1213 | firstName: 1214 | description: User first name 1215 | type: string 1216 | minLength: 1 1217 | example: John 1218 | lastName: 1219 | description: User last name 1220 | type: string 1221 | minLength: 1 1222 | example: Smith 1223 | email: 1224 | description: User email address 1225 | type: string 1226 | format: email 1227 | example: john.smith@example.com 1228 | password: 1229 | type: string 1230 | description: >- 1231 | User password, MUST contain a mix of upper and lower case letters, 1232 | as well as digits 1233 | format: password 1234 | minLength: 8 1235 | pattern: '/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/' 1236 | example: drowssaP123 1237 | phone: 1238 | description: User phone number in international format 1239 | type: string 1240 | pattern: '/^\+(?:[0-9]-?){6,14}[0-9]$/' 1241 | example: +1-202-555-0192 1242 | userStatus: 1243 | description: User status 1244 | type: integer 1245 | format: int32 1246 | image: 1247 | description: User image 1248 | type: string 1249 | contentEncoding: base64 1250 | contentMediaType: image/png 1251 | addresses: 1252 | type: array 1253 | minItems: 0 1254 | maxLength: 10 1255 | prefixItems: 1256 | - type: object 1257 | properties: 1258 | city: 1259 | type: string 1260 | minLength: 0 1261 | country: 1262 | type: string 1263 | minLength: 0 1264 | street: 1265 | description: includes build/apartment number 1266 | type: string 1267 | minLength: 0 1268 | - type: number 1269 | items: 1270 | type: string 1271 | if: 1272 | title: userStatus === 10 1273 | properties: 1274 | userStatus: 1275 | enum: [10] 1276 | then: 1277 | required: ['phone'] 1278 | else: 1279 | required: [] 1280 | xml: 1281 | name: User 1282 | requestBodies: 1283 | Pet: 1284 | content: 1285 | application/json: 1286 | schema: 1287 | description: My Pet 1288 | title: Pettie 1289 | $ref: '#/components/schemas/Pet' 1290 | application/xml: 1291 | schema: 1292 | type: 'object' 1293 | properties: 1294 | name: 1295 | type: string 1296 | description: hooray 1297 | description: Pet object that needs to be added to the store 1298 | required: true 1299 | UserArray: 1300 | content: 1301 | application/json: 1302 | schema: 1303 | type: array 1304 | items: 1305 | $ref: '#/components/schemas/User' 1306 | description: List of user object 1307 | required: true 1308 | securitySchemes: 1309 | petstore_auth: 1310 | description: | 1311 | Get access to data while protecting your account credentials. 1312 | OAuth2 is also a safer and more secure way to give you access. 1313 | type: oauth2 1314 | flows: 1315 | implicit: 1316 | authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' 1317 | scopes: 1318 | 'write:pets': modify pets in your account 1319 | 'read:pets': read your pets 1320 | api_key: 1321 | description: > 1322 | For this sample, you can use the api key `special-key` to test the 1323 | authorization filters. 1324 | type: apiKey 1325 | name: api_key 1326 | in: header 1327 | examples: 1328 | Order: 1329 | value: 1330 | quantity: 1 1331 | shipDate: '2018-10-19T16:46:45Z' 1332 | status: placed 1333 | complete: false 1334 | webhooks: 1335 | newPet: 1336 | post: 1337 | summary: New pet 1338 | description: Information about a new pet in the systems 1339 | operationId: newPet 1340 | tags: 1341 | - webhooks 1342 | requestBody: 1343 | content: 1344 | application/json: 1345 | schema: 1346 | $ref: '#/components/schemas/Pet' 1347 | responses: 1348 | '200': 1349 | description: Return a 200 status to indicate that the data was received successfully 1350 | myWebhook: 1351 | $ref: '#/components/pathItems/webhooks' 1352 | description: Overriding description 1353 | summary: Overriding summary 1354 | -------------------------------------------------------------------------------- /test/test_schema.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: Sample API 4 | version: 0.0.1 5 | 6 | servers: 7 | - url: http://api.example.com/v1 8 | 9 | components: 10 | schemas: 11 | Error: 12 | type: object 13 | properties: 14 | code: 15 | type: integer 16 | message: 17 | type: string 18 | 19 | RequiredSample: 20 | type: object 21 | properties: 22 | p1: 23 | type: integer 24 | p2: 25 | type: integer 26 | readOnly: true 27 | p3: 28 | type: integer 29 | writeOnly: true 30 | p4: 31 | type: integer 32 | p5: 33 | type: integer 34 | required: 35 | - p1 36 | - p2 37 | - p3 38 | 39 | CollectionSample: 40 | type: object 41 | properties: 42 | elements: 43 | type: array 44 | items: 45 | $ref: "#/components/schemas/RequiredSample" 46 | 47 | 48 | securitySchemes: 49 | BasicAuth: 50 | type: http 51 | scheme: basic 52 | 53 | security: 54 | - BasicAuth: [] 55 | 56 | paths: 57 | /jsonArray: 58 | post: 59 | operationId: jsonArray 60 | summary: Request in json, response in text/plain 61 | responses: 62 | '200': 63 | description: successful operation 64 | content: 65 | text/plain: 66 | schema: 67 | type: array 68 | properties: 69 | json_res: 70 | type: integer 71 | requestBody: 72 | content: 73 | application/json: 74 | schema: 75 | type: array 76 | items: 77 | type: integer 78 | 79 | /headersContentType: 80 | get: 81 | summary: Response with preassigned content type 82 | operationId: headersContentType 83 | parameters: 84 | - name: response_view 85 | in: query 86 | description: | 87 | If the value is "simple", the response without headers, with only content will be sent from the server. 88 | In a case of any other value or in a case of absence of this parameter the response with headers will be sent from the server. 89 | required: false 90 | schema: 91 | type: string 92 | examples: 93 | "simple" 94 | - name: content_type 95 | in: query 96 | description: The type of response content 97 | required: true 98 | schema: 99 | type: string 100 | oneOf: 101 | - const: "application/xml" 102 | - const: "application/json" 103 | - const: "text/plain" 104 | - const: "random/nonsense" 105 | - name: Accept 106 | description: Accept header value 107 | required: true 108 | in: header 109 | schema: 110 | type: string 111 | responses: 112 | '200': 113 | description: 114 | content: 115 | application/json: 116 | schema: 117 | type: object 118 | examples: 119 | '{"result": "OK"}' 120 | application/xml: 121 | schema: 122 | type: string 123 | examples: 124 | ' OK ' 125 | text/plain: 126 | schema: 127 | type: string 128 | examples: 129 | 'OK' 130 | 131 | /requiredFilter: 132 | post: 133 | summary: Required filters in requests 134 | operationId: saveRequiredFilter 135 | responses: 136 | '200': 137 | description: Required fields sample 138 | content: 139 | application/json: 140 | schema: 141 | $ref: "#/components/schemas/RequiredSample" 142 | requestBody: 143 | content: 144 | application/json: 145 | schema: 146 | $ref: "#/components/schemas/RequiredSample" 147 | 148 | /selectCollectionFields: 149 | post: 150 | summary: Response with collection content 151 | operationId: selectCollectionFields 152 | responses: 153 | '200': 154 | description: 155 | content: 156 | application/json: 157 | schema: 158 | $ref: "#/components/schemas/CollectionSample" 159 | requestBody: 160 | content: 161 | application/json: 162 | schema: 163 | type: object 164 | 'x-collection-name': elements 165 | 'x-collection-type': some 166 | 167 | 168 | -------------------------------------------------------------------------------- /test/test_schema_res.erl: -------------------------------------------------------------------------------- 1 | -module(test_schema_res). 2 | -compile([nowarn_export_all, export_all]). 3 | 4 | authorize(_) -> 5 | #{auth => yes_please}. 6 | 7 | log_call(CallInfo) -> 8 | (whereis(test_schema_log_call_server) /= undefined) andalso (test_schema_log_call_server ! {log_call, ?MODULE, CallInfo}). 9 | 10 | jsonArray(#{json_body := [1,2,3]} = Req) -> 11 | ct:pal("Req: ~p", [Req]), 12 | #{json_res => <<"1">>}. 13 | 14 | 15 | headersContentType(#{accept := _Accept, content_type := ContentType} = Req) -> 16 | Content = #{ 17 | 'text/plain' => <<"OK">>, 18 | 'application/json' => #{result => <<"OK">>}, 19 | 'application/xml' => <<" OK ">>, 20 | 'random/nonsense' => <<"Some">> 21 | }, 22 | case Req of 23 | #{response_view := <<"simple">>} -> maps:get(ContentType, Content); 24 | _ -> {raw, 200, #{<<"content-type">> => atom_to_binary(ContentType)}, maps:get(ContentType, Content)} 25 | end. 26 | 27 | saveRequiredFilter(#{json_body := B}) -> 28 | #{} = B. 29 | 30 | 31 | selectCollectionFields(#{json_body := #{<<"unavailable">> := true}}) -> 32 | {error,unavailable}; 33 | 34 | selectCollectionFields(#{json_body := B}) -> 35 | #{elements => [B, B]}. 36 | 37 | --------------------------------------------------------------------------------