Skip to content

Commit 3a72454

Browse files
committed
Fully support location-independent identifiers and refs
1 parent e83884d commit 3a72454

10 files changed

Lines changed: 150 additions & 121 deletions

.formatter.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[
2-
line_length: 120,
3-
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
2+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
3+
line_length: 120
44
]

config/test.exs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ end
66

77
config :ex_json_schema,
88
decode_json: fn json -> Jason.decode(json) end,
9-
remote_schema_resolver: fn url -> HTTPoison.get!(url).body |> Jason.decode!() end,
9+
remote_schema_resolver: fn url ->
10+
case HTTPoison.get!(url) do
11+
%{status_code: 200, body: body} -> body |> Jason.decode!()
12+
%{status_code: 404} -> raise "Remote schema not found at #{url}"
13+
end
14+
end,
1015
custom_format_validator: {CustomFormatValidator, :validate}
1116

1217
config :ex_json_schema, SamplePhoenix.Endpoint,

lib/ex_json_schema/schema.ex

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,23 @@ defmodule ExJsonSchema.Schema do
2626
alias ExJsonSchema.Validator
2727

2828
@type ref_path :: [:root | String.t()]
29-
@type resolved ::
30-
ExJsonSchema.data()
31-
| %{String.t() => (Root.t() -> {Root.t(), resolved}) | ref_path}
32-
| true
33-
| false
29+
@type resolved :: ExJsonSchema.data() | %{String.t() => (Root.t() -> {Root.t(), resolved}) | ref_path} | true | false
3430
@type invalid_reference_error :: {:error, :invalid_reference}
3531

3632
@current_draft_schema_url "http://json-schema.org/schema"
3733
@draft4_schema_url "http://json-schema.org/draft-04/schema"
3834
@draft6_schema_url "http://json-schema.org/draft-06/schema"
3935
@draft7_schema_url "http://json-schema.org/draft-07/schema"
4036

37+
@ignored_properties ["const", "default", "enum", "examples"]
38+
4139
@spec decode_json(String.t()) :: {:ok, String.t()} | {:error, String.t()}
4240
def decode_json(json) do
43-
decoder =
44-
Application.get_env(:ex_json_schema, :decode_json) ||
45-
fn _json -> raise MissingJsonDecoderError end
46-
41+
decoder = Application.get_env(:ex_json_schema, :decode_json) || fn _json -> raise MissingJsonDecoderError end
4742
decoder.(json)
4843
end
4944

50-
@spec resolve(boolean | Root.t() | ExJsonSchema.object(),
51-
custom_format_validator: {module(), atom()}
52-
) ::
45+
@spec resolve(boolean | Root.t() | ExJsonSchema.object(), custom_format_validator: {module(), atom()}) ::
5346
Root.t() | no_return
5447
def resolve(schema, options \\ [])
5548

@@ -75,25 +68,31 @@ defmodule ExJsonSchema.Schema do
7568
end
7669
end
7770

78-
def get_fragment(root = %Root{}, [:root | path] = ref) do
79-
do_get_fragment(root.schema, path, ref)
71+
def get_fragment(%Root{schema: schema, refs: refs}, [:root | path] = ref) do
72+
case Map.get(refs, ref_to_string(ref)) do
73+
nil -> do_get_fragment(schema, path, ref)
74+
schema -> {:ok, schema}
75+
end
8076
end
8177

82-
def get_fragment(root = %Root{}, [url | path] = ref) when is_binary(url) do
83-
do_get_fragment(root.refs[url], path, ref)
78+
def get_fragment(%Root{refs: refs}, [url | path] = ref) when is_binary(url) do
79+
case Map.get(refs, ref_to_string(ref)) do
80+
nil -> do_get_fragment(refs[url], path, ref)
81+
schema -> {:ok, schema}
82+
end
8483
end
8584

8685
@spec get_fragment!(Root.t(), ref_path | ExJsonSchema.json_path()) :: resolved | no_return
87-
def get_fragment!(schema, ref) do
88-
case get_fragment(schema, ref) do
86+
def get_fragment!(root, ref) do
87+
case get_fragment(root, ref) do
8988
{:ok, schema} -> schema
9089
{:error, :invalid_reference} -> raise_invalid_reference_error(ref)
9190
end
9291
end
9392

9493
@spec get_ref_schema(Root.t(), [:root | String.t()]) :: ExJsonSchema.data() | no_return
95-
def get_ref_schema(root = %Root{}, [:root | path] = ref) do
96-
case get_ref_schema_with_schema(root.schema, path, ref) do
94+
def get_ref_schema(%Root{schema: schema}, [:root | path] = ref) do
95+
case get_ref_schema_with_schema(schema, path, ref) do
9796
{:error, error} ->
9897
raise InvalidSchemaError, message: error
9998

@@ -102,8 +101,8 @@ defmodule ExJsonSchema.Schema do
102101
end
103102
end
104103

105-
def get_ref_schema(root = %Root{}, [url | path] = ref) when is_binary(url) do
106-
case get_ref_schema_with_schema(root.refs[url], path, ref) do
104+
def get_ref_schema(%Root{refs: refs}, [url | path] = ref) when is_binary(url) do
105+
case get_ref_schema_with_schema(refs[url], path, ref) do
107106
{:error, error} ->
108107
raise InvalidSchemaError, message: error
109108

@@ -128,9 +127,42 @@ defmodule ExJsonSchema.Schema do
128127
message: "schema did not pass validation against its meta-schema: #{inspect(errors)}"
129128
end
130129

130+
root = %Root{root | version: schema_version}
131131
{root, schema} = resolve_with_root(root, root_schema)
132132

133-
%Root{root | schema: schema, version: schema_version}
133+
%Root{root | schema: schema}
134+
|> resolve_refs(schema)
135+
end
136+
137+
defp resolve_refs(%Root{} = root, schema) when is_map(schema) do
138+
schema
139+
|> Enum.reduce(root, fn
140+
{"$ref", [:root | _]}, root ->
141+
root
142+
143+
{"$ref", ref}, root when is_list(ref) ->
144+
if local_ref?(root, ref) do
145+
root
146+
else
147+
resolve_and_cache_remote_schema(root, ref)
148+
end
149+
150+
{_, value}, root when is_map(value) ->
151+
resolve_refs(root, value)
152+
153+
{_, values}, root when is_list(values) ->
154+
values
155+
|> Enum.reduce(root, fn value, root -> resolve_refs(root, value) end)
156+
157+
_, root ->
158+
root
159+
end)
160+
end
161+
162+
defp resolve_refs(%Root{} = root, _), do: root
163+
164+
defp local_ref?(%Root{refs: refs}, [url | _] = ref) do
165+
Map.has_key?(refs, url) || Map.has_key?(refs, ref_to_string(ref))
134166
end
135167

136168
defp schema_version!(schema_url) do
@@ -165,25 +197,30 @@ defmodule ExJsonSchema.Schema do
165197

166198
defp resolve_with_root(root, schema, scope \\ "")
167199

200+
defp resolve_with_root(root, %{"$ref" => ref}, scope) when is_binary(ref) do
201+
do_resolve(root, %{"$ref" => ref}, scope)
202+
end
203+
168204
defp resolve_with_root(root, schema = %{"$id" => id}, scope) when is_binary(id) do
169-
resolve_id(root, schema, scope, id)
205+
resolve_with_id(root, schema, scope, id)
170206
end
171207

172208
defp resolve_with_root(root, schema = %{"id" => id}, scope) when is_binary(id) do
173-
resolve_id(root, schema, scope, id)
209+
resolve_with_id(root, schema, scope, id)
174210
end
175211

176212
defp resolve_with_root(root, schema = %{}, scope), do: do_resolve(root, schema, scope)
177213
defp resolve_with_root(root, non_schema, _scope), do: {root, non_schema}
178214

179-
defp resolve_id(root, schema, scope, id) do
215+
defp resolve_with_id(root, schema, scope, id) do
180216
scope =
181217
case URI.parse(scope) do
182218
%URI{host: nil} -> id
183219
uri -> uri |> URI.merge(id) |> to_string()
184220
end
185221

186-
do_resolve(root, schema, scope)
222+
{root, schema} = do_resolve(root, schema, scope)
223+
{root_with_ref(root, scope, schema), schema}
187224
end
188225

189226
defp do_resolve(root, schema, scope) do
@@ -196,12 +233,12 @@ defmodule ExJsonSchema.Schema do
196233
{root, schema |> sanitize_attributes()}
197234
end
198235

199-
defp resolve_property(root, {key, value}, scope) when is_map(value) do
236+
defp resolve_property(root, {key, value}, scope) when is_map(value) and key not in @ignored_properties do
200237
{root, resolved} = resolve_with_root(root, value, scope)
201238
{root, {key, resolved}}
202239
end
203240

204-
defp resolve_property(root, {key, values}, scope) when is_list(values) do
241+
defp resolve_property(root, {key, values}, scope) when is_list(values) and key not in @ignored_properties do
205242
{root, values} =
206243
Enum.reduce(values, {root, []}, fn value, {root, values} ->
207244
{root, resolved} = resolve_with_root(root, value, scope)
@@ -211,19 +248,13 @@ defmodule ExJsonSchema.Schema do
211248
{root, {key, Enum.reverse(values)}}
212249
end
213250

214-
defp resolve_property(root, {"$ref", ref}, scope) do
251+
defp resolve_property(root, {"$ref", ref}, scope) when is_binary(ref) do
252+
ref_uri = URI.parse(ref)
253+
215254
scoped_ref =
216-
case URI.parse(ref) do
217-
# TODO: this special case is only needed until there is proper support for URL references
218-
# that point to a local schema (via scope changes)
219-
%URI{host: nil, path: nil} = uri ->
220-
to_string(uri)
221-
222-
ref_uri ->
223-
case URI.parse(scope) do
224-
%URI{host: nil} -> ref
225-
scope_uri -> URI.merge(scope_uri, ref_uri) |> to_string()
226-
end
255+
case URI.parse(scope) do
256+
%URI{host: nil} -> ref
257+
scope_uri -> URI.merge(scope_uri, ref_uri) |> to_string()
227258
end
228259

229260
{root, path} = resolve_ref!(root, scoped_ref)
@@ -232,19 +263,20 @@ defmodule ExJsonSchema.Schema do
232263

233264
defp resolve_property(root, tuple, _) when is_tuple(tuple), do: {root, tuple}
234265

235-
defp resolve_ref(root, "#") do
236-
{:ok, {root, [root.location]}}
266+
defp resolve_ref(%Root{location: location} = root, "#") do
267+
{:ok, {root, [location]}}
237268
end
238269

239270
defp resolve_ref(root, ref) do
240271
[url | anchor] = String.split(ref, "#")
241272
ref_path = validate_ref_path(anchor, ref)
242273
{root, path} = root_and_path_for_url(root, ref_path, url)
243274

244-
case get_fragment(root, path) do
245-
{:ok, _schema} -> {:ok, {root, path}}
246-
error -> error
247-
end
275+
{:ok, {root, path}}
276+
# case get_fragment(root, path) do
277+
# {:ok, _schema} -> {:ok, {root, path}}
278+
# error -> error
279+
# end
248280
end
249281

250282
defp resolve_ref!(root, ref) do
@@ -256,23 +288,23 @@ defmodule ExJsonSchema.Schema do
256288

257289
defp validate_ref_path([], _), do: nil
258290
defp validate_ref_path([""], _), do: nil
259-
defp validate_ref_path([fragment = "/" <> _], _), do: fragment
291+
defp validate_ref_path([fragment], _) when is_binary(fragment), do: fragment
260292
defp validate_ref_path(_, ref), do: raise_invalid_reference_error(ref)
261293

262-
defp root_and_path_for_url(root, fragment, "") do
263-
{root, [root.location | relative_path(fragment)]}
294+
defp root_and_path_for_url(%Root{location: location} = root, fragment, "") do
295+
{root, [location | relative_path(fragment)]}
264296
end
265297

266298
defp root_and_path_for_url(root, fragment, url) do
267-
root = resolve_and_cache_remote_schema(root, url)
299+
# root = resolve_and_cache_remote_schema(root, url)
268300
{root, [url | relative_path(fragment)]}
269301
end
270302

271303
defp relative_path(nil), do: []
272304
defp relative_path(fragment), do: relative_ref_path(fragment)
273305

274306
defp relative_ref_path(ref) do
275-
["" | keys] = unescaped_ref_segments(ref)
307+
keys = unescaped_ref_segments(ref)
276308

277309
Enum.map(keys, fn key ->
278310
case key =~ ~r/^\d+$/ do
@@ -285,13 +317,9 @@ defmodule ExJsonSchema.Schema do
285317
end)
286318
end
287319

288-
defp resolve_and_cache_remote_schema(root, url) do
289-
if root.refs[url] do
290-
root
291-
else
292-
remote_schema = remote_schema(url)
293-
resolve_remote_schema(root, url, remote_schema)
294-
end
320+
defp resolve_and_cache_remote_schema(root, [url | _]) do
321+
remote_schema = remote_schema(url)
322+
resolve_remote_schema(root, url, remote_schema)
295323
end
296324

297325
@spec remote_schema(String.t()) :: ExJsonSchema.object()
@@ -303,13 +331,14 @@ defmodule ExJsonSchema.Schema do
303331

304332
defp resolve_remote_schema(root, url, remote_schema) do
305333
root = root_with_ref(root, url, remote_schema)
306-
resolved_root = resolve_root(%{root | schema: remote_schema, location: url})
307-
root = %{root | refs: resolved_root.refs}
308-
root_with_ref(root, url, resolved_root.schema)
334+
%Root{schema: schema, refs: refs} = resolve_root(%Root{root | schema: remote_schema, location: url})
335+
336+
%Root{root | refs: refs}
337+
|> root_with_ref(url, schema)
309338
end
310339

311-
defp root_with_ref(root, url, ref) do
312-
%{root | refs: Map.put(root.refs, url, ref)}
340+
defp root_with_ref(%Root{refs: refs} = root, url, ref) do
341+
%{root | refs: Map.put(refs, url, ref)}
313342
end
314343

315344
defp fetch_remote_schema(url) do
@@ -362,6 +391,7 @@ defmodule ExJsonSchema.Schema do
362391
defp unescaped_ref_segments(ref) do
363392
ref
364393
|> String.split("/")
394+
|> Enum.reject(&(&1 == ""))
365395
|> Enum.map(fn segment ->
366396
segment
367397
|> String.replace("~0", "~")
@@ -377,8 +407,9 @@ defmodule ExJsonSchema.Schema do
377407
defp do_get_fragment(nil, _, _ref), do: {:error, :invalid_reference}
378408
defp do_get_fragment(schema, [], _), do: {:ok, schema}
379409

380-
defp do_get_fragment(schema, [key | path], ref) when is_binary(key),
381-
do: do_get_fragment(Map.get(schema, key), path, ref)
410+
defp do_get_fragment(schema, [key | path], ref) when is_binary(key) do
411+
do_get_fragment(Map.get(schema, key), path, ref)
412+
end
382413

383414
defp do_get_fragment(schema, [idx | path], ref) when is_integer(idx) do
384415
try do
@@ -408,8 +439,8 @@ defmodule ExJsonSchema.Schema do
408439
|> get_ref_schema_with_schema(path, ref)
409440
end
410441

411-
defp ref_to_string([:root | path]), do: ["#" | path] |> Enum.join("/")
412-
defp ref_to_string([url | path]), do: [url <> "#" | path] |> Enum.join("/")
442+
defp ref_to_string([:root | path]), do: "##{path}"
443+
defp ref_to_string([url | path]), do: url <> "#" <> Enum.join(path, "/")
413444

414445
@spec raise_invalid_reference_error(any) :: no_return
415446
def raise_invalid_reference_error(ref) when is_binary(ref),

lib/ex_json_schema/schema/root.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
defmodule ExJsonSchema.Schema.Root do
22
defstruct schema: %{},
33
refs: %{},
4-
definitions: %{},
54
location: :root,
65
version: nil,
76
custom_format_validator: nil
@@ -10,7 +9,6 @@ defmodule ExJsonSchema.Schema.Root do
109
schema: ExJsonSchema.Schema.resolved(),
1110
refs: %{String.t() => ExJsonSchema.Schema.resolved()},
1211
location: :root | String.t(),
13-
definitions: %{String.t() => ExJsonSchema.Schema.resolved()},
1412
version: non_neg_integer | nil,
1513
custom_format_validator: {module(), atom()} | (String.t(), any() -> boolean | {:error, any()}) | nil
1614
}

lib/ex_json_schema/validator/multiple_of.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ defmodule ExJsonSchema.Validator.MultipleOf do
2727
cond do
2828
dec_multiple_of == @zero -> [%Error{error: %Error.MultipleOf{expected: 0}}]
2929
dec_data == @zero -> []
30-
Decimal.integer?(Decimal.div(dec_data, dec_multiple_of)) -> []
30+
Decimal.equal?(Decimal.rem(dec_data, dec_multiple_of), Decimal.new(0)) -> []
3131
true -> [%Error{error: %Error.MultipleOf{expected: multiple_of}}]
3232
end
33+
rescue
34+
Decimal.Error -> [%Error{error: %Error.MultipleOf{expected: multiple_of}}]
3335
end
3436

3537
defp do_validate(_, _) do

test/JSON-Schema-Test-Suite

0 commit comments

Comments
 (0)