Skip to content

Commit ab8365b

Browse files
committed
feat: RFC 9457 error responses with configurable status codes
Add RFC 9457 Problem Details (application/problem+json) error responses for JSON schema validation failures. Configurable status codes, optional field grouping, and comprehensive error rendering. Add EUnit tests for all error rendering, validation, and edge cases.
1 parent ab3ca9e commit ab8365b

File tree

3 files changed

+747
-52
lines changed

3 files changed

+747
-52
lines changed

rebar.config

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
{jesse, "1.8.1"}
55
]}.
66

7+
{profiles, [
8+
{test, [
9+
{deps, [
10+
{meck, "0.9.2"}
11+
]}
12+
]}
13+
]}.
14+
715
{xref_ignores, [
816
{nova_json_schemas, init, 0},
917
{nova_json_schemas, load_local_schemas, 0},
@@ -43,6 +51,7 @@
4351
{files, [
4452
"rebar.config",
4553
"src/*.app.src",
46-
"src/**/{*.erl, *.hrl}"
54+
"src/**/{*.erl, *.hrl}",
55+
"test/**/{*.erl, *.hrl}"
4756
]}
4857
]}.

src/nova_json_schemas.erl

Lines changed: 224 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,25 @@
1111

1212
-include_lib("kernel/include/logger.hrl").
1313

14+
-ifdef(TEST).
15+
-export([
16+
render_errors/1,
17+
render_one_error/1,
18+
build_problem_details/3,
19+
group_errors_by_field/1,
20+
to_json_pointer/1,
21+
format_error_message/3,
22+
safe_format/1,
23+
safe_value/1,
24+
validate_json/3
25+
]).
26+
-endif.
27+
1428
init() ->
1529
jesse_database:load_all(),
1630
load_local_schemas(),
1731
#{}.
1832

19-
%%--------------------------------------------------------------------
20-
%% @doc
21-
%% Load all local JSON schemas from the main application's
22-
%% priv/schemas directory
23-
%% @end
24-
%%--------------------------------------------------------------------
2533
-spec load_local_schemas() -> ok.
2634
load_local_schemas() ->
2735
{ok, MainApp} = nova:get_main_app(),
@@ -35,11 +43,6 @@ load_local_schemas() ->
3543
end,
3644
ok.
3745

38-
%%--------------------------------------------------------------------
39-
%% @doc
40-
%% Pre-request callback
41-
%% @end
42-
%%--------------------------------------------------------------------
4346
-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
4447
{ok, Req0 :: cowboy_req:req(), NewState :: any()}
4548
| {stop, Req0 :: cowboy_req:req(), NewState :: any()}
@@ -57,22 +60,21 @@ pre_request(
5760
{ok, Req, State};
5861
{error, Errors} ->
5962
?LOG_DEBUG("Got validation-errors on JSON body. Errors: ~p", [Errors]),
63+
StatusCode = maps:get(status_code, Options, 422),
6064
case maps:get(render_errors, Options, false) of
6165
true ->
62-
?LOG_DEBUG("Rendering validation-errors and send back to requester"),
66+
GroupByField = maps:get(group_by_field, Options, false),
67+
ErrorList = render_errors(Errors),
68+
ErrorBody = build_problem_details(StatusCode, ErrorList, GroupByField),
69+
ErrorJson = json:encode(ErrorBody),
6370
Req0 = cowboy_req:set_resp_headers(
64-
#{<<"content-type">> => <<"application/json">>}, Req
71+
#{<<"content-type">> => <<"application/problem+json">>}, Req
6572
),
66-
ErrorStruct = render_error(Errors),
67-
ErrorJson = json:encode(ErrorStruct),
6873
Req1 = cowboy_req:set_resp_body(ErrorJson, Req0),
69-
Req2 = cowboy_req:reply(400, Req1),
74+
Req2 = cowboy_req:reply(StatusCode, Req1),
7075
{stop, Req2, State};
7176
_ ->
72-
?LOG_DEBUG(
73-
"render_errors-option not set for plugin nova_json_schemas - returning plain 400-status to requester"
74-
),
75-
Req0 = cowboy_req:reply(400, Req),
77+
Req0 = cowboy_req:reply(StatusCode, Req),
7678
{stop, Req0, State}
7779
end
7880
end;
@@ -84,21 +86,11 @@ pre_request(#{extra_state := #{json_schema := _SchemaLocation}}, _Env, _Options,
8486
pre_request(Req, _Env, _Options, State) ->
8587
{ok, Req, State}.
8688

87-
%%--------------------------------------------------------------------
88-
%% @doc
89-
%% Post-request callback
90-
%% @end
91-
%%--------------------------------------------------------------------
9289
-spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
9390
{ok, Req0 :: cowboy_req:req(), NewState :: any()}.
9491
post_request(Req, _Env, _Options, State) ->
9592
{ok, Req, State}.
9693

97-
%%--------------------------------------------------------------------
98-
%% @doc
99-
%% nova_plugin callback. Returns information about the plugin.
100-
%% @end
101-
%%--------------------------------------------------------------------
10294
-spec plugin_info() ->
10395
#{
10496
title := binary(),
@@ -111,46 +103,224 @@ post_request(Req, _Env, _Options, State) ->
111103
plugin_info() ->
112104
#{
113105
title => <<"Nova JSON Schema plugin">>,
114-
version => <<"0.2.0">>,
106+
version => <<"0.3.0">>,
115107
url => <<"https://github.com/novaframework/nova_json_schemas">>,
116108
authors => [<<"Niclas Axelsson <[email protected]>">>],
117109
description => <<"Validates JSON request bodies against JSON schemas using jesse">>,
118110
options => [
119-
{render_errors, <<"If true, validation errors are returned as JSON to the requester">>}
111+
{render_errors,
112+
<<"If true, validation errors are returned as RFC 9457 problem+json to the requester">>},
113+
{status_code, <<"HTTP status code for validation errors (default: 422)">>},
114+
{group_by_field,
115+
<<"If true, errors are grouped by field name in the response (default: false)">>}
120116
]
121117
}.
122118

119+
%%% Internal functions
120+
123121
validate_json(SchemaLocation, Json, JesseOpts) ->
124-
case jesse:validate(SchemaLocation, Json, JesseOpts) of
122+
Key = ensure_list(SchemaLocation),
123+
case jesse:validate(Key, Json, JesseOpts) of
125124
{error, {database_error, _, schema_not_found}} ->
126-
%% Load the schema
127125
{ok, MainApp} = nova:get_main_app(),
128126
PrivDir = code:priv_dir(MainApp),
129127
SchemaLocation0 = filename:join([PrivDir, SchemaLocation]),
130-
{ok, Filecontent} = file:read_file(SchemaLocation0),
131-
Schema = json:decode(Filecontent),
132-
jesse:add_schema(SchemaLocation, Schema),
133-
validate_json(SchemaLocation, Json, JesseOpts);
128+
case file:read_file(SchemaLocation0) of
129+
{ok, Filecontent} ->
130+
Schema = json:decode(Filecontent),
131+
jesse:add_schema(SchemaLocation, Schema),
132+
validate_json(SchemaLocation, Json, JesseOpts);
133+
{error, Reason} ->
134+
?LOG_ERROR("Failed to read schema file ~s: ~p", [SchemaLocation0, Reason]),
135+
{error, [{schema_invalid, SchemaLocation, {file_error, Reason}}]}
136+
end;
134137
{error, ValidationError} ->
135138
{error, ValidationError};
136139
{ok, _} ->
137140
ok
138141
end.
139142

140-
render_error([]) ->
143+
build_problem_details(StatusCode, ErrorList, true) ->
144+
#{
145+
type => <<"about:blank">>,
146+
title => <<"Validation Error">>,
147+
status => StatusCode,
148+
detail => <<"Request body failed JSON schema validation">>,
149+
errors => group_errors_by_field(ErrorList)
150+
};
151+
build_problem_details(StatusCode, ErrorList, false) ->
152+
#{
153+
type => <<"about:blank">>,
154+
title => <<"Validation Error">>,
155+
status => StatusCode,
156+
detail => <<"Request body failed JSON schema validation">>,
157+
errors => ErrorList
158+
}.
159+
160+
group_errors_by_field(Errors) ->
161+
lists:foldl(
162+
fun(#{path := Path, message := Msg}, Acc) ->
163+
Existing = maps:get(Path, Acc, []),
164+
Acc#{Path => Existing ++ [Msg]}
165+
end,
166+
#{},
167+
Errors
168+
).
169+
170+
render_errors([]) ->
141171
[];
142-
render_error([{data_invalid, FieldInfo, Type, ActualValue, Field} | Tl]) ->
143-
%% We don't do any fancy with this currently.
144-
[
145-
#{
146-
error_context => schema_violation,
147-
field_info => FieldInfo,
148-
error_type => Type,
149-
actual_value => ActualValue,
150-
expected_value => Field
151-
}
152-
| render_error(Tl)
153-
].
172+
render_errors([Error | Tl]) ->
173+
[render_one_error(Error) | render_errors(Tl)].
174+
175+
render_one_error({data_invalid, _Schema, {ErrorType, Details}, Value, Path}) ->
176+
#{
177+
path => to_json_pointer(Path),
178+
type => ErrorType,
179+
message => format_error_message(ErrorType, Details, Value),
180+
actual_value => safe_value(Value),
181+
expected => safe_value(Details)
182+
};
183+
render_one_error({data_invalid, Schema, wrong_type, Value, Path}) ->
184+
Expected = maps:get(<<"type">>, Schema, undefined),
185+
#{
186+
path => to_json_pointer(Path),
187+
type => wrong_type,
188+
message => format_error_message(wrong_type, Expected, Value),
189+
actual_value => safe_value(Value),
190+
expected => safe_value(Expected)
191+
};
192+
render_one_error({data_invalid, _Schema, missing_required_property, Property, Path}) ->
193+
#{
194+
path => to_json_pointer(Path),
195+
type => missing_required_property,
196+
message => format_error_message(missing_required_property, Property, undefined)
197+
};
198+
render_one_error({data_invalid, _Schema, ErrorType, Value, Path}) ->
199+
#{
200+
path => to_json_pointer(Path),
201+
type => ErrorType,
202+
message => format_error_message(ErrorType, undefined, Value),
203+
actual_value => safe_value(Value)
204+
};
205+
render_one_error({schema_invalid, _Schema, {ErrorType, Details}}) ->
206+
#{
207+
path => <<"">>,
208+
type => ErrorType,
209+
message => format_error_message(ErrorType, Details, undefined)
210+
};
211+
render_one_error({schema_invalid, _Schema, ErrorType}) ->
212+
#{
213+
path => <<"">>,
214+
type => ErrorType,
215+
message => format_error_message(ErrorType, undefined, undefined)
216+
};
217+
render_one_error({data_error, {parse_error, Details}}) ->
218+
#{
219+
path => <<"">>,
220+
type => parse_error,
221+
message => iolist_to_binary(io_lib:format("Parse error: ~p", [Details]))
222+
};
223+
render_one_error({schema_error, {parse_error, Details}}) ->
224+
#{
225+
path => <<"">>,
226+
type => schema_parse_error,
227+
message => iolist_to_binary(io_lib:format("Schema parse error: ~p", [Details]))
228+
};
229+
render_one_error(Unknown) ->
230+
?LOG_WARNING("Unhandled validation error format: ~p", [Unknown]),
231+
#{
232+
path => <<"">>,
233+
type => unknown_error,
234+
message => iolist_to_binary(io_lib:format("~p", [Unknown]))
235+
}.
236+
237+
to_json_pointer([]) ->
238+
<<"/">>;
239+
to_json_pointer(Path) when is_list(Path) ->
240+
Segments = [escape_json_pointer(segment_to_binary(S)) || S <- Path],
241+
iolist_to_binary([<<"/">>, lists:join(<<"/">>, Segments)]).
242+
243+
segment_to_binary(S) when is_binary(S) -> S;
244+
segment_to_binary(S) when is_integer(S) -> integer_to_binary(S);
245+
segment_to_binary(S) when is_atom(S) -> atom_to_binary(S);
246+
segment_to_binary(S) -> iolist_to_binary(io_lib:format("~p", [S])).
247+
248+
escape_json_pointer(Bin) ->
249+
binary:replace(binary:replace(Bin, <<"~">>, <<"~0">>, [global]), <<"/">>, <<"~1">>, [global]).
250+
251+
format_error_message(wrong_type, Expected, _Value) ->
252+
iolist_to_binary(io_lib:format("Must be of type ~s", [safe_format(Expected)]));
253+
format_error_message(not_in_enum, _Expected, _Value) ->
254+
<<"Value is not in the allowed set of values">>;
255+
format_error_message(not_in_range, Expected, _Value) ->
256+
iolist_to_binary(
257+
io_lib:format("Value is not in the allowed range ~s", [safe_format(Expected)])
258+
);
259+
format_error_message(missing_required_property, Property, _Value) ->
260+
iolist_to_binary(io_lib:format("Missing required property: ~s", [safe_format(Property)]));
261+
format_error_message(no_extra_properties_allowed, _Expected, _Value) ->
262+
<<"Additional properties are not allowed">>;
263+
format_error_message(no_extra_items_allowed, _Expected, _Value) ->
264+
<<"Additional items are not allowed">>;
265+
format_error_message(not_allowed, _Expected, _Value) ->
266+
<<"Value is not allowed">>;
267+
format_error_message(not_unique, _Expected, _Value) ->
268+
<<"Array items are not unique">>;
269+
format_error_message(wrong_size, Expected, _Value) ->
270+
iolist_to_binary(io_lib:format("Array size is invalid, expected ~s", [safe_format(Expected)]));
271+
format_error_message(wrong_length, Expected, _Value) ->
272+
iolist_to_binary(
273+
io_lib:format("String length is invalid, expected ~s", [safe_format(Expected)])
274+
);
275+
format_error_message(wrong_format, Expected, _Value) ->
276+
iolist_to_binary(io_lib:format("Value does not match format: ~s", [safe_format(Expected)]));
277+
format_error_message(not_divisible, Expected, _Value) ->
278+
iolist_to_binary(io_lib:format("Value is not divisible by ~s", [safe_format(Expected)]));
279+
format_error_message(not_multiple_of, Expected, _Value) ->
280+
iolist_to_binary(io_lib:format("Value is not a multiple of ~s", [safe_format(Expected)]));
281+
format_error_message(no_match, _Expected, _Value) ->
282+
<<"Value does not match the required pattern">>;
283+
format_error_message(all_schemas_not_valid, _Expected, _Value) ->
284+
<<"Value does not match all of the required schemas">>;
285+
format_error_message(any_schemas_not_valid, _Expected, _Value) ->
286+
<<"Value does not match any of the required schemas">>;
287+
format_error_message(not_one_schema_valid, _Expected, _Value) ->
288+
<<"Value does not match exactly one of the required schemas">>;
289+
format_error_message(more_than_one_schema_valid, _Expected, _Value) ->
290+
<<"Value matches more than one of the required schemas">>;
291+
format_error_message(not_schema_valid, _Expected, _Value) ->
292+
<<"Value must not match the schema">>;
293+
format_error_message(too_many_properties, Expected, _Value) ->
294+
iolist_to_binary(
295+
io_lib:format("Too many properties, maximum allowed: ~s", [safe_format(Expected)])
296+
);
297+
format_error_message(too_few_properties, Expected, _Value) ->
298+
iolist_to_binary(
299+
io_lib:format("Too few properties, minimum required: ~s", [safe_format(Expected)])
300+
);
301+
format_error_message(missing_dependency, Expected, _Value) ->
302+
iolist_to_binary(io_lib:format("Missing dependency: ~s", [safe_format(Expected)]));
303+
format_error_message(file_error, Reason, _Value) ->
304+
iolist_to_binary(io_lib:format("Schema file error: ~p", [Reason]));
305+
format_error_message(Type, undefined, _Value) ->
306+
iolist_to_binary(io_lib:format("Validation failed: ~s", [Type]));
307+
format_error_message(Type, Expected, _Value) ->
308+
iolist_to_binary(io_lib:format("Validation failed (~s): ~s", [Type, safe_format(Expected)])).
309+
310+
safe_format(undefined) -> <<"">>;
311+
safe_format(V) when is_binary(V) -> V;
312+
safe_format(V) when is_atom(V) -> atom_to_binary(V);
313+
safe_format(V) when is_integer(V) -> integer_to_binary(V);
314+
safe_format(V) when is_float(V) -> float_to_binary(V, [{decimals, 10}, compact]);
315+
safe_format(V) -> iolist_to_binary(io_lib:format("~p", [V])).
316+
317+
safe_value(V) when is_map(V) -> V;
318+
safe_value(V) when is_list(V) -> V;
319+
safe_value(V) when is_binary(V) -> V;
320+
safe_value(V) when is_number(V) -> V;
321+
safe_value(V) when is_boolean(V) -> V;
322+
safe_value(null) -> null;
323+
safe_value(V) -> iolist_to_binary(io_lib:format("~p", [V])).
154324

155325
load_schemas_from_dir(Dir, RelativePrefix) ->
156326
case file:list_dir(Dir) of
@@ -191,3 +361,6 @@ load_schema_file(FilePath, RelativePath) ->
191361
{error, Reason} ->
192362
?LOG_ERROR("Failed to read schema file ~s: ~p", [FilePath, Reason])
193363
end.
364+
365+
ensure_list(V) when is_list(V) -> V;
366+
ensure_list(V) when is_binary(V) -> binary_to_list(V).

0 commit comments

Comments
 (0)