Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -43,6 +51,7 @@
{files, [
"rebar.config",
"src/*.app.src",
"src/**/{*.erl, *.hrl}"
"src/**/{*.erl, *.hrl}",
"test/**/{*.erl, *.hrl}"
]}
]}.
275 changes: 224 additions & 51 deletions src/nova_json_schemas.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()}
Expand All @@ -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;
Expand All @@ -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(),
Expand All @@ -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 <niclas@burbas.se>">>],
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
Expand Down Expand Up @@ -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).
Loading
Loading