diff --git a/rebar.config b/rebar.config index 57d87d3..3d2fbf8 100644 --- a/rebar.config +++ b/rebar.config @@ -4,6 +4,14 @@ {jesse, "1.8.1"} ]}. +{profiles, [ + {test, [ + {deps, [ + {meck, "0.9.2"} + ]} + ]} +]}. + {xref_ignores, [ {nova_json_schemas, init, 0}, {nova_json_schemas, load_local_schemas, 0}, @@ -43,6 +51,7 @@ {files, [ "rebar.config", "src/*.app.src", - "src/**/{*.erl, *.hrl}" + "src/**/{*.erl, *.hrl}", + "test/**/{*.erl, *.hrl}" ]} ]}. diff --git a/src/nova_json_schemas.erl b/src/nova_json_schemas.erl index 01c97d6..067ab16 100644 --- a/src/nova_json_schemas.erl +++ b/src/nova_json_schemas.erl @@ -11,17 +11,25 @@ -include_lib("kernel/include/logger.hrl"). +-ifdef(TEST). +-export([ + render_errors/1, + render_one_error/1, + build_problem_details/3, + group_errors_by_field/1, + to_json_pointer/1, + format_error_message/3, + safe_format/1, + safe_value/1, + validate_json/3 +]). +-endif. + init() -> jesse_database:load_all(), load_local_schemas(), #{}. -%%-------------------------------------------------------------------- -%% @doc -%% Load all local JSON schemas from the main application's -%% priv/schemas directory -%% @end -%%-------------------------------------------------------------------- -spec load_local_schemas() -> ok. load_local_schemas() -> {ok, MainApp} = nova:get_main_app(), @@ -35,11 +43,6 @@ load_local_schemas() -> end, ok. -%%-------------------------------------------------------------------- -%% @doc -%% Pre-request callback -%% @end -%%-------------------------------------------------------------------- -spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> {ok, Req0 :: cowboy_req:req(), NewState :: any()} | {stop, Req0 :: cowboy_req:req(), NewState :: any()} @@ -57,22 +60,21 @@ pre_request( {ok, Req, State}; {error, Errors} -> ?LOG_DEBUG("Got validation-errors on JSON body. Errors: ~p", [Errors]), + StatusCode = maps:get(status_code, Options, 422), case maps:get(render_errors, Options, false) of true -> - ?LOG_DEBUG("Rendering validation-errors and send back to requester"), + GroupByField = maps:get(group_by_field, Options, false), + ErrorList = render_errors(Errors), + ErrorBody = build_problem_details(StatusCode, ErrorList, GroupByField), + ErrorJson = json:encode(ErrorBody), Req0 = cowboy_req:set_resp_headers( - #{<<"content-type">> => <<"application/json">>}, Req + #{<<"content-type">> => <<"application/problem+json">>}, Req ), - ErrorStruct = render_error(Errors), - ErrorJson = json:encode(ErrorStruct), Req1 = cowboy_req:set_resp_body(ErrorJson, Req0), - Req2 = cowboy_req:reply(400, Req1), + Req2 = cowboy_req:reply(StatusCode, Req1), {stop, Req2, State}; _ -> - ?LOG_DEBUG( - "render_errors-option not set for plugin nova_json_schemas - returning plain 400-status to requester" - ), - Req0 = cowboy_req:reply(400, Req), + Req0 = cowboy_req:reply(StatusCode, Req), {stop, Req0, State} end end; @@ -84,21 +86,11 @@ pre_request(#{extra_state := #{json_schema := _SchemaLocation}}, _Env, _Options, pre_request(Req, _Env, _Options, State) -> {ok, Req, State}. -%%-------------------------------------------------------------------- -%% @doc -%% Post-request callback -%% @end -%%-------------------------------------------------------------------- -spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> {ok, Req0 :: cowboy_req:req(), NewState :: any()}. post_request(Req, _Env, _Options, State) -> {ok, Req, State}. -%%-------------------------------------------------------------------- -%% @doc -%% nova_plugin callback. Returns information about the plugin. -%% @end -%%-------------------------------------------------------------------- -spec plugin_info() -> #{ title := binary(), @@ -111,46 +103,224 @@ post_request(Req, _Env, _Options, State) -> plugin_info() -> #{ title => <<"Nova JSON Schema plugin">>, - version => <<"0.2.0">>, + version => <<"0.3.0">>, url => <<"https://github.com/novaframework/nova_json_schemas">>, authors => [<<"Niclas Axelsson ">>], description => <<"Validates JSON request bodies against JSON schemas using jesse">>, options => [ - {render_errors, <<"If true, validation errors are returned as JSON to the requester">>} + {render_errors, + <<"If true, validation errors are returned as RFC 9457 problem+json to the requester">>}, + {status_code, <<"HTTP status code for validation errors (default: 422)">>}, + {group_by_field, + <<"If true, errors are grouped by field name in the response (default: false)">>} ] }. +%%% Internal functions + validate_json(SchemaLocation, Json, JesseOpts) -> - case jesse:validate(SchemaLocation, Json, JesseOpts) of + Key = ensure_list(SchemaLocation), + case jesse:validate(Key, Json, JesseOpts) of {error, {database_error, _, schema_not_found}} -> - %% Load the schema {ok, MainApp} = nova:get_main_app(), PrivDir = code:priv_dir(MainApp), SchemaLocation0 = filename:join([PrivDir, SchemaLocation]), - {ok, Filecontent} = file:read_file(SchemaLocation0), - Schema = json:decode(Filecontent), - jesse:add_schema(SchemaLocation, Schema), - validate_json(SchemaLocation, Json, JesseOpts); + case file:read_file(SchemaLocation0) of + {ok, Filecontent} -> + Schema = json:decode(Filecontent), + jesse:add_schema(SchemaLocation, Schema), + validate_json(SchemaLocation, Json, JesseOpts); + {error, Reason} -> + ?LOG_ERROR("Failed to read schema file ~s: ~p", [SchemaLocation0, Reason]), + {error, [{schema_invalid, SchemaLocation, {file_error, Reason}}]} + end; {error, ValidationError} -> {error, ValidationError}; {ok, _} -> ok end. -render_error([]) -> +build_problem_details(StatusCode, ErrorList, true) -> + #{ + type => <<"about:blank">>, + title => <<"Validation Error">>, + status => StatusCode, + detail => <<"Request body failed JSON schema validation">>, + errors => group_errors_by_field(ErrorList) + }; +build_problem_details(StatusCode, ErrorList, false) -> + #{ + type => <<"about:blank">>, + title => <<"Validation Error">>, + status => StatusCode, + detail => <<"Request body failed JSON schema validation">>, + errors => ErrorList + }. + +group_errors_by_field(Errors) -> + lists:foldl( + fun(#{path := Path, message := Msg}, Acc) -> + Existing = maps:get(Path, Acc, []), + Acc#{Path => Existing ++ [Msg]} + end, + #{}, + Errors + ). + +render_errors([]) -> []; -render_error([{data_invalid, FieldInfo, Type, ActualValue, Field} | Tl]) -> - %% We don't do any fancy with this currently. - [ - #{ - error_context => schema_violation, - field_info => FieldInfo, - error_type => Type, - actual_value => ActualValue, - expected_value => Field - } - | render_error(Tl) - ]. +render_errors([Error | Tl]) -> + [render_one_error(Error) | render_errors(Tl)]. + +render_one_error({data_invalid, _Schema, {ErrorType, Details}, Value, Path}) -> + #{ + path => to_json_pointer(Path), + type => ErrorType, + message => format_error_message(ErrorType, Details, Value), + actual_value => safe_value(Value), + expected => safe_value(Details) + }; +render_one_error({data_invalid, Schema, wrong_type, Value, Path}) -> + Expected = maps:get(<<"type">>, Schema, undefined), + #{ + path => to_json_pointer(Path), + type => wrong_type, + message => format_error_message(wrong_type, Expected, Value), + actual_value => safe_value(Value), + expected => safe_value(Expected) + }; +render_one_error({data_invalid, _Schema, missing_required_property, Property, Path}) -> + #{ + path => to_json_pointer(Path), + type => missing_required_property, + message => format_error_message(missing_required_property, Property, undefined) + }; +render_one_error({data_invalid, _Schema, ErrorType, Value, Path}) -> + #{ + path => to_json_pointer(Path), + type => ErrorType, + message => format_error_message(ErrorType, undefined, Value), + actual_value => safe_value(Value) + }; +render_one_error({schema_invalid, _Schema, {ErrorType, Details}}) -> + #{ + path => <<"">>, + type => ErrorType, + message => format_error_message(ErrorType, Details, undefined) + }; +render_one_error({schema_invalid, _Schema, ErrorType}) -> + #{ + path => <<"">>, + type => ErrorType, + message => format_error_message(ErrorType, undefined, undefined) + }; +render_one_error({data_error, {parse_error, Details}}) -> + #{ + path => <<"">>, + type => parse_error, + message => iolist_to_binary(io_lib:format("Parse error: ~p", [Details])) + }; +render_one_error({schema_error, {parse_error, Details}}) -> + #{ + path => <<"">>, + type => schema_parse_error, + message => iolist_to_binary(io_lib:format("Schema parse error: ~p", [Details])) + }; +render_one_error(Unknown) -> + ?LOG_WARNING("Unhandled validation error format: ~p", [Unknown]), + #{ + path => <<"">>, + type => unknown_error, + message => iolist_to_binary(io_lib:format("~p", [Unknown])) + }. + +to_json_pointer([]) -> + <<"/">>; +to_json_pointer(Path) when is_list(Path) -> + Segments = [escape_json_pointer(segment_to_binary(S)) || S <- Path], + iolist_to_binary([<<"/">>, lists:join(<<"/">>, Segments)]). + +segment_to_binary(S) when is_binary(S) -> S; +segment_to_binary(S) when is_integer(S) -> integer_to_binary(S); +segment_to_binary(S) when is_atom(S) -> atom_to_binary(S); +segment_to_binary(S) -> iolist_to_binary(io_lib:format("~p", [S])). + +escape_json_pointer(Bin) -> + binary:replace(binary:replace(Bin, <<"~">>, <<"~0">>, [global]), <<"/">>, <<"~1">>, [global]). + +format_error_message(wrong_type, Expected, _Value) -> + iolist_to_binary(io_lib:format("Must be of type ~s", [safe_format(Expected)])); +format_error_message(not_in_enum, _Expected, _Value) -> + <<"Value is not in the allowed set of values">>; +format_error_message(not_in_range, Expected, _Value) -> + iolist_to_binary( + io_lib:format("Value is not in the allowed range ~s", [safe_format(Expected)]) + ); +format_error_message(missing_required_property, Property, _Value) -> + iolist_to_binary(io_lib:format("Missing required property: ~s", [safe_format(Property)])); +format_error_message(no_extra_properties_allowed, _Expected, _Value) -> + <<"Additional properties are not allowed">>; +format_error_message(no_extra_items_allowed, _Expected, _Value) -> + <<"Additional items are not allowed">>; +format_error_message(not_allowed, _Expected, _Value) -> + <<"Value is not allowed">>; +format_error_message(not_unique, _Expected, _Value) -> + <<"Array items are not unique">>; +format_error_message(wrong_size, Expected, _Value) -> + iolist_to_binary(io_lib:format("Array size is invalid, expected ~s", [safe_format(Expected)])); +format_error_message(wrong_length, Expected, _Value) -> + iolist_to_binary( + io_lib:format("String length is invalid, expected ~s", [safe_format(Expected)]) + ); +format_error_message(wrong_format, Expected, _Value) -> + iolist_to_binary(io_lib:format("Value does not match format: ~s", [safe_format(Expected)])); +format_error_message(not_divisible, Expected, _Value) -> + iolist_to_binary(io_lib:format("Value is not divisible by ~s", [safe_format(Expected)])); +format_error_message(not_multiple_of, Expected, _Value) -> + iolist_to_binary(io_lib:format("Value is not a multiple of ~s", [safe_format(Expected)])); +format_error_message(no_match, _Expected, _Value) -> + <<"Value does not match the required pattern">>; +format_error_message(all_schemas_not_valid, _Expected, _Value) -> + <<"Value does not match all of the required schemas">>; +format_error_message(any_schemas_not_valid, _Expected, _Value) -> + <<"Value does not match any of the required schemas">>; +format_error_message(not_one_schema_valid, _Expected, _Value) -> + <<"Value does not match exactly one of the required schemas">>; +format_error_message(more_than_one_schema_valid, _Expected, _Value) -> + <<"Value matches more than one of the required schemas">>; +format_error_message(not_schema_valid, _Expected, _Value) -> + <<"Value must not match the schema">>; +format_error_message(too_many_properties, Expected, _Value) -> + iolist_to_binary( + io_lib:format("Too many properties, maximum allowed: ~s", [safe_format(Expected)]) + ); +format_error_message(too_few_properties, Expected, _Value) -> + iolist_to_binary( + io_lib:format("Too few properties, minimum required: ~s", [safe_format(Expected)]) + ); +format_error_message(missing_dependency, Expected, _Value) -> + iolist_to_binary(io_lib:format("Missing dependency: ~s", [safe_format(Expected)])); +format_error_message(file_error, Reason, _Value) -> + iolist_to_binary(io_lib:format("Schema file error: ~p", [Reason])); +format_error_message(Type, undefined, _Value) -> + iolist_to_binary(io_lib:format("Validation failed: ~s", [Type])); +format_error_message(Type, Expected, _Value) -> + iolist_to_binary(io_lib:format("Validation failed (~s): ~s", [Type, safe_format(Expected)])). + +safe_format(undefined) -> <<"">>; +safe_format(V) when is_binary(V) -> V; +safe_format(V) when is_atom(V) -> atom_to_binary(V); +safe_format(V) when is_integer(V) -> integer_to_binary(V); +safe_format(V) when is_float(V) -> float_to_binary(V, [{decimals, 10}, compact]); +safe_format(V) -> iolist_to_binary(io_lib:format("~p", [V])). + +safe_value(V) when is_map(V) -> V; +safe_value(V) when is_list(V) -> V; +safe_value(V) when is_binary(V) -> V; +safe_value(V) when is_number(V) -> V; +safe_value(V) when is_boolean(V) -> V; +safe_value(null) -> null; +safe_value(V) -> iolist_to_binary(io_lib:format("~p", [V])). load_schemas_from_dir(Dir, RelativePrefix) -> case file:list_dir(Dir) of @@ -191,3 +361,6 @@ load_schema_file(FilePath, RelativePath) -> {error, Reason} -> ?LOG_ERROR("Failed to read schema file ~s: ~p", [FilePath, Reason]) end. + +ensure_list(V) when is_list(V) -> V; +ensure_list(V) when is_binary(V) -> binary_to_list(V). diff --git a/test/nova_json_schemas_tests.erl b/test/nova_json_schemas_tests.erl new file mode 100644 index 0000000..d2721d4 --- /dev/null +++ b/test/nova_json_schemas_tests.erl @@ -0,0 +1,513 @@ +-module(nova_json_schemas_tests). + +-include_lib("eunit/include/eunit.hrl"). + +%%% ================================================================= +%%% to_json_pointer/1 +%%% ================================================================= + +to_json_pointer_empty_test() -> + ?assertEqual(<<"/">>, nova_json_schemas:to_json_pointer([])). + +to_json_pointer_single_segment_test() -> + ?assertEqual(<<"/name">>, nova_json_schemas:to_json_pointer([<<"name">>])). + +to_json_pointer_nested_test() -> + ?assertEqual( + <<"/address/street">>, + nova_json_schemas:to_json_pointer([<<"address">>, <<"street">>]) + ). + +to_json_pointer_integer_index_test() -> + ?assertEqual( + <<"/items/0/name">>, + nova_json_schemas:to_json_pointer([<<"items">>, 0, <<"name">>]) + ). + +to_json_pointer_atom_segment_test() -> + ?assertEqual(<<"/foo">>, nova_json_schemas:to_json_pointer([foo])). + +to_json_pointer_escapes_tilde_test() -> + ?assertEqual(<<"/a~0b">>, nova_json_schemas:to_json_pointer([<<"a~b">>])). + +to_json_pointer_escapes_slash_test() -> + ?assertEqual(<<"/a~1b">>, nova_json_schemas:to_json_pointer([<<"a/b">>])). + +to_json_pointer_escapes_both_test() -> + ?assertEqual(<<"/a~0b~1c">>, nova_json_schemas:to_json_pointer([<<"a~b/c">>])). + +%%% ================================================================= +%%% format_error_message/3 +%%% ================================================================= + +format_error_message_wrong_type_test() -> + ?assertEqual( + <<"Must be of type string">>, + nova_json_schemas:format_error_message(wrong_type, <<"string">>, 42) + ). + +format_error_message_not_in_enum_test() -> + ?assertEqual( + <<"Value is not in the allowed set of values">>, + nova_json_schemas:format_error_message(not_in_enum, [1, 2], 3) + ). + +format_error_message_missing_required_property_test() -> + ?assertEqual( + <<"Missing required property: name">>, + nova_json_schemas:format_error_message(missing_required_property, <<"name">>, undefined) + ). + +format_error_message_no_extra_properties_test() -> + ?assertEqual( + <<"Additional properties are not allowed">>, + nova_json_schemas:format_error_message(no_extra_properties_allowed, undefined, undefined) + ). + +format_error_message_not_unique_test() -> + ?assertEqual( + <<"Array items are not unique">>, + nova_json_schemas:format_error_message(not_unique, undefined, [1, 1]) + ). + +format_error_message_no_match_test() -> + ?assertEqual( + <<"Value does not match the required pattern">>, + nova_json_schemas:format_error_message(no_match, undefined, <<"abc">>) + ). + +format_error_message_file_error_test() -> + ?assertEqual( + <<"Schema file error: enoent">>, + nova_json_schemas:format_error_message(file_error, enoent, undefined) + ). + +format_error_message_unknown_type_no_details_test() -> + ?assertEqual( + <<"Validation failed: custom_check">>, + nova_json_schemas:format_error_message(custom_check, undefined, undefined) + ). + +format_error_message_unknown_type_with_details_test() -> + ?assertEqual( + <<"Validation failed (custom_check): some detail">>, + nova_json_schemas:format_error_message(custom_check, <<"some detail">>, undefined) + ). + +%%% ================================================================= +%%% safe_format/1 +%%% ================================================================= + +safe_format_undefined_test() -> + ?assertEqual(<<"">>, nova_json_schemas:safe_format(undefined)). + +safe_format_binary_test() -> + ?assertEqual(<<"hello">>, nova_json_schemas:safe_format(<<"hello">>)). + +safe_format_atom_test() -> + ?assertEqual(<<"foo">>, nova_json_schemas:safe_format(foo)). + +safe_format_integer_test() -> + ?assertEqual(<<"42">>, nova_json_schemas:safe_format(42)). + +safe_format_float_test() -> + Result = nova_json_schemas:safe_format(3.14), + ?assert(is_binary(Result)), + ?assertMatch(<<"3.14", _/binary>>, Result). + +safe_format_other_test() -> + Result = nova_json_schemas:safe_format({some, tuple}), + ?assert(is_binary(Result)). + +%%% ================================================================= +%%% safe_value/1 +%%% ================================================================= + +safe_value_map_test() -> + M = #{a => 1}, + ?assertEqual(M, nova_json_schemas:safe_value(M)). + +safe_value_list_test() -> + ?assertEqual([1, 2], nova_json_schemas:safe_value([1, 2])). + +safe_value_binary_test() -> + ?assertEqual(<<"hi">>, nova_json_schemas:safe_value(<<"hi">>)). + +safe_value_number_test() -> + ?assertEqual(42, nova_json_schemas:safe_value(42)), + ?assertEqual(1.5, nova_json_schemas:safe_value(1.5)). + +safe_value_boolean_test() -> + ?assertEqual(true, nova_json_schemas:safe_value(true)), + ?assertEqual(false, nova_json_schemas:safe_value(false)). + +safe_value_null_test() -> + ?assertEqual(null, nova_json_schemas:safe_value(null)). + +safe_value_other_test() -> + Result = nova_json_schemas:safe_value({some, tuple}), + ?assert(is_binary(Result)). + +%%% ================================================================= +%%% render_one_error/1 +%%% ================================================================= + +render_one_error_data_invalid_with_details_test() -> + Error = {data_invalid, #{}, {wrong_type, <<"string">>}, 42, [<<"name">>]}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"/name">>, maps:get(path, Result)), + ?assertEqual(wrong_type, maps:get(type, Result)), + ?assertEqual(<<"Must be of type string">>, maps:get(message, Result)), + ?assertEqual(42, maps:get(actual_value, Result)), + ?assertEqual(<<"string">>, maps:get(expected, Result)). + +render_one_error_data_invalid_simple_test() -> + Error = {data_invalid, #{}, not_in_enum, <<"foo">>, [<<"status">>]}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"/status">>, maps:get(path, Result)), + ?assertEqual(not_in_enum, maps:get(type, Result)), + ?assertEqual(<<"foo">>, maps:get(actual_value, Result)), + ?assertNot(maps:is_key(expected, Result)). + +render_one_error_wrong_type_from_schema_test() -> + Schema = #{<<"type">> => <<"integer">>}, + Error = {data_invalid, Schema, wrong_type, <<"old">>, [<<"age">>]}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"/age">>, maps:get(path, Result)), + ?assertEqual(wrong_type, maps:get(type, Result)), + ?assertEqual(<<"Must be of type integer">>, maps:get(message, Result)), + ?assertEqual(<<"old">>, maps:get(actual_value, Result)), + ?assertEqual(<<"integer">>, maps:get(expected, Result)). + +render_one_error_missing_required_property_test() -> + Schema = #{<<"required">> => [<<"name">>, <<"age">>]}, + Error = {data_invalid, Schema, missing_required_property, <<"age">>, []}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"/">>, maps:get(path, Result)), + ?assertEqual(missing_required_property, maps:get(type, Result)), + ?assertEqual(<<"Missing required property: age">>, maps:get(message, Result)), + ?assertNot(maps:is_key(actual_value, Result)). + +render_one_error_schema_invalid_with_details_test() -> + Error = {schema_invalid, #{}, {missing_required_property, <<"id">>}}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"">>, maps:get(path, Result)), + ?assertEqual(missing_required_property, maps:get(type, Result)). + +render_one_error_schema_invalid_simple_test() -> + Error = {schema_invalid, #{}, some_error}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"">>, maps:get(path, Result)), + ?assertEqual(some_error, maps:get(type, Result)). + +render_one_error_parse_error_test() -> + Error = {data_error, {parse_error, <<"bad json">>}}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"">>, maps:get(path, Result)), + ?assertEqual(parse_error, maps:get(type, Result)). + +render_one_error_schema_parse_error_test() -> + Error = {schema_error, {parse_error, <<"bad schema">>}}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"">>, maps:get(path, Result)), + ?assertEqual(schema_parse_error, maps:get(type, Result)). + +render_one_error_unknown_test() -> + Error = {totally_unexpected, error}, + Result = nova_json_schemas:render_one_error(Error), + ?assertEqual(<<"">>, maps:get(path, Result)), + ?assertEqual(unknown_error, maps:get(type, Result)). + +%%% ================================================================= +%%% render_errors/1 +%%% ================================================================= + +render_errors_empty_test() -> + ?assertEqual([], nova_json_schemas:render_errors([])). + +render_errors_multiple_test() -> + Errors = [ + {data_invalid, #{}, not_in_enum, <<"x">>, [<<"status">>]}, + {data_invalid, #{}, {wrong_type, <<"integer">>}, <<"abc">>, [<<"age">>]} + ], + Result = nova_json_schemas:render_errors(Errors), + ?assertEqual(2, length(Result)), + [First, Second] = Result, + ?assertEqual(<<"/status">>, maps:get(path, First)), + ?assertEqual(<<"/age">>, maps:get(path, Second)). + +%%% ================================================================= +%%% build_problem_details/3 +%%% ================================================================= + +build_problem_details_flat_test() -> + ErrorList = [ + #{path => <<"/name">>, type => wrong_type, message => <<"Must be of type string">>} + ], + Result = nova_json_schemas:build_problem_details(422, ErrorList, false), + ?assertEqual(<<"about:blank">>, maps:get(type, Result)), + ?assertEqual(<<"Validation Error">>, maps:get(title, Result)), + ?assertEqual(422, maps:get(status, Result)), + ?assertEqual(<<"Request body failed JSON schema validation">>, maps:get(detail, Result)), + ?assertEqual(ErrorList, maps:get(errors, Result)). + +build_problem_details_custom_status_test() -> + Result = nova_json_schemas:build_problem_details(400, [], false), + ?assertEqual(400, maps:get(status, Result)). + +build_problem_details_grouped_test() -> + ErrorList = [ + #{path => <<"/name">>, type => wrong_type, message => <<"Must be of type string">>}, + #{path => <<"/name">>, type => wrong_length, message => <<"String length is invalid">>}, + #{path => <<"/age">>, type => wrong_type, message => <<"Must be of type integer">>} + ], + Result = nova_json_schemas:build_problem_details(422, ErrorList, true), + Grouped = maps:get(errors, Result), + ?assert(is_map(Grouped)), + ?assertEqual(2, map_size(Grouped)), + ?assertEqual( + [<<"Must be of type string">>, <<"String length is invalid">>], + maps:get(<<"/name">>, Grouped) + ), + ?assertEqual( + [<<"Must be of type integer">>], + maps:get(<<"/age">>, Grouped) + ). + +%%% ================================================================= +%%% group_errors_by_field/1 +%%% ================================================================= + +group_errors_by_field_empty_test() -> + ?assertEqual(#{}, nova_json_schemas:group_errors_by_field([])). + +group_errors_by_field_single_test() -> + Errors = [#{path => <<"/name">>, message => <<"required">>}], + Result = nova_json_schemas:group_errors_by_field(Errors), + ?assertEqual(#{<<"/name">> => [<<"required">>]}, Result). + +group_errors_by_field_multiple_same_field_test() -> + Errors = [ + #{path => <<"/name">>, message => <<"required">>}, + #{path => <<"/name">>, message => <<"too short">>} + ], + Result = nova_json_schemas:group_errors_by_field(Errors), + ?assertEqual(#{<<"/name">> => [<<"required">>, <<"too short">>]}, Result). + +%%% ================================================================= +%%% Tests requiring jesse application (test generators with setup) +%%% ================================================================= + +jesse_setup() -> + jesse_database:load_all(), + ok. + +jesse_cleanup(_) -> + ok. + +validate_json_test_() -> + {setup, fun jesse_setup/0, fun jesse_cleanup/1, [ + {"valid JSON passes validation", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"test_schema">>, Schema), + ?assertEqual( + ok, + nova_json_schemas:validate_json( + <<"test_schema">>, #{<<"name">> => <<"Alice">>}, [] + ) + ) + end}, + {"invalid JSON fails validation", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"test_schema_inv">>, Schema), + ?assertMatch( + {error, _}, + nova_json_schemas:validate_json(<<"test_schema_inv">>, #{<<"name">> => 42}, []) + ) + end} + ]}. + +pre_request_test_() -> + {setup, fun jesse_setup/0, fun jesse_cleanup/1, [ + {"passthrough when no schema configured", fun() -> + Req = #{method => <<"GET">>, has_body => false}, + ?assertEqual( + {ok, Req, my_state}, + nova_json_schemas:pre_request(Req, #{}, #{}, my_state) + ) + end}, + {"error when body not parsed", fun() -> + Req = #{extra_state => #{json_schema => <<"some_schema">>}}, + ?assertEqual( + {error, body_not_parsed}, + nova_json_schemas:pre_request(Req, #{}, #{}, my_state) + ) + end}, + {"valid JSON passes through", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"pre_req_test">>, Schema), + JSON = #{<<"name">> => <<"Alice">>}, + Req = #{ + extra_state => #{json_schema => <<"pre_req_test">>}, + json => JSON + }, + ?assertMatch( + {ok, _, my_state}, + nova_json_schemas:pre_request(Req, #{}, #{}, my_state) + ) + end}, + {"invalid JSON returns 422 without render", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"pre_req_no_render">>, Schema), + JSON = #{<<"name">> => 42}, + Req = #{ + extra_state => #{json_schema => <<"pre_req_no_render">>}, + json => JSON + }, + meck:new(cowboy_req, [passthrough]), + meck:expect(cowboy_req, reply, fun(Status, R) -> R#{status => Status} end), + try + {stop, ResultReq, my_state} = + nova_json_schemas:pre_request(Req, #{}, #{}, my_state), + ?assertEqual(422, maps:get(status, ResultReq)) + after + meck:unload(cowboy_req) + end + end}, + {"invalid JSON respects custom status code", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"pre_req_custom_status">>, Schema), + JSON = #{<<"name">> => 42}, + Req = #{ + extra_state => #{json_schema => <<"pre_req_custom_status">>}, + json => JSON + }, + meck:new(cowboy_req, [passthrough]), + meck:expect(cowboy_req, reply, fun(Status, R) -> R#{status => Status} end), + try + {stop, ResultReq, my_state} = + nova_json_schemas:pre_request( + Req, #{}, #{status_code => 400}, my_state + ), + ?assertEqual(400, maps:get(status, ResultReq)) + after + meck:unload(cowboy_req) + end + end}, + {"invalid JSON renders RFC 9457 problem+json", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>} + } + }, + jesse:add_schema(<<"pre_req_render">>, Schema), + JSON = #{<<"name">> => 42}, + Req = #{ + extra_state => #{json_schema => <<"pre_req_render">>}, + json => JSON + }, + meck:new(cowboy_req, [passthrough]), + meck:expect(cowboy_req, set_resp_headers, fun(_Headers, R) -> R end), + meck:expect(cowboy_req, set_resp_body, fun(Body, R) -> R#{resp_body => Body} end), + meck:expect(cowboy_req, reply, fun(Status, R) -> R#{status => Status} end), + try + {stop, ResultReq, my_state} = + nova_json_schemas:pre_request( + Req, #{}, #{render_errors => true}, my_state + ), + ?assertEqual(422, maps:get(status, ResultReq)), + RespBody = json:decode(iolist_to_binary(maps:get(resp_body, ResultReq))), + ?assertEqual(<<"about:blank">>, maps:get(<<"type">>, RespBody)), + ?assertEqual(<<"Validation Error">>, maps:get(<<"title">>, RespBody)), + ?assertEqual(422, maps:get(<<"status">>, RespBody)), + ?assert(is_list(maps:get(<<"errors">>, RespBody))) + after + meck:unload(cowboy_req) + end + end}, + {"invalid JSON renders grouped errors", fun() -> + Schema = #{ + <<"type">> => <<"object">>, + <<"required">> => [<<"name">>, <<"age">>], + <<"properties">> => #{ + <<"name">> => #{<<"type">> => <<"string">>}, + <<"age">> => #{<<"type">> => <<"integer">>} + } + }, + jesse:add_schema(<<"pre_req_grouped">>, Schema), + JSON = #{<<"name">> => 42, <<"age">> => <<"not_int">>}, + Req = #{ + extra_state => #{json_schema => <<"pre_req_grouped">>}, + json => JSON + }, + meck:new(cowboy_req, [passthrough]), + meck:expect(cowboy_req, set_resp_headers, fun(_Headers, R) -> R end), + meck:expect(cowboy_req, set_resp_body, fun(Body, R) -> R#{resp_body => Body} end), + meck:expect(cowboy_req, reply, fun(Status, R) -> R#{status => Status} end), + try + {stop, ResultReq, my_state} = + nova_json_schemas:pre_request( + Req, + #{}, + #{render_errors => true, group_by_field => true}, + my_state + ), + RespBody = json:decode(iolist_to_binary(maps:get(resp_body, ResultReq))), + Errors = maps:get(<<"errors">>, RespBody), + ?assert(is_map(Errors)) + after + meck:unload(cowboy_req) + end + end} + ]}. + +%%% ================================================================= +%%% post_request/4 +%%% ================================================================= + +post_request_passthrough_test() -> + Req = #{method => <<"GET">>}, + ?assertEqual({ok, Req, my_state}, nova_json_schemas:post_request(Req, #{}, #{}, my_state)). + +%%% ================================================================= +%%% plugin_info/0 +%%% ================================================================= + +plugin_info_test() -> + application:load(nova_json_schemas), + Info = nova_json_schemas:plugin_info(), + ?assert(is_map(Info)), + ?assert(maps:is_key(title, Info)), + ?assert(maps:is_key(version, Info)), + ?assert(maps:is_key(options, Info)), + Options = maps:get(options, Info), + OptionKeys = [K || {K, _} <- Options], + ?assert(lists:member(render_errors, OptionKeys)), + ?assert(lists:member(status_code, OptionKeys)), + ?assert(lists:member(group_by_field, OptionKeys)).