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+
1428init () ->
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 .
2634load_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,
8486pre_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 ()}.
9491post_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) ->
111103plugin_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+
123121validate_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
155325load_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