-module(livery_openapi_validate). -moduledoc """ Request validation against a JSON-Schema subset, plus a middleware that rejects malformed request bodies with `422`. `validate/2` checks a decoded JSON term (maps with binary keys, lists, binaries, numbers, booleans, `null`) against a schema map. Supported keywords: - core: `type` (single or a list of types), `enum`, `const` - numbers: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` - strings: `minLength`, `maxLength`, `pattern` - objects: `required`, `properties`, `additionalProperties` (`false` or a schema), `minProperties`, `maxProperties` - arrays: `items`, `minItems`, `maxItems`, `uniqueItems` - combinators: `allOf`, `anyOf`, `oneOf` Schema keys may be atoms or binaries; property names inside `properties` must be binaries (they are matched against decoded JSON keys). It is a pragmatic subset, not a complete JSON Schema implementation (`$ref`, `if`/`then`/`else`, `patternProperties`, and `dependentSchemas` are not supported). The `call/3` middleware reads `#{body_schema => Schema}` from its state, decodes the request's JSON body, validates it, and on failure short-circuits with a `422` whose body lists the errors. On success it stores the decoded body under `meta(body, Decoded)`. """. -behaviour(livery_middleware). -include("livery.hrl"). -export([validate/2, call/3]). -export_type([schema/0, error/0]). -type schema() :: map(). -type error() :: {binary(), binary()}. %%==================================================================== %% Validation %%==================================================================== -doc "Validate a decoded JSON term against a schema subset.". -spec validate(term(), schema()) -> ok | {error, [error()]}. validate(Value, Schema) -> case check(Value, Schema, <<"$">>, []) of [] -> ok; Errs -> {error, lists:reverse(Errs)} end. check(Value, Schema, Path, Errs) -> case type_check(Value, Schema) of ok -> Errs1 = keywords(Value, Schema, Path, Errs), combinators(Value, Schema, Path, Errs1); {error, Msg} -> [{Path, Msg} | Errs] end. type_check(Value, Schema) -> case sget(type, Schema) of undefined -> ok; Types when is_list(Types) -> case lists:any(fun(T) -> type_ok(T, Value) end, Types) of true -> ok; false -> {error, type_error_list(Types)} end; Type -> case type_ok(Type, Value) of true -> ok; false -> {error, type_error(Type)} end end. keywords(Value, Schema, Path, Errs) -> Checks = [ fun check_const/4, fun check_enum/4, fun check_bounds/4, fun check_multiple_of/4, fun check_length/4, fun check_pattern/4, fun check_required/4, fun check_properties/4, fun check_property_count/4, fun check_additional_properties/4, fun check_items/4, fun check_array_constraints/4 ], lists:foldl( fun(Check, Acc) -> Check(Value, Schema, Path, Acc) end, Errs, Checks ). %%==================================================================== %% Combinators: allOf / anyOf / oneOf %%==================================================================== combinators(Value, Schema, Path, Errs) -> Errs1 = check_all_of(Value, Schema, Path, Errs), Errs2 = check_any_of(Value, Schema, Path, Errs1), check_one_of(Value, Schema, Path, Errs2). check_all_of(Value, Schema, Path, Errs) -> case sget(allOf, Schema) of Schemas when is_list(Schemas) -> lists:foldl( fun(S, Acc) -> check(Value, S, Path, Acc) end, Errs, Schemas ); _ -> Errs end. check_any_of(Value, Schema, Path, Errs) -> case sget(anyOf, Schema) of Schemas when is_list(Schemas) -> case lists:any(fun(S) -> validate(Value, S) =:= ok end, Schemas) of true -> Errs; false -> [{Path, <<"does not match any schema in anyOf">>} | Errs] end; _ -> Errs end. check_one_of(Value, Schema, Path, Errs) -> case sget(oneOf, Schema) of Schemas when is_list(Schemas) -> Matches = length([S || S <- Schemas, validate(Value, S) =:= ok]), case Matches of 1 -> Errs; _ -> [{Path, <<"must match exactly one schema in oneOf">>} | Errs] end; _ -> Errs end. %%==================================================================== %% Keyword checks %%==================================================================== check_const(Value, Schema, Path, Errs) -> case sget(const, Schema) of undefined -> Errs; Const when Value =:= Const -> Errs; _ -> [{Path, <<"does not equal const">>} | Errs] end. check_enum(Value, Schema, Path, Errs) -> case sget(enum, Schema) of undefined -> Errs; Allowed -> case lists:member(Value, Allowed) of true -> Errs; false -> [{Path, <<"not one of the allowed values">>} | Errs] end end. check_bounds(Value, Schema, Path, Errs) when is_number(Value) -> E1 = bound( Value, sget(minimum, Schema), fun(V, M) -> V < M end, <<"below minimum">>, Path, Errs ), E2 = bound( Value, sget(maximum, Schema), fun(V, M) -> V > M end, <<"above maximum">>, Path, E1 ), E3 = bound( Value, sget(exclusiveMinimum, Schema), fun(V, M) -> V =< M end, <<"not above exclusiveMinimum">>, Path, E2 ), bound( Value, sget(exclusiveMaximum, Schema), fun(V, M) -> V >= M end, <<"not below exclusiveMaximum">>, Path, E3 ); check_bounds(_Value, _Schema, _Path, Errs) -> Errs. bound(_Value, undefined, _Fail, _Msg, _Path, Errs) -> Errs; bound(Value, Limit, Fail, Msg, Path, Errs) when is_number(Limit) -> case Fail(Value, Limit) of true -> [{Path, Msg} | Errs]; false -> Errs end; bound(_Value, _Limit, _Fail, _Msg, _Path, Errs) -> Errs. check_multiple_of(Value, Schema, Path, Errs) when is_number(Value) -> case sget(multipleOf, Schema) of M when is_number(M), M > 0 -> Ratio = Value / M, case Ratio == trunc(Ratio) of true -> Errs; false -> [{Path, <<"not a multiple of">>} | Errs] end; _ -> Errs end; check_multiple_of(_Value, _Schema, _Path, Errs) -> Errs. check_pattern(Value, Schema, Path, Errs) when is_binary(Value) -> case sget(pattern, Schema) of Pattern when is_binary(Pattern) -> case re:run(Value, Pattern, [unicode]) of {match, _} -> Errs; nomatch -> [{Path, <<"does not match pattern">>} | Errs]; {error, _} -> Errs end; _ -> Errs end; check_pattern(_Value, _Schema, _Path, Errs) -> Errs. check_length(Value, Schema, Path, Errs) when is_binary(Value) -> Len = byte_size(Value), Errs1 = case sget(minLength, Schema) of undefined -> Errs; Min when Len < Min -> [{Path, <<"shorter than minLength">>} | Errs]; _ -> Errs end, case sget(maxLength, Schema) of undefined -> Errs1; Max when Len > Max -> [{Path, <<"longer than maxLength">>} | Errs1]; _ -> Errs1 end; check_length(_Value, _Schema, _Path, Errs) -> Errs. check_required(Value, Schema, Path, Errs) when is_map(Value) -> case sget(required, Schema) of undefined -> Errs; Names -> lists:foldl( fun(Name, Acc) -> case maps:is_key(Name, Value) of true -> Acc; false -> [{join(Path, Name), <<"required property missing">>} | Acc] end end, Errs, Names ) end; check_required(_Value, _Schema, _Path, Errs) -> Errs. check_properties(Value, Schema, Path, Errs) when is_map(Value) -> case sget(properties, Schema) of undefined -> Errs; Props -> maps:fold( fun(Name, PropSchema, Acc) -> case maps:find(Name, Value) of {ok, V} -> check(V, PropSchema, join(Path, Name), Acc); error -> Acc end end, Errs, Props ) end; check_properties(_Value, _Schema, _Path, Errs) -> Errs. check_items(Value, Schema, Path, Errs) when is_list(Value) -> case sget(items, Schema) of undefined -> Errs; ItemSchema -> {_, Acc} = lists:foldl( fun(V, {I, A}) -> IPath = <>, {I + 1, check(V, ItemSchema, IPath, A)} end, {0, Errs}, Value ), Acc end; check_items(_Value, _Schema, _Path, Errs) -> Errs. check_property_count(Value, Schema, Path, Errs) when is_map(Value) -> N = map_size(Value), E1 = case sget(minProperties, Schema) of Min when is_integer(Min), N < Min -> [{Path, <<"fewer than minProperties">>} | Errs]; _ -> Errs end, case sget(maxProperties, Schema) of Max when is_integer(Max), N > Max -> [{Path, <<"more than maxProperties">>} | E1]; _ -> E1 end; check_property_count(_Value, _Schema, _Path, Errs) -> Errs. check_additional_properties(Value, Schema, Path, Errs) when is_map(Value) -> case sget(additionalProperties, Schema) of undefined -> Errs; true -> Errs; false -> Known = known_props(Schema), lists:foldl( fun(K, Acc) -> case lists:member(K, Known) of true -> Acc; false -> [{join(Path, K), <<"additional property not allowed">>} | Acc] end end, Errs, maps:keys(Value) ); Sub when is_map(Sub) -> Known = known_props(Schema), maps:fold( fun(K, V, Acc) -> case lists:member(K, Known) of true -> Acc; false -> check(V, Sub, join(Path, K), Acc) end end, Errs, Value ) end; check_additional_properties(_Value, _Schema, _Path, Errs) -> Errs. known_props(Schema) -> case sget(properties, Schema) of Props when is_map(Props) -> maps:keys(Props); _ -> [] end. check_array_constraints(Value, Schema, Path, Errs) when is_list(Value) -> Len = length(Value), E1 = case sget(minItems, Schema) of Min when is_integer(Min), Len < Min -> [{Path, <<"fewer than minItems">>} | Errs]; _ -> Errs end, E2 = case sget(maxItems, Schema) of Max when is_integer(Max), Len > Max -> [{Path, <<"more than maxItems">>} | E1]; _ -> E1 end, case sget(uniqueItems, Schema) of true -> case length(lists:usort(Value)) =:= Len of true -> E2; false -> [{Path, <<"items not unique">>} | E2] end; _ -> E2 end; check_array_constraints(_Value, _Schema, _Path, Errs) -> Errs. %%==================================================================== %% Type checks %%==================================================================== type_ok(<<"object">>, V) -> is_map(V); type_ok(<<"array">>, V) -> is_list(V); type_ok(<<"string">>, V) -> is_binary(V); type_ok(<<"number">>, V) -> is_number(V); type_ok(<<"integer">>, V) -> is_integer(V); type_ok(<<"boolean">>, V) -> is_boolean(V); type_ok(<<"null">>, V) -> V =:= null; type_ok(Type, V) when is_atom(Type) -> type_ok(atom_to_binary(Type), V); type_ok(_Other, _V) -> true. type_error(Type) when is_atom(Type) -> type_error(atom_to_binary(Type)); type_error(Type) -> <<"expected ", Type/binary>>. type_error_list(Types) -> Names = lists:join(<<", ">>, [type_name(T) || T <- Types]), iolist_to_binary([<<"expected one of ">>, Names]). type_name(T) when is_atom(T) -> atom_to_binary(T); type_name(T) -> T. %%==================================================================== %% Middleware %%==================================================================== -spec call( livery_req:req(), livery_middleware:next(), #{body_schema := schema()} ) -> livery_resp:resp(). call(Req, Next, #{body_schema := Schema}) -> case livery_ext:json(Req) of {ok, Decoded} -> case validate(Decoded, Schema) of ok -> Next(livery_req:set_meta(body, Decoded, Req)); {error, Errors} -> livery_resp:json(422, encode_errors(Errors)) end; {error, no_body} -> livery_resp:text(400, <<"request body required">>); {error, _} -> livery_resp:text(400, <<"malformed JSON body">>) end. encode_errors(Errors) -> Items = [#{<<"path">> => P, <<"error">> => E} || {P, E} <- Errors], iolist_to_binary(json:encode(#{<<"errors">> => Items})). %%==================================================================== %% Helpers %%==================================================================== %% Accept atom or binary schema keys. sget(Key, Schema) when is_atom(Key) -> case maps:find(Key, Schema) of {ok, V} -> V; error -> maps:get(atom_to_binary(Key), Schema, undefined) end. join(<<"$">>, Name) -> <<"$.", Name/binary>>; join(Path, Name) -> <>.