diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..819b668 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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*/ diff --git a/rebar.config.pre27_ b/rebar.config.pre27_ new file mode 100644 index 0000000..78913c2 --- /dev/null +++ b/rebar.config.pre27_ @@ -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]}]} + ]} +]}. diff --git a/rebar.config_ b/rebar.config_ index fc2641b..afc56eb 100644 --- a/rebar.config_ +++ b/rebar.config_ @@ -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]}]} diff --git a/src/openapi_client.erl b/src/openapi_client.erl index b0ec0b1..da560d4 100644 --- a/src/openapi_client.erl +++ b/src/openapi_client.erl @@ -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) -> @@ -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}) -> diff --git a/src/openapi_schema.erl b/src/openapi_schema.erl index 123e0f6..47d9ab2 100644 --- a/src/openapi_schema.erl +++ b/src/openapi_schema.erl @@ -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), @@ -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 @@ -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 @@ -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}}; {_, _} -> @@ -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 @@ -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 -> @@ -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; @@ -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} @@ -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), diff --git a/test/example-openapi.json b/test/example-openapi.json new file mode 100644 index 0000000..6e997ad --- /dev/null +++ b/test/example-openapi.json @@ -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" + } + } + } + } + } + } + } +} diff --git a/test/openapi_handler_SUITE.erl b/test/openapi_handler_SUITE.erl index b6b227b..66e6c8b 100644 --- a/test/openapi_handler_SUITE.erl +++ b/test/openapi_handler_SUITE.erl @@ -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"), @@ -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. @@ -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, diff --git a/test/openapi_schema_SUITE.erl b/test/openapi_schema_SUITE.erl index 04a03f9..6db88c5 100644 --- a/test/openapi_schema_SUITE.erl +++ b/test/openapi_schema_SUITE.erl @@ -15,8 +15,10 @@ groups() -> extra_keys_drop, null_in_array, nullable_by_oneof, + boolean_vs_string, discriminator, discriminator_default_missing_fields, + discriminator_default_wrong_format, recursive_discriminator, non_object_validate, regexp_pattern, @@ -32,7 +34,9 @@ groups() -> check_explain, check_explain_on_error, one_of_integer_const, + one_of_const_wrong_type, one_of_const_default, + drop_unidirectional_keys, filter_read_only_props ]}, {introspection, [], [ @@ -47,6 +51,8 @@ init_per_suite(Config) -> openapi_handler:load_schema(SchemaPath, test_openapi), BigOpenapiPath = code:lib_dir(openapi_handler) ++ "/test/redocly-big-openapi.json", openapi_handler:load_schema(BigOpenapiPath, big_openapi), + ExamplePath = code:lib_dir(openapi_handler) ++ "/test/example-openapi.json", + openapi_handler:load_schema(ExamplePath, example_openapi), Config. end_per_suite(Config) -> @@ -116,6 +122,12 @@ nullable_by_oneof(_) -> Expect2 = openapi_schema:process(#{nk => null}, #{schema => Schema, extra_obj_key => error, apply_defaults => true}), ok. +boolean_vs_string(_) -> + % Even when auto_convert is enabled (default behaviour) consider boolean values not valid strings + {error, _} = openapi_schema:process(true, #{schema => #{type => <<"string">>}}), + {error, _} = openapi_schema:process(false, #{schema => #{type => <<"string">>}}), + ok. + discriminator(_) -> FooProp = #{dis => #{type => <<"string">>}, k1 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, BarProp = #{dis => #{type => <<"string">>}, k2 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, @@ -154,6 +166,8 @@ discriminator(_) -> Foo5 = openapi_schema:process(#{k1 => 12, k2 => 34, k4 => 56}, #{type => discr_t, whole_schema => DSchema1}), [k1, k4] = lists:sort(maps:keys(Foo5)), % apply default + {error, #{error := not_string}} = openapi_schema:process(#{dis => 42}, #{type => discr_t, query => true, whole_schema => DSchema}), + ok. % See example without oneOf at https://spec.openapis.org/oas/v3.1.0.html#discriminator-object @@ -204,6 +218,8 @@ recursive_discriminator(_) -> DSchema3b = DSchemaForDis(#{type => <<"string">>, default => <<"bar">>}), #{dis := bar, k2 := 22} = openapi_schema:process(#{}, #{type => discr_t, whole_schema => DSchema3b, apply_defaults => true}), + {error, #{error := not_string}} = openapi_schema:process(#{dis => 42}, #{type => discr_t, whole_schema => DSchema3b, apply_defaults => true}), + [] = maps:keys(openapi_schema:process(#{}, #{type => discr_t, whole_schema => DSchema3b})), [k2, k3] = maps:keys(openapi_schema:process(#{k2 => 11, k3 => 435}, #{type => discr_t, whole_schema => DSchema3b})), % k1 from foo_t is handled like dis=bar @@ -232,6 +248,26 @@ discriminator_default_missing_fields(_) -> ok. +discriminator_default_wrong_format(_) -> + FooProp = #{dis => #{type => <<"string">>}, k1 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, + BarProp = #{dis => #{type => <<"string">>}, k2 => #{type => <<"integer">>}, k3 => #{type => <<"integer">>}}, + FooOwn = #{type => <<"object">>, properties => FooProp, required => [<<"k1">>, <<"k3">>]}, + BarOwn = #{type => <<"object">>, properties => BarProp, required => [<<"k2">>, <<"k3">>]}, + FooType = #{allOf => [#{'$ref' => <<"#/components/schemas/discr_t">>}, FooOwn]}, + BarType = #{allOf => [#{'$ref' => <<"#/components/schemas/discr_t">>}, BarOwn]}, + Mapping = #{foo => <<"#/components/schemas/foo_t">>, bar => <<"#/components/schemas/bar_t">>}, + DSchemaForDis = fun(DisSchema) -> + BaseProp = #{dis => DisSchema}, + DType = #{type => <<"object">>, properties => BaseProp, discriminator => #{propertyName => <<"dis">>, mapping => Mapping}}, + #{components => #{schemas => #{discr_t => DType, foo_t => FooType, bar_t => BarType}}} + end, + DSchema_foo = DSchemaForDis(#{type => <<"string">>, default => <<"foo">>}), + {error, #{error := not_integer}} = openapi_schema:process(#{k3 => [<<"BAD">>]}, #{type => discr_t, whole_schema => DSchema_foo, required_obj_keys => error}), + DSchema_bar = DSchemaForDis(#{type => <<"string">>, default => <<"bar">>}), + {error, #{error := not_integer}} = openapi_schema:process(#{k3 => [<<"BAD">>]}, #{type => discr_t, whole_schema => DSchema_bar, required_obj_keys => error}), + ok. + + non_object_validate(_) -> {error, #{error := not_object}} = openapi_schema:process([<<"123">>], #{schema => #{type => <<"object">>}}), ok. @@ -274,10 +310,18 @@ external_validators(_) -> {error, Err2} = openapi_schema:process(<<" ab cd">>, #{schema => Schema, validators => Validators}), #{error := wrong_format, format := no_space, detail := leading_space} = Err2, - % format validators work with loaded schema - % big_openapi.digest is a composition of allOf and object, so it needs schema to be properly prepared - #{password := <<"ab_cd">>} = openapi_schema:process( - #{username => <<"Joe">>, password => <<"ab cd">>}, #{name => big_openapi, type => digest, validators => #{password => fun no_space_validator/1}}), + % format validators work with loaded schema (to ensure openapi_schema:prepare_type works well) + % as simple value + <<"ab_cd">> = openapi_schema:process(<<"ab cd">>, #{name => example_openapi, type => external_validators_simple, validators => Validators}), + {error, _} = openapi_schema:process(<<" cd">>, #{name => example_openapi, type => external_validators_simple, validators => Validators}), + % as array item (nested) + [[<<"ab_cd">>]] = openapi_schema:process([[<<"ab cd">>]], #{name => example_openapi, type => external_validators_array, validators => Validators}), + {error, _} = openapi_schema:process([[<<" cd">>]], #{name => example_openapi, type => external_validators_array, validators => Validators}), + % as object key (nested) + #{in_object := #{prop1 := <<"ab_cd">>}} = + openapi_schema:process(#{in_object => #{prop1 => <<"ab cd">>}}, #{name => example_openapi, type => external_validators_object, validators => Validators}), + {error, _} = + openapi_schema:process(#{in_object => #{prop1 => <<" cd">>}}, #{name => example_openapi, type => external_validators_object, validators => Validators}), % format validator and pattern work simultaneously Schema2 = #{type => <<"string">>, format => no_space, pattern => <<"^[a-z]+$">>}, @@ -290,10 +334,21 @@ external_validators(_) -> min_max_length(_) -> - {error, #{error := too_short}} = + {error, #{error := too_short, detail := 3}} = openapi_schema:process(<<"123">>, #{schema => #{type => <<"string">>, minLength => 5}}), - {error, #{error := too_long}} = + {error, #{error := too_long, detail := 3}} = openapi_schema:process(<<"123">>, #{schema => #{type => <<"string">>, maxLength => 2}}), + + {error, #{error := too_short, detail := 4}} = + openapi_schema:process(atom, #{schema => #{type => <<"string">>, minLength => 5}}), + {error, #{error := too_long, detail := 4}} = + openapi_schema:process(atom, #{schema => #{type => <<"string">>, maxLength => 2}}), + + UTFString = unicode:characters_to_binary([128578,128579]), % 2 code points, but 8 bytes + {error, #{error := too_short, detail := 2}} = + openapi_schema:process(UTFString, #{schema => #{type => <<"string">>, minLength => 3}}), + {error, #{error := too_long, detail := 2}} = + openapi_schema:process(UTFString, #{schema => #{type => <<"string">>, maxLength => 1}}), ok. @@ -394,6 +449,33 @@ one_of_const_default(_) -> #{schema => FooType, apply_defaults => true}), ok. +one_of_const_wrong_type(_) -> + FooProp = #{k1 => #{oneOf => [#{const => <<"hello">>}, #{const => <<"world">>}]}}, + FooType = #{type => <<"object">>, properties => FooProp}, + {error, #{error := not_const}} = openapi_schema:process(#{k1 => #{}}, #{schema => FooType}), + {error, #{error := not_const}} = openapi_schema:process(#{k1 => []}, #{schema => FooType}), + ok. + +drop_unidirectional_keys(_) -> + Schema = #{ + type => <<"object">>, + properties => #{ + pn => #{type => <<"integer">>}, + pr => #{type => <<"integer">>, readOnly => true}, + pw => #{type => <<"integer">>, writeOnly => true} + } + }, + % 'raw' access does not involve any read/write filtering logic + ObjRaw = openapi_schema:process(#{pn => 1, pr => 2, pw => 3}, #{schema => Schema, extra_obj_key => drop, access_type => raw}), + [pn, pr, pw] = lists:sort(maps:keys(ObjRaw)), + % 'write' access drops readOnly properties + ObjW = openapi_schema:process(#{pn => 1, pr => 2, pw => 3}, #{schema => Schema, extra_obj_key => drop, access_type => write}), + [pn, pw] = lists:sort(maps:keys(ObjW)), + % 'read' access drops writeOnly properties + ObjR = openapi_schema:process(#{pn => 1, pr => 2, pw => 3}, #{schema => Schema, extra_obj_key => drop, access_type => read}), + [pn, pr] = lists:sort(maps:keys(ObjR)), + ok. + filter_read_only_props(_) -> Schema = persistent_term:get({openapi_handler_schema,test_openapi}), Json = #{