diff --git a/rebar.config b/rebar.config index 4eb8ef8..de1a8e0 100644 --- a/rebar.config +++ b/rebar.config @@ -5,12 +5,14 @@ {deps, [ {elli, {git, "git@github.com:elli-lib/elli.git", {branch, "main"}}}, - {ndto, {git, "git@github.com:nomasystems/ndto.git", {branch, "main"}}}, + {ndto, + {git, "git@github.com:nomasystems/ndto.git", + {branch, "refactor/split-oas-3-0-json-schema-draft-04-parsers"}}}, {njson, {git, "git@github.com:nomasystems/njson.git", {branch, "main"}}} ]}. {plugins, [ - {rebar3_ndto, {git, "git@github.com:nomasystems/rebar3_ndto.git", {branch, "main"}}} + {rebar3_ndto, {git, "git@github.com:nomasystems/rebar3_ndto.git", {tag, "0.1.0"}}} ]}. {ndto, [ {specs, [ @@ -27,6 +29,9 @@ {project_plugins, [ {erlfmt, {git, "git@github.com:WhatsApp/erlfmt.git", {branch, "main"}}}, + {eqwalizer_rebar3, + {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, + "eqwalizer_rebar3"}}, {gradualizer, {git, "git@github.com:josefs/Gradualizer.git", {branch, "master"}}}, rebar3_ex_doc ]}. @@ -42,6 +47,9 @@ {test, [ {erl_opts, [nowarn_export_all]}, {deps, [ + {eqwalizer_support, + {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, + "eqwalizer_support"}}, {meck, {git, "git@github.com:eproxus/meck.git", {branch, "master"}}}, {nct_util, {git, "git@github.com:nomasystems/nct_util.git", {branch, "main"}}} ]} diff --git a/rebar.lock b/rebar.lock index 5725a88..40007d0 100644 --- a/rebar.lock +++ b/rebar.lock @@ -8,9 +8,9 @@ 1}, {<<"ndto">>, {git,"git@github.com:nomasystems/ndto.git", - {ref,"491a2441e43afa2fb037c6e7e826c45a383e3bd9"}}, + {ref,"41043b1c89dccf1485a12b04467949a1c9d161f5"}}, 0}, {<<"njson">>, {git,"git@github.com:nomasystems/njson.git", - {ref,"76ab40033ee977f876e7b3addca5de981ff4a9ef"}}, + {ref,"b230b3e6fb5e35320aeaa203762f3f12277c9970"}}, 0}]. diff --git a/src/erf.erl b/src/erf.erl index b6d0a95..9ed1795 100644 --- a/src/erf.erl +++ b/src/erf.erl @@ -41,7 +41,7 @@ %%% TYPES -type api() :: erf_parser:api(). --type body() :: njson:t(). +-type body() :: undefined | njson:t(). -type conf() :: #{ spec_path := binary(), callback := module(), diff --git a/src/erf_oas_3_0.erl b/src/erf_oas_3_0.erl index 9c5453d..ff3777b 100644 --- a/src/erf_oas_3_0.erl +++ b/src/erf_oas_3_0.erl @@ -23,17 +23,15 @@ parse/1 ]). -%%% RECORDS --record(ctx, { - base_path :: binary(), - namespace :: binary(), - resolved :: [erf_parser:ref()], - spec :: oas() -}). - %%% TYPES --type ctx() :: #ctx{}. --type oas() :: njson:t(). +-type ctx() :: #{ + base_path := binary(), + base_name := binary(), + namespace := binary(), + resolved := [binary()], + spec := spec() +}. +-type spec() :: njson:t(). %%% MACROS -define(METHODS, [ @@ -57,24 +55,22 @@ Reason :: term(). %% @doc Parses an OpenAPI Specification 3.0 file into an API AST. parse(SpecPath) -> - case read_spec(SpecPath) of - {ok, BinSpec} -> - case deserialize_spec(BinSpec) of - {ok, OAS} -> - case oas_3_0:is_valid(OAS) of - true -> - CTX = #ctx{ - base_path = SpecPath, - namespace = filename:rootname(filename:basename(SpecPath)), - resolved = [], - spec = OAS - }, - {ok, parse_api(OAS, CTX)}; - false -> - {error, {invalid_spec, <<"Invalid OpenAPI Specification 3.0">>}} - end; - {error, Reason} -> - {error, {invalid_spec, Reason}} + case parse_spec(SpecPath) of + {ok, OAS} -> + case oas_3_0:is_valid(OAS) of + true -> + BasePath = filename:dirname(SpecPath), + BaseName = filename:rootname(filename:basename(SpecPath)), + CTX = #{ + base_path => BasePath, + base_name => BaseName, + namespace => BaseName, + resolved => [], + spec => OAS + }, + {ok, parse_api(OAS, CTX)}; + false -> + {error, {invalid_spec, <<"Invalid OpenAPI Specification 3.0">>}} end; {error, Reason} -> {error, {invalid_spec, Reason}} @@ -83,18 +79,24 @@ parse(SpecPath) -> %%%----------------------------------------------------------------------------- %%% INTERNAL FUNCTIONS %%%----------------------------------------------------------------------------- --spec deserialize_spec(Bin) -> Result when - Bin :: binary(), - Result :: {ok, map()} | {error, Reason}, - Reason :: term(). -deserialize_spec(Bin) -> - try - Data = njson:decode(Bin), - {ok, Data} - catch - _Error:Reason -> - {error, {invalid_json, Reason}} - end. +-spec clean_spec(RawSchema) -> Schema when + RawSchema :: ndto:spec(), + Schema :: ndto:spec(). +clean_spec(RawSpec) when is_map(RawSpec) -> + maps:fold( + fun + (_Key, undefined, Acc) -> + Acc; + (Key, List, Acc) when is_list(List) -> + maps:put(Key, [clean_spec(Value) || Value <- List, Value =/= undefined], Acc); + (Key, Value, Acc) -> + maps:put(Key, clean_spec(Value), Acc) + end, + #{}, + RawSpec + ); +clean_spec(Schema) -> + Schema. -spec get(Keys, Spec) -> Result when Keys :: [binary()], @@ -106,7 +108,7 @@ get([Key | Keys], Spec) -> get(Keys, maps:get(Key, Spec)). -spec parse_api(OAS, CTX) -> Result when - OAS :: oas(), + OAS :: spec(), CTX :: ctx(), Result :: erf:api(). parse_api(OAS, CTX) -> @@ -115,30 +117,33 @@ parse_api(OAS, CTX) -> {RawEndpoints, RawSchemas, _NewCTX} = lists:foldl( fun({Path, RawEndpoint}, {EndpointsAcc, SchemasAcc, CTXAcc}) -> {Endpoint, EndpointSchemas, NewCTX} = parse_endpoint(Path, RawEndpoint, CTXAcc), - {[Endpoint | EndpointsAcc], SchemasAcc ++ EndpointSchemas, NewCTX} + { + [Endpoint | EndpointsAcc], + SchemasAcc ++ EndpointSchemas, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } end, {[], [], CTX}, maps:to_list(maps:get(<<"paths">>, OAS)) ), Endpoints = lists:reverse(RawEndpoints), Schemas = maps:from_list(RawSchemas), - #{ + clean_spec(#{ name => Name, version => Version, endpoints => Endpoints, schemas => Schemas - }. + }). -spec parse_endpoint(Path, RawEndpoint, CTX) -> Result when Path :: binary(), - RawEndpoint :: oas(), + RawEndpoint :: spec(), CTX :: ctx(), Result :: {Endpoint, Schemas, NewCTX}, Endpoint :: erf_parser:endpoint(), Schemas :: [{erf_parser:ref(), erf_parser:schema()}], NewCTX :: ctx(). -parse_endpoint(Path, RawEndpoint, #ctx{namespace = Namespace} = CTX) -> - EndpointNamespace = erf_util:to_snake_case(<>), +parse_endpoint(Path, RawEndpoint, CTX) -> {Parameters, ParametersSchemas, ParametersCTX} = lists:foldl( fun(RawParameter, {ParametersAcc, ParametersExtraSchemasAcc, ParametersCTXAcc}) -> @@ -148,10 +153,10 @@ parse_endpoint(Path, RawEndpoint, #ctx{namespace = Namespace} = CTX) -> { [Parameter | ParametersAcc], ParameterExtraSchemas ++ ParametersExtraSchemasAcc, - ParameterCTX + ParametersCTXAcc#{resolved => maps:get(resolved, ParameterCTX)} } end, - {[], [], CTX#ctx{namespace = EndpointNamespace}}, + {[], [], CTX}, maps:get(<<"parameters">>, RawEndpoint, []) ), @@ -176,9 +181,13 @@ parse_endpoint(Path, RawEndpoint, #ctx{namespace = Namespace} = CTX) -> {Operation, Schemas, OperationCTX} = parse_operation( Path, Method, RawOperation, OperationsCTXAcc ), - {[Operation | OperationsAcc], Schemas ++ OperationsExtraSchemasAcc, OperationCTX} + { + [Operation | OperationsAcc], + Schemas ++ OperationsExtraSchemasAcc, + OperationsCTXAcc#{resolved => maps:get(resolved, OperationCTX)} + } end, - {[], [], ParametersCTX#ctx{namespace = Namespace}}, + {[], [], ParametersCTX}, RawOperations ), @@ -188,7 +197,7 @@ parse_endpoint(Path, RawEndpoint, #ctx{namespace = Namespace} = CTX) -> operations => Operations }, Schemas = ParametersSchemas ++ OperationsSchemas, - {Endpoint, Schemas, OperationsCTX#ctx{namespace = Namespace}}. + {Endpoint, Schemas, OperationsCTX}. -spec parse_method(Method) -> Result when Method :: binary(), @@ -213,7 +222,7 @@ parse_method(<<"connect">>) -> connect. -spec parse_name(Val) -> Result when - Val :: oas(), + Val :: spec(), Result :: binary(). parse_name(#{<<"info">> := #{<<"title">> := Name}}) -> Name. @@ -221,7 +230,7 @@ parse_name(#{<<"info">> := #{<<"title">> := Name}}) -> -spec parse_operation(Path, Method, RawOperation, CTX) -> Result when Path :: binary(), Method :: binary(), - RawOperation :: oas(), + RawOperation :: spec(), CTX :: ctx(), Result :: {Operation, Schemas, NewCTX}, Operation :: erf_parser:operation(), @@ -231,7 +240,7 @@ parse_operation( Path, RawMethod, #{<<"responses">> := RawResponses} = RawOperation, - #ctx{namespace = Namespace} = CTX + #{namespace := Namespace} = CTX ) -> OperationId = case maps:get(<<"operationId">>, RawOperation, undefined) of @@ -240,7 +249,7 @@ parse_operation( RawOperationId -> erf_util:to_snake_case(RawOperationId) end, - NewCTX = CTX#ctx{namespace = <>}, + NewCTX = CTX#{namespace => <>}, Method = parse_method(RawMethod), {Parameters, ParametersSchemas, ParametersCTX} = @@ -252,7 +261,7 @@ parse_operation( { [Parameter | ParametersAcc], ParameterExtraSchemas ++ ParametersExtraSchemasAcc, - ParameterCTX + ParametersCTXAcc#{resolved => maps:get(resolved, ParameterCTX)} } end, {[], [], NewCTX}, @@ -263,7 +272,10 @@ parse_operation( {ParsedRequestBody, RawRequestBodySchemas, RequestBodyCTX} = parse_request_body( RawRequestBody, ParametersCTX ), - RequestBodyRef = erf_util:to_snake_case(<<(NewCTX#ctx.namespace)/binary, "_request_body">>), + RequestBodyRef = + erf_util:to_snake_case(<< + (maps:get(namespace, NewCTX))/binary, "_request_body" + >>), RequestBodyRequired = maps:get(required, ParsedRequestBody), RequestBodySchema = maps:get(schema, ParsedRequestBody), RequestBodySchemas = [{RequestBodyRef, RequestBodySchema} | RawRequestBodySchemas], @@ -288,9 +300,12 @@ parse_operation( {ParsedResponse, RawResponseExtraSchemas, ResponseCTX} = parse_response( RawResponse, ResponsesCTXAcc ), - ResponseRef = erf_util:to_snake_case(<< - (NewCTX#ctx.namespace)/binary, "_response_body_", RawStatusCode/binary - >>), + ResponseRef = + erf_util:to_snake_case(<< + (maps:get(namespace, NewCTX))/binary, + "_response_body_", + RawStatusCode/binary + >>), ResponseRequired = maps:get(required, ParsedResponse), ResponseSchema = maps:get(schema, ParsedResponse), ResponseExtraSchemas = [{ResponseRef, ResponseSchema} | RawResponseExtraSchemas], @@ -301,7 +316,7 @@ parse_operation( { ResponsesAcc#{StatusCode => Response}, ResponseExtraSchemas ++ ResponsesExtraSchemasAcc, - ResponseCTX + ResponsesCTXAcc#{resolved => maps:get(resolved, ResponseCTX)} } end, {#{}, [], RequestBodyCTX}, @@ -319,7 +334,7 @@ parse_operation( {Operation, Schemas, ResponsesCTX}. -spec parse_parameter(OAS, CTX) -> Result when - OAS :: oas(), + OAS :: spec(), CTX :: ctx(), Result :: {Parameter, ExtraSchemas, NewCTX}, Parameter :: erf_parser:parameter(), @@ -328,7 +343,7 @@ parse_operation( parse_parameter(#{<<"$ref">> := Ref}, CTX) -> {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), parse_parameter(RefOAS, RefCTX); -parse_parameter(#{<<"content">> := Content} = RawParameter, #ctx{namespace = Namespace} = CTX) -> +parse_parameter(#{<<"content">> := Content} = RawParameter, #{namespace := Namespace} = CTX) -> ParameterType = case maps:get(<<"in">>, RawParameter) of <<"query">> -> @@ -359,15 +374,19 @@ parse_parameter(#{<<"content">> := Content} = RawParameter, #ctx{namespace = Nam {AnyOf, ExtraSchemas, NewCTX} = lists:foldl( fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} + {Schema, NewExtraSchemas, NewCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + NewExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } end, {[], [], CTX}, maps:to_list(Content) ), - ParameterSchema = #{<<"anyOf">> => AnyOf}, + ParameterSchema = #{any_of => AnyOf}, {Parameter, [{ParameterRef, ParameterSchema} | ExtraSchemas], NewCTX}; -parse_parameter(#{<<"schema">> := RawSchema} = RawParameter, #ctx{namespace = Namespace} = CTX) -> +parse_parameter(#{<<"schema">> := RawSchema} = RawParameter, #{namespace := Namespace} = CTX) -> ParameterType = case maps:get(<<"in">>, RawParameter) of <<"query">> -> @@ -395,11 +414,11 @@ parse_parameter(#{<<"schema">> := RawSchema} = RawParameter, #ctx{namespace = Na type => ParameterType, required => Required }, - {ParameterSchema, NewExtraSchemas, NewCTX} = parse_schema(RawSchema, CTX), + {ParameterSchema, NewExtraSchemas, NewCTX} = parse_schemas(RawSchema, CTX), {Parameter, [{ParameterRef, ParameterSchema} | NewExtraSchemas], NewCTX}. -spec parse_request_body(OAS, CTX) -> Result when - OAS :: oas(), + OAS :: spec(), CTX :: ctx(), Result :: {RequestBody, ExtraSchemas, NewCTX}, RequestBody :: #{schema := erf_parser:schema(), required := boolean()}, @@ -412,13 +431,19 @@ parse_request_body(#{<<"content">> := Content} = ReqBody, CTX) -> Required = maps:get(<<"required">>, ReqBody, false), {AnyOf, ExtraSchemas, NewCTX} = lists:foldl( fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, NewExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], NewExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} + {Schema, NewExtraSchemas, SchemaCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + NewExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{ + resolved => maps:get(resolved, SchemaCTX) + } + } end, {[], [], CTX}, maps:to_list(Content) ), - RequestBodySchema = #{<<"anyOf">> => AnyOf}, + RequestBodySchema = #{any_of => AnyOf}, RequestBody = #{ schema => RequestBodySchema, required => Required @@ -432,7 +457,7 @@ parse_request_body(_ReqBody, CTX) -> {RequestBody, [], CTX}. -spec parse_response(OAS, CTX) -> Result when - OAS :: oas(), + OAS :: spec(), CTX :: ctx(), Result :: {Response, ExtraSchemas, NewCTX}, Response :: #{schema := erf_parser:schema(), required := boolean()}, @@ -442,15 +467,22 @@ parse_response(#{<<"$ref">> := Ref}, CTX) -> {_RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), parse_response(RefOAS, RefCTX); parse_response(#{<<"content">> := Content}, CTX) -> - {AnyOf, ExtraSchemas, NewCTX} = lists:foldl( - fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} - end, - {[], [], CTX}, - maps:to_list(Content) - ), - ResponseSchema = #{<<"anyOf">> => AnyOf}, + {AnyOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun({_MediaType, #{<<"schema">> := RawSchema}}, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Schema, ExtraSchemas, SchemaCTX} = parse_schemas(RawSchema, CTXAcc), + { + [Schema | AnyOfAcc], + ExtraSchemas ++ ExtraSchemasAcc, + CTXAcc#{ + resolved => maps:get(resolved, SchemaCTX) + } + } + end, + {[], [], CTX}, + maps:to_list(Content) + ), + ResponseSchema = #{any_of => AnyOf}, Response = #{ schema => ResponseSchema, required => false @@ -463,131 +495,340 @@ parse_response(_Response, CTX) -> }, {Response, [], CTX}. --spec parse_schema(OAS, CTX) -> Result when - OAS :: oas(), +-spec parse_spec(SpecPath) -> Result when + SpecPath :: binary(), + Result :: {ok, Spec} | {error, Reason}, + Spec :: spec(), + Reason :: term(). +parse_spec(SpecPath) -> + case file:read_file(SpecPath) of + {ok, BinSpec} -> + case filename:extension(SpecPath) of + <<".json">> -> + case njson:decode(BinSpec) of + {ok, undefined} -> + {error, {invalid_spec, BinSpec}}; + {ok, Spec} -> + {ok, Spec}; + {error, Reason} -> + {error, {invalid_json, Reason}} + end; + Extension -> + {error, {unsupported_extension, Extension}} + end; + {error, Reason} -> + {error, {invalid_spec, Reason}} + end. + +-spec parse_version(OAS) -> Version when + OAS :: spec(), + Version :: binary(). +parse_version(#{<<"info">> := #{<<"version">> := Version}}) -> + Version. + +-spec parse_schemas(Spec, CTX) -> Result when + Spec :: spec(), CTX :: ctx(), Result :: {Schema, ExtraSchemas, NewCTX}, - Schema :: erf_parser:schema(), - ExtraSchemas :: [{erf_parser:ref(), erf_parser:schema()}], + Schema :: ndto:schema(), + ExtraSchemas :: [{ndto:name(), ndto:schema()}], NewCTX :: ctx(). -%% TODO: parse to new ndto:schema() type -parse_schema(#{<<"$ref">> := Ref}, CTX) -> - {RefResolved, RefOAS, RefCTX} = resolve_ref(Ref, CTX), - {NewSchema, NewExtraSchemas, NewCTX} = parse_schema(RefOAS, RefCTX), - {#{<<"$ref">> => RefResolved}, [{RefResolved, NewSchema} | NewExtraSchemas], NewCTX}; -parse_schema(#{<<"items">> := RawItems} = Schema, CTX) -> - {Items, NewExtraSchemas, NewCTX} = parse_schema(RawItems, CTX), - {Schema#{<<"items">> => Items}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"properties">> := RawProperties} = Schema, CTX) -> - {Properties, PropertiesExtraSchemas, PropertiesCTX} = lists:foldl( - fun({Name, RawProperty}, {PropertiesAcc, ExtraSchemasAcc, CTXAcc}) -> - {Property, ExtraSchemas, PropertyCTX} = parse_schema(RawProperty, CTXAcc), - {PropertiesAcc#{Name => Property}, ExtraSchemas ++ ExtraSchemasAcc, PropertyCTX} - end, - {#{}, [], CTX}, - maps:to_list(RawProperties) - ), - PropertiesSchema = Schema#{<<"properties">> => Properties}, - RawAdditionalProperties = maps:get(<<"additionalProperties">>, Schema, undefined), - {AdditionalProperties, AdditionalPropertiesExtraSchemas, AdditionalPropertiesCTX} = parse_schema( - RawAdditionalProperties, PropertiesCTX - ), - AdditionalPropertiesSchema = PropertiesSchema#{ - <<"additionalProperties">> => AdditionalProperties +%% @doc Parses an OpenAPI 3.0 Schema specification file into a list of ndto:schema() values. +parse_schemas(false, CTX) -> + Schema = false, + {Schema, [], CTX}; +parse_schemas(true, CTX) -> + Schema = #{}, + {Schema, [], CTX}; +parse_schemas(#{<<"$ref">> := Ref} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {RefName, RefSchema, RefCTX} = resolve_ref(Ref, CTX), + Schema = #{ + ref => RefName, + nullable => Nullable + }, + case lists:member(RefName, maps:get(resolved, CTX)) of + true -> + {Schema, [], CTX}; + false -> + {NewSchema, NewExtraSchemas, NewCTX} = parse_schemas(RefSchema, RefCTX), + {Schema, [{RefName, NewSchema} | NewExtraSchemas], NewCTX} + end; +parse_schemas(#{<<"enum">> := Enum} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Schema = #{ + enum => Enum, + nullable => Nullable }, - {AdditionalPropertiesSchema, PropertiesExtraSchemas ++ AdditionalPropertiesExtraSchemas, - AdditionalPropertiesCTX}; -parse_schema(#{<<"allOf">> := RawAllOf}, CTX) -> - {AllOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {AllOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AllOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"boolean">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Schema = #{ + type => boolean, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"integer">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Minimum = maps:get(<<"minimum">>, RawSchema, undefined), + ExclusiveMinimum = maps:get(<<"exclusiveMinimum">>, RawSchema, undefined), + Maximum = maps:get(<<"maximum">>, RawSchema, undefined), + ExclusiveMaximum = maps:get(<<"exclusiveMaximum">>, RawSchema, undefined), + MultipleOf = maps:get(<<"multipleOf">>, RawSchema, undefined), + Schema = + #{ + type => integer, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum, + multiple_of => MultipleOf, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"number">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + Minimum = maps:get(<<"minimum">>, RawSchema, undefined), + ExclusiveMinimum = maps:get(<<"exclusiveMinimum">>, RawSchema, undefined), + Maximum = maps:get(<<"maximum">>, RawSchema, undefined), + ExclusiveMaximum = maps:get(<<"exclusiveMaximum">>, RawSchema, undefined), + MultipleOf = maps:get(<<"multipleOf">>, RawSchema, undefined), + Schema = + #{ + any_of => [ + #{ + type => integer, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum, + multiple_of => MultipleOf + }, + #{ + type => float, + minimum => Minimum, + exclusive_minimum => ExclusiveMinimum, + maximum => Maximum, + exclusive_maximum => ExclusiveMaximum + } + ], + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"string">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + MinLength = maps:get(<<"minLength">>, RawSchema, undefined), + MaxLength = maps:get(<<"maxLength">>, RawSchema, undefined), + Format = + case maps:get(<<"format">>, RawSchema, undefined) of + <<"iso8601">> -> + iso8601; + <<"byte">> -> + base64; + _Otherwise -> + undefined end, - {[], [], CTX}, - RawAllOf - ), - {#{<<"allOf">> => AllOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"oneOf">> := RawOneOf}, CTX) -> - {OneOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {OneOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | OneOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} + Pattern = maps:get(<<"pattern">>, RawSchema, undefined), + Schema = + #{ + type => string, + min_length => MinLength, + max_length => MaxLength, + format => Format, + pattern => Pattern, + nullable => Nullable + }, + {Schema, [], CTX}; +parse_schemas(#{<<"type">> := <<"array">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Items, ItemsExtraSchemas, ItemsCTX} = + case maps:get(<<"items">>, RawSchema, undefined) of + undefined -> + {undefined, [], CTX}; + RawItems -> + parse_schemas(RawItems, CTX) end, - {[], [], CTX}, - RawOneOf - ), - {#{<<"oneOf">> => OneOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"anyOf">> := RawAnyOf}, CTX) -> - {AnyOf, NewExtraSchemas, NewCTX} = lists:foldl( - fun(RawSchema, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> - {Schema, ExtraSchemas, SchemaCTX} = parse_schema(RawSchema, CTXAcc), - {[Schema | AnyOfAcc], ExtraSchemas ++ ExtraSchemasAcc, SchemaCTX} + MinItems = maps:get(<<"minItems">>, RawSchema, undefined), + MaxItems = maps:get(<<"maxItems">>, RawSchema, undefined), + UniqueItems = maps:get(<<"uniqueItems">>, RawSchema, undefined), + Schema = + #{ + type => array, + items => Items, + min_items => MinItems, + max_items => MaxItems, + unique_items => UniqueItems, + nullable => Nullable + }, + {Schema, ItemsExtraSchemas, ItemsCTX}; +parse_schemas(#{<<"type">> := <<"object">>} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Properties, PropertiesExtraSchemas, PropertiesCTX} = + case maps:get(<<"properties">>, RawSchema, undefined) of + undefined -> + {undefined, [], CTX}; + RawProperties -> + lists:foldl( + fun({Property, RawPropertySchema}, {PropertiesAcc, ExtraSchemasAcc, CTXAcc}) -> + {PropertySchema, ExtraSchemas, NewCTX} = parse_schemas( + RawPropertySchema, CTXAcc + ), + { + PropertiesAcc#{Property => PropertySchema}, + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{resolved => maps:get(resolved, NewCTX)} + } + end, + {#{}, [], CTX}, + maps:to_list(RawProperties) + ) end, - {[], [], CTX}, - RawAnyOf - ), - {#{<<"anyOf">> => AnyOf}, NewExtraSchemas, NewCTX}; -parse_schema(#{<<"not">> := RawNot}, CTX) -> - {Not, NewExtraSchemas, NewCTX} = parse_schema(RawNot, CTX), - {#{<<"not">> => Not}, NewExtraSchemas, NewCTX}; -parse_schema(Schema, CTX) -> + Required = maps:get(<<"required">>, RawSchema, undefined), + MinProperties = maps:get(<<"minProperties">>, RawSchema, undefined), + MaxProperties = maps:get(<<"maxProperties">>, RawSchema, undefined), + {AdditionalProperties, AdditionalPropertiesExtraSchemas, AdditionalPropertiesCTX} = + case maps:get(<<"additionalProperties">>, RawSchema, undefined) of + undefined -> + {undefined, [], PropertiesCTX}; + RawAdditionalProperties -> + parse_schemas(RawAdditionalProperties, PropertiesCTX) + end, + Schema = + #{ + type => object, + properties => Properties, + required => Required, + min_properties => MinProperties, + max_properties => MaxProperties, + additional_properties => AdditionalProperties, + nullable => Nullable + }, + ExtraSchemas = PropertiesExtraSchemas ++ AdditionalPropertiesExtraSchemas, + {Schema, ExtraSchemas, AdditionalPropertiesCTX}; +parse_schemas(#{<<"anyOf">> := RawAnyOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {AnyOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {AnyOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | AnyOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawAnyOf + ), + Schema = #{ + any_of => AnyOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"allOf">> := RawAllOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {AllOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {AllOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | AllOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawAllOf + ), + Schema = #{ + all_of => AllOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"not">> := RawNot} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {Not, ExtraSchemas, NewCTX} = parse_schemas(RawNot, CTX), + Schema = #{ + 'not' => Not, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(#{<<"oneOf">> := RawOneOf} = RawSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawSchema, undefined), + {OneOf, ExtraSchemas, NewCTX} = + lists:foldl( + fun(RawSubschema, {OneOfAcc, ExtraSchemasAcc, CTXAcc}) -> + {Subschema, ExtraSchemas, NewCTX} = parse_schemas(RawSubschema, CTXAcc), + { + [Subschema | OneOfAcc], + ExtraSchemasAcc ++ ExtraSchemas, + CTXAcc#{ + resolved => maps:get(resolved, NewCTX) + } + } + end, + {[], [], CTX}, + RawOneOf + ), + Schema = #{ + one_of => OneOf, + nullable => Nullable + }, + {Schema, ExtraSchemas, NewCTX}; +parse_schemas(RawUniversalSchema, CTX) -> + Nullable = maps:get(<<"nullable">>, RawUniversalSchema, undefined), + Schema = #{ + nullable => Nullable + }, {Schema, [], CTX}. --spec parse_version(OAS) -> Version when - OAS :: oas(), - Version :: binary(). -parse_version(#{<<"info">> := #{<<"version">> := Version}}) -> - Version. - --spec read_spec(SpecPath) -> Result when - SpecPath :: binary(), - Result :: {ok, BinSpec} | {error, Reason}, - BinSpec :: binary(), - Reason :: term(). -read_spec(SpecPath) -> - case file:read_file(SpecPath) of - {ok, BinSpec} -> - {ok, BinSpec}; - {error, Reason} -> - {error, {invalid_spec, Reason}} - end. - -spec resolve_ref(Ref, CTX) -> Result when Ref :: binary(), CTX :: ctx(), - Result :: {NewResolved, NewOAS, NewCTX}, + Result :: {NewResolved, NewSchema, NewCTX}, NewResolved :: binary(), - NewOAS :: oas(), + NewSchema :: ndto:schema(), NewCTX :: ctx(). -resolve_ref(Ref, #ctx{base_path = BasePath, resolved = Resolved, spec = Spec}) -> +resolve_ref(Ref, CTX) -> + BasePath = maps:get(base_path, CTX), + BaseName = maps:get(base_name, CTX), + Resolved = maps:get(resolved, CTX), + Spec = maps:get(spec, CTX), + [FilePath, ElementPath] = binary:split(Ref, <<"#">>, [global]), - [<<>> | LocalPath] = binary:split(ElementPath, <<"/">>, [global]), - {NewSpec, NewBasePath, NewNamespace} = + LocalPath = binary:split(ElementPath, <<"/">>, [global, trim_all]), + {NewSpec, NewBasePath, NewBaseName} = case FilePath of <<>> -> - ResetNamespace = filename:rootname(filename:basename(BasePath)), - {Spec, BasePath, ResetNamespace}; + {Spec, BasePath, BaseName}; _FilePath -> - RefBasePath = filename:join(filename:dirname(BasePath), FilePath), - case read_spec(RefBasePath) of - {ok, Bin} -> - case deserialize_spec(Bin) of - {ok, RefSpec} -> - RefNamespace = filename:rootname(filename:basename(FilePath)), - {RefSpec, RefBasePath, RefNamespace}; - {error, Reason} -> - erlang:error({invalid_ref, Reason}) - end; + AbsPath = filename:join(BasePath, FilePath), + case parse_spec(AbsPath) of + {ok, RefSpec} -> + RefBasePath = filename:dirname(AbsPath), + RefBaseName = filename:rootname(filename:basename(AbsPath)), + {RefSpec, RefBasePath, RefBaseName}; {error, Reason} -> erlang:error({invalid_ref, Reason}) end end, - NewResolved = <>, - NewOAS = get(LocalPath, NewSpec), - NewCTX = #ctx{ - base_path = NewBasePath, - namespace = NewNamespace, - resolved = [NewResolved | Resolved], - spec = NewSpec + NewResolved = + case LocalPath of + [] -> + NewBaseName; + _LocalPath -> + erf_util:to_snake_case(<>) + end, + NewSchema = get(LocalPath, NewSpec), + NewCTX = #{ + base_path => NewBasePath, + base_name => NewBaseName, + namespace => NewBaseName, + resolved => [NewResolved | Resolved], + spec => NewSpec }, - {NewResolved, NewOAS, NewCTX}. + {NewResolved, NewSchema, NewCTX}. diff --git a/src/erf_router.erl b/src/erf_router.erl index 389dbc6..3d7ae95 100644 --- a/src/erf_router.erl +++ b/src/erf_router.erl @@ -126,18 +126,23 @@ handle(ElliRequest, [Name]) -> {ok, PreProcessMiddlewares} = erf_conf:preprocess_middlewares(Name), {ok, RouterMod} = erf_conf:router_mod(Name), {ok, PostProcessMiddlewares} = erf_conf:postprocess_middlewares(Name), - Request = preprocess(ElliRequest), - {InitialResponse, InitialRequest} = - case apply_preprocess_middlewares(Request, PreProcessMiddlewares) of - {stop, PreprocessResponse, PreprocessRequest} -> - {PreprocessResponse, PreprocessRequest}; - PreprocessRequest -> - {RouterMod:handle(PreprocessRequest), PreprocessRequest} - end, - Response = apply_postprocess_middlewares( - InitialRequest, InitialResponse, PostProcessMiddlewares - ), - postprocess(InitialRequest, Response). + case preprocess(ElliRequest) of + {ok, Request} -> + {InitialResponse, InitialRequest} = + case apply_preprocess_middlewares(Request, PreProcessMiddlewares) of + {stop, PreprocessResponse, PreprocessRequest} -> + {PreprocessResponse, PreprocessRequest}; + PreprocessRequest -> + {RouterMod:handle(PreprocessRequest), PreprocessRequest} + end, + Response = apply_postprocess_middlewares( + InitialRequest, InitialResponse, PostProcessMiddlewares + ), + postprocess(InitialRequest, Response); + {error, _Reason} -> + % TODO: handle error + {500, [{<<"content-type">>, <<"text/plain">>}], <<"Internal Server Error">>} + end. -spec handle_event(Event, Data, CallbackArgs) -> ok when Event :: atom(), @@ -744,43 +749,74 @@ postprocess( <<>>, erlang:self(), undefined, {undefined, []}} ), {Status, Headers, {file, File, Range}}; -postprocess(_Request, {Status, RawHeaders, RawBody}) -> +postprocess(_Request, {Status, Headers, Body}) -> ContentTypeHeader = string:casefold(<<"content-type">>), - {Headers, Body} = - case proplists:get_value(ContentTypeHeader, RawHeaders, undefined) of - undefined -> - { - [{ContentTypeHeader, <<"application/json">>} | RawHeaders], - njson:encode(RawBody) - }; - _Otherwise -> - case RawBody of - undefined -> - {RawHeaders, <<>>}; - _RawBody -> - {RawHeaders, RawBody} - end - end, - {Status, Headers, Body}. + case proplists:get_value(ContentTypeHeader, Headers, undefined) of + JSON when (JSON =:= undefined) orelse (JSON =:= <<"application/json">>) -> + case njson:encode(Body) of + {ok, EncodedBody} -> + RawHeaders = proplists:delete(ContentTypeHeader, Headers), + { + Status, + [{ContentTypeHeader, <<"application/json">>} | RawHeaders], + EncodedBody + }; + {error, _Reason} -> + % TODO: handle error + { + 500, + [{ContentTypeHeader, <<"text/plain">>}], + <<"Internal Server Error">> + } + end; + _Otherwise -> + case Body of + undefined -> + {Status, Headers, <<>>}; + _RawBody -> + {Status, Headers, Body} + end + end. --spec preprocess(Req) -> Request when +-spec preprocess(Req) -> Result when Req :: elli:req(), - Request :: erf:request(). + Result :: {ok, Request} | {error, Reason}, + Request :: erf:request(), + Reason :: term(). preprocess(Req) -> Path = elli_request:path(Req), Method = preprocess_method(elli_request:method(Req)), QueryParameters = elli_request:get_args_decoded(Req), Headers = elli_request:headers(Req), - Body = njson:decode(elli_request:body(Req)), Peer = elli_request:peer(Req), - #{ - path => Path, - method => Method, - query_parameters => QueryParameters, - headers => Headers, - body => Body, - peer => Peer - }. + ContentTypeHeader = string:casefold(<<"content-type">>), + RawBody = elli_request:body(Req), + + case proplists:get_value(ContentTypeHeader, Headers, undefined) of + <<"application/json">> -> + case njson:decode(RawBody) of + {ok, Body} -> + {ok, #{ + path => Path, + method => Method, + query_parameters => QueryParameters, + headers => Headers, + body => Body, + peer => Peer + }}; + {error, Reason} -> + {error, {cannot_decode_body, Reason}} + end; + _ContentType -> + {ok, #{ + path => Path, + method => Method, + query_parameters => QueryParameters, + headers => Headers, + body => RawBody, + peer => Peer + }} + end. -spec preprocess_method(ElliMethod) -> Result when ElliMethod :: elli:http_method(), diff --git a/test/erf_oas_3_0_SUITE.erl b/test/erf_oas_3_0_SUITE.erl index 4570178..83524fa 100644 --- a/test/erf_oas_3_0_SUITE.erl +++ b/test/erf_oas_3_0_SUITE.erl @@ -78,31 +78,55 @@ petstore(_Conf) -> name := <<"Swagger Petstore">>, version := <<"1.0.0">>, schemas := #{ + <<"petstore_error">> := #{ + properties := + #{ + <<"code">> := #{type := integer}, + <<"message">> := #{type := string} + }, + required := [<<"code">>, <<"message">>], + type := object + }, + <<"petstore_pet">> := #{ + properties := + #{ + <<"id">> := #{type := integer}, + <<"name">> := #{type := string}, + <<"tag">> := #{type := string} + }, + required := [<<"id">>, <<"name">>], + type := object + }, + <<"petstore_pets">> := #{ + items := #{ref := <<"petstore_pet">>}, + max_items := 100, + type := array + }, <<"petstore_list_pets_limit">> := #{ - <<"type">> := <<"integer">>, - <<"maximum">> := 100 + type := integer, + maximum := 100 }, <<"petstore_list_pets_request_body">> := true, <<"petstore_list_pets_response_body_200">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Pets">>}] + any_of := [#{ref := <<"petstore_pets">>}] }, <<"petstore_list_pets_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] }, <<"petstore_create_pets_request_body">> := true, <<"petstore_create_pets_response_body_201">> := true, <<"petstore_create_pets_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] }, <<"petstore_show_pet_by_id_pet_id">> := #{ - <<"type">> := <<"string">> + type := string }, <<"petstore_show_pet_by_id_request_body">> := true, <<"petstore_show_pet_by_id_response_body_200">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Pet">>}] + any_of := [#{ref := <<"petstore_pet">>}] }, <<"petstore_show_pet_by_id_response_body_default">> := #{ - <<"anyOf">> := [#{<<"$ref">> := <<"petstore_Error">>}] + any_of := [#{ref := <<"petstore_error">>}] } }, endpoints := [ @@ -207,17 +231,17 @@ with_refs(_Conf) -> version := <<"1.0.0">>, schemas := #{ <<"common_oas_3_0_spec_enabled">> := #{ - <<"type">> := <<"boolean">> + type := boolean }, <<"common_oas_3_0_spec_version">> := #{ - <<"type">> := <<"string">>, - <<"pattern">> := <<"^[0-9]+$">> + type := string, + pattern := <<"^[0-9]+$">> }, <<"with_refs_oas_3_0_spec_delete_foo_request_body">> := true, <<"with_refs_oas_3_0_spec_delete_foo_response_body_204">> := true, <<"with_refs_oas_3_0_spec_delete_foo_response_body_404">> := #{ - <<"anyOf">> := [ - #{<<"$ref">> := <<"common_oas_3_0_spec_Error">>} + any_of := [ + #{ref := <<"common_oas_3_0_spec_error">>} ] } } diff --git a/test/erf_router_SUITE.erl b/test/erf_router_SUITE.erl index 83297e7..03867a1 100644 --- a/test/erf_router_SUITE.erl +++ b/test/erf_router_SUITE.erl @@ -71,33 +71,23 @@ foo(_Conf) -> version => <<"1.0.0">>, schemas => #{ <<"version_foo_version">> => #{ - <<"type">> => <<"integer">> + type => integer }, <<"get_foo_request_body">> => true, - <<"get_foo_response_body">> => #{ - <<"anyOf">> => [ - #{ - <<"anyOf">> => [ - #{ - <<"description">> => <<"A foo">>, - <<"enum">> => [<<"bar">>, <<"baz">>], - <<"type">> => <<"string">> - } - ] - }, + <<"get_foo_response_body_200">> => #{ + any_of => [#{enum => [<<"bar">>, <<"baz">>]}] + }, + <<"get_foo_response_body_default">> => #{ + any_of => [ #{ - <<"anyOf">> => [ - #{ - <<"properties">> => #{ - <<"description">> => #{ - <<"description">> => - <<"An English human-friendly description of the error.">>, - <<"type">> => <<"string">> - } - }, - <<"type">> => <<"object">> + type => object, + properties => #{ + description => #{ + description => + <<"An English human-friendly description of the error.">>, + type => string } - ] + } } ] } @@ -122,8 +112,13 @@ foo(_Conf) -> ref => <<"get_foo_request_body">>, required => false }, - response_body => #{ - ref => <<"get_foo_response_body">> + responses => #{ + 200 => #{ + ref => <<"get_foo_response_body_200">> + }, + '*' => #{ + ref => <<"get_foo_response_body_default">> + } } } ] diff --git a/test/fixtures/common_oas_3_0_spec.json b/test/fixtures/common_oas_3_0_spec.json index 9eabce2..7656b26 100644 --- a/test/fixtures/common_oas_3_0_spec.json +++ b/test/fixtures/common_oas_3_0_spec.json @@ -56,7 +56,7 @@ } }, "requestBodies": { - "Foo": { + "PostFoo": { "content": { "application/json": { "schema": { diff --git a/test/fixtures/with_refs_oas_3_0_spec.json b/test/fixtures/with_refs_oas_3_0_spec.json index 6598234..d75d010 100644 --- a/test/fixtures/with_refs_oas_3_0_spec.json +++ b/test/fixtures/with_refs_oas_3_0_spec.json @@ -38,7 +38,7 @@ "summary": "Create foo", "operationId": "createFoo", "requestBody": { - "$ref": "common_oas_3_0_spec.json#/components/requestBodies/Foo" + "$ref": "common_oas_3_0_spec.json#/components/requestBodies/PostFoo" }, "responses": { "201": {