Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bb14564
fix a crash when ordinary field behind discriminator has wrong format
Jul 16, 2025
3e6a228
Merge pull request #11 from stolen/fix-discriminator-crash
maxlapshin Jul 16, 2025
2021872
openapi_schema: boolean values should not be valid strings
Aug 28, 2025
6bbd763
Merge pull request #12 from stolen/boolean_vs_string
maxlapshin Aug 28, 2025
fc12b13
fix format validation of array items
Sep 1, 2025
248d6c8
Merge pull request #13 from stolen/fix-format-validators-in-array
maxlapshin Sep 1, 2025
550b373
openapi_schema: introduce access_type=raw, fix writeOnly property beh…
Sep 1, 2025
f0b66ca
format validation test: add forgotten fixture
Sep 1, 2025
88af1db
Merge pull request #14 from stolen/fix-writeOnly-behaviour
maxlapshin Sep 1, 2025
17aa383
openapi_schema: fix error when passed discriminator has wrong type
Sep 4, 2025
25aec5f
Merge pull request #15 from stolen/fix-error-on-bad-discriminator-type
maxlapshin Sep 4, 2025
fa555b6
openapi_schema: extend error message in string length restriction cases
AndrewNikoala Sep 22, 2025
0a292a1
Merge pull request #16 from AndrewNikoala/stolen-extend_error_msg_in_…
maxlapshin Sep 23, 2025
f09a14f
openapi_schema: fix crash when validating array/map vs const
Sep 26, 2025
3ba37b4
openapi_schema: count UTF8 code points when checking string length co…
Sep 26, 2025
a6d3ee8
Merge pull request #17 from stolen/string-length-in-code-points
maxlapshin Oct 1, 2025
e95b2bf
Merge pull request #18 from stolen/fix-crash-on-const
maxlapshin Oct 1, 2025
600a243
reduce test dependencies
stolen Sep 26, 2025
56e562b
define tests workflow
stolen Sep 26, 2025
a8dc196
openapi_client: configurable http request function
Oct 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Erlang CI

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

permissions:
contents: read

jobs:
common_test:
runs-on: ubuntu-latest
strategy:
matrix:
otp_ver: [27, 28, latest]
container:
image: erlang:${{ matrix.otp_ver }}

steps:
- uses: actions/checkout@v4
- name: Run tests
env:
REBAR_CONFIG: rebar.config_
run: rebar3 as dev ct --logdir test-logs --readable true -vv
- name: upload CT report
uses: actions/upload-artifact@v4
with:
name: ct_report_${{ matrix.otp_ver }}
path: test-logs/ct_run*/

common_test_legacy:
runs-on: ubuntu-latest
strategy:
matrix:
otp_ver: [24, 25, 26]
container:
image: erlang:${{ matrix.otp_ver }}

steps:
- uses: actions/checkout@v4
- name: Run tests
env:
REBAR_CONFIG: rebar.config.pre27_
run: rebar3 as dev ct --logdir test-logs --readable true -vv
- name: upload CT report
uses: actions/upload-artifact@v4
with:
name: ct_report_${{ matrix.otp_ver }}
path: test-logs/ct_run*/
14 changes: 14 additions & 0 deletions rebar.config.pre27_
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
% No deps by default to prevent conflicts
{deps, [
]}.

{profiles, [
{dev, [
{deps, [
jsx,
{cowboy, "2.12.0"},
redbug
]},
{ct_opts, [{ct_hooks, [cth_surefire]}]}
]}
]}.
3 changes: 0 additions & 3 deletions rebar.config_
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
{profiles, [
{dev, [
{deps, [
jsx,
yamerl,
{cowboy, "2.12.0"},
{lhttpc, {git, "https://github.com/erlyvideo/lhttpc.git", {branch, "master"}}},
redbug
]},
{ct_opts, [{ct_hooks, [cth_surefire]}]}
Expand Down
34 changes: 27 additions & 7 deletions src/openapi_client.erl
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,8 @@ call(#{schema := Schema, uri := URI} = State, OperationId, Args0, Opts) when is_
#{raw_body := _} -> <<"raw_file_upload">>;
_ -> RequestBody
end]),
Timeout = proplists:get_value(timeout, Opts, 50000),
Result = case lhttpc:request(RequestURL, Method, RequestHeaders, RequestBody, Timeout) of
{ok, {{Code0,_},ResponseHeaders0,Bin0}} ->
{ok, Code0, [{string:to_lower(K),V} || {K,V} <- ResponseHeaders0], Bin0};
{error, E0} ->
{error, E0}
end,
HttpRequestFun = proplists:get_value(http_request_fun, Opts, fun http_request_httpc/5),
Result = HttpRequestFun(Method, RequestURL, RequestHeaders, RequestBody, Opts),

case Result of
{ok, Code,ResponseHeaders,Bin} when is_map_key(Code, Responses) ->
Expand Down Expand Up @@ -130,6 +125,31 @@ call(#{} = State, OperationId, Args, Opts) ->
end.


-spec http_request_httpc(
Method :: get | post | put | patch | delete,
URL :: uri_string:uri_string(),
Headers :: [{string(), string()}],
Body :: binary(),
Opts :: proplists:proplist()
) ->
{ok, Status :: integer(), Headers :: [{NameLowercase :: string(), string()}], Body :: binary()} |
{error, any()}.

http_request_httpc(Method, RequestURL, RequestHeaders, RequestBody, Opts) ->
Timeout = proplists:get_value(timeout, Opts, 50000),
Request = case Method of
get ->
{RequestURL, RequestHeaders};
_ ->
ContentType = proplists:get_value("Content-Type", RequestHeaders, "text/plain"),
{RequestURL, RequestHeaders, ContentType, RequestBody}
end,
case httpc:request(Method, Request, [{timeout, Timeout}], [{body_format, binary}]) of
{ok, {{_, Code0, _}, ResponseHeaders0, Bin0}} ->
{ok, Code0, [{string:to_lower(K), V} || {K, V} <- ResponseHeaders0], Bin0};
{error, E0} ->
{error, E0}
end.


search_operation(OperationId, #{paths := Paths}) ->
Expand Down
37 changes: 27 additions & 10 deletions src/openapi_schema.erl
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ process(Input, #{} = Opts) ->
(patch,Flag) when Flag == true; Flag == false -> ok;
(extra_obj_key,Flag) when Flag == drop; Flag == error -> ok;
(required_obj_keys,Flag) when Flag == drop; Flag == error -> ok;
(access_type,Flag) when Flag == read; Flag == write -> ok;
(access_type,Flag) when Flag == read; Flag == write; Flag == raw -> ok;
(explain,FlagList) -> check_explain_keys(FlagList);
(K,V) -> error({unknown_option,K,V})
end, Opts),
Expand Down Expand Up @@ -88,6 +88,8 @@ prepare_type(#{oneOf := Types} = Type0) ->
Type0#{oneOf := [prepare_type(T) || T <- Types]};
prepare_type(#{type := <<"object">>, properties := Props} = Type0) ->
Type0#{properties => maps:map(fun(_, T) -> prepare_type(T) end, Props)};
prepare_type(#{type := <<"array">>, items := Items} = Type0) ->
Type0#{items => prepare_type(Items)};
prepare_type(#{} = Type0) ->
% Convert format name to atom. This matches validators syntax
Type1 = case Type0 of
Expand Down Expand Up @@ -205,13 +207,15 @@ encode3(#{discriminator := #{propertyName := DKey, mapping := DMap}} = Schema, O
try binary_to_existing_atom(DValue) catch error:badarg -> DValue end;
#{ADKey := DValue} when is_binary(DValue) ->
try binary_to_existing_atom(DValue) catch error:badarg -> DValue end;
#{} -> undefined
#{} -> undefined;
{error, _} -> Input_
end
end,
ADvalue1 = DefaultFun(Input),
DiscrInput = maps:with([DKey, ADKey], Input),
ADvalue1 = DefaultFun(DiscrInput),
ADvalue2 = case ADvalue1 of
undefined ->
Try = encode3(hd(Types), Opts#{apply_defaults => true, required_obj_keys => drop}, Input, Path),
Try = encode3(hd(Types), Opts#{apply_defaults => true, required_obj_keys => drop}, DiscrInput, Path),
DefaultFun(Try);
_ ->
ADvalue1
Expand All @@ -222,6 +226,9 @@ encode3(#{discriminator := #{propertyName := DKey, mapping := DMap}} = Schema, O
encode3(maps:without([discriminator], Schema), Opts, Input, Path);
{undefined, _} ->
{error, #{error => discriminator_missing, path => Path, propertyName => DKey}};
{{error, _}, _} ->
% this error comes from processing discriminator with first possible type, and may contain useful type error (e.g. not_string)
ADvalue2;
{_, undefined} ->
{error, #{error => discriminator_unmapped, path => Path, propertyName => DKey, value => ADvalue2}};
{_, _} ->
Expand Down Expand Up @@ -282,9 +289,11 @@ encode3(#{type := <<"object">>, properties := Properties} = Schema, #{query := Q

RequiredKeys = get_required_keys(Schema, Opts),
IsReadOnly = maps:get(readOnly, Prop, false),
IsWriteOnly = maps:get(writeOnly, Prop, false),
IsPrimary = maps:get('x-primary-key', Prop, false),
IsRequired = (lists:member(FieldBin, RequiredKeys) orelse IsPrimary),
IsWriteAccess = maps:get(access_type, Opts, read) == write,
IsReadAccess = maps:get(access_type, Opts, raw) == read,
IsWriteAccess = maps:get(access_type, Opts, raw) == write,

ApplyDefaults = maps:get(apply_defaults, Opts, false),
EffectiveValue = case {Input, Prop} of
Expand Down Expand Up @@ -315,9 +324,12 @@ encode3(#{type := <<"object">>, properties := Properties} = Schema, #{query := Q
{ok, Null} when (Null == null orelse Null == undefined) andalso
not NullableProp andalso not Patching ->
Obj;
% Silently drop read only fields with write access
% Silently drop readOnly fields on write
{ok, _Value} when IsWriteAccess andalso IsReadOnly andalso (not IsRequired) ->
Obj;
% Silently drop writeOnly fields on read
{ok, _Value} when IsReadAccess andalso IsWriteOnly andalso (not IsRequired) ->
Obj;
{ok, Value} ->
case encode3(Prop#{nullable => NullableProp}, Opts, Value, Path ++ [Field]) of
{error, _} = E ->
Expand Down Expand Up @@ -450,6 +462,9 @@ encode3(#{const := Value}, #{auto_convert := Convert}, Input, Path) when is_atom
_ -> {error, #{error => not_const2, path => Path, input => Input, value => Value}}
end;

encode3(#{const := Value}, #{}, Input, Path) ->
{error, #{error => not_const, path => Path, input => Input, value => Value}};

encode3(#{enum := Choices, type := <<"string">>}, #{auto_convert := Convert}, Input, Path) ->
InputValue = case Input of
_ when is_binary(Input) -> Input;
Expand All @@ -465,6 +480,7 @@ encode3(#{enum := Choices, type := <<"string">>}, #{auto_convert := Convert}, In
encode3(#{type := <<"string">>} = Spec, #{auto_convert := Convert} = Options, Input, Path) ->
{Input1, InputForValidation} = case Input of
_ when is_binary(Input) -> {Input, Input};
_ when is_boolean(Input) -> {{error, #{error => not_string, path => Path, input => Input}}, undefined};
_ when is_atom(Input) andalso Convert -> {atom_to_binary(Input), atom_to_binary(Input)};
_ when is_atom(Input) -> {Input, atom_to_binary(Input)};
_ -> {{error, #{error => not_string, path => Path, input => Input}}, undefined}
Expand All @@ -473,11 +489,12 @@ encode3(#{type := <<"string">>} = Spec, #{auto_convert := Convert} = Options, In
{error, _} ->
Input1;
_ ->
Length = string:length(InputForValidation),
case Spec of
#{minLength := MinLength} when size(InputForValidation) < MinLength ->
{error, #{error => too_short, path => Path, input => Input, min_length => MinLength}};
#{maxLength := MaxLength} when size(InputForValidation) > MaxLength ->
{error, #{error => too_long, path => Path, input => Input, max_length => MaxLength}};
#{minLength := MinLength} when Length < MinLength ->
{error, #{error => too_short, path => Path, input => Input, detail => Length, min_length => MinLength}};
#{maxLength := MaxLength} when Length > MaxLength ->
{error, #{error => too_long, path => Path, input => Input, detail => Length, max_length => MaxLength}};
#{} ->
Format = maps:get(format, Spec, undefined),
Validators = maps:get(validators, Options),
Expand Down
41 changes: 41 additions & 0 deletions test/example-openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"openapi": "3.1.1",
"info": {
"contact": { "email": "hello@exampl.com", "name": "Test", "url": "https://example.com/" },
"description": "dummy desc",
"title": "Test API",
"version": "0.1.0"
},
"components": {
"schemas": {
"external_validators_simple": {
"type": "string",
"format": "no_space"
},
"external_validators_array": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string",
"format": "no_space"
}
}
},
"external_validators_object": {
"type": "object",
"properties": {
"in_object": {
"type": "object",
"properties": {
"prop1": {
"type": "string",
"format": "no_space"
}
}
}
}
}
}
}
}
20 changes: 7 additions & 13 deletions test/openapi_handler_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ start_http(Routes, ApiName) ->


init_per_suite(Config) ->
inets:start(),
{ok, _} = application:ensure_all_started(cowboy),
{ok, _} = application:ensure_all_started(lhttpc),

PetstorePath = filename:join(code:lib_dir(openapi_handler),"test/redocly-petstore.json"),
TestSchemaPath = filename:join(code:lib_dir(openapi_handler),"test/test_schema.json"),
Expand Down Expand Up @@ -183,9 +183,9 @@ json_body_parameters(_) ->
broken_json(_) ->
Port = integer_to_list(ranch:get_port(petstore_api_server)),
JSON = "{\"key\":\"value\"]}",
{ok, {{400,_},Headers,Body}} = lhttpc:request("http://127.0.0.1:"++Port++"/test/yml/store/order", post,
[{"Content-Type", "application/json"}], JSON, 5000),
"application/json" = proplists:get_value("Content-Type", Headers),
Request = {"http://127.0.0.1:"++Port++"/test/yml/store/order", [], "application/json", JSON},
{ok, {{_, 400, _}, Headers, Body}} = httpc:request(post, Request, [{timeout, 5000}], [{body_format, binary}]),
"application/json" = proplists:get_value("content-type", Headers),
#{<<"error">> := <<"broken_json">>} = openapi_json:decode(Body),
ok.

Expand Down Expand Up @@ -438,18 +438,12 @@ required_keys_filter(_) ->


select_not_filters_required_keys(_) ->
% p3 is 'writeOnly', so it should be dropped in results
#{elements := [Elem1,Elem1]} = openapi_client:call(test_schema_api, selectCollectionFields,
#{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}}),
#{p1 := 1, p2 := 2, p3 := 3, p4 := 4, p5 :=5} = Elem1,
#{p1 := 1, p2 := 2, p4 := 4, p5 :=5} = Elem1,

% p1, p2 are 'readOnly' required keys
#{elements := [Elem2,Elem2]} = openapi_client:call(test_schema_api, selectCollectionFields,
#{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}, select => <<"p3">>}),
#{p1 := 1, p2 := 2, p3 := 3} = Elem2,
undefined = maps:get(p4, Elem2, undefined),
undefined = maps:get(p5, Elem2, undefined),

% p3 is 'writeOnly' required key
% p1, p2 are required keys, so they are returned despite not explicitly requested
#{elements := [Elem3,Elem3]} = openapi_client:call(test_schema_api, selectCollectionFields,
#{json_body => #{p1 => 1, p2 => 2, p3 => 3, p4 => 4, p5 => 5}, select => <<"p4">>}),
#{p1 := 1, p2 := 2, p4 := 4} = Elem3,
Expand Down
Loading