diff --git a/README.md b/README.md index b64de2e..5b451a7 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ julia> validate(my_schema, data_pass) ``` -If the validation fails, a struct is returned that, when printed, explains the -reason for the failure: +By default, if the validation fails, a struct is returned that, when printed, +explains the reason for the failure: ```julia julia> data_fail = Dict("bar" => 12.5) Dict{String,Float64} with 1 entry: @@ -74,6 +74,19 @@ schema key: required schema value: ["foo"] ``` +Pass `fail_fast = false` to collect every validation issue in one pass. In this +mode, `validate` returns a vector of issue structs, or an empty vector when the +instance is valid: +```julia +julia> issues = validate(my_schema, data_fail; fail_fast = false) +1-element Vector{JSONSchema.SingleIssue}: + Validation failed: +path: top-level +instance: Dict("bar"=>12.5) +schema key: required +schema value: ["foo"] +``` + As a short-hand for `validate(schema, x) === nothing`, use `Base.isvalid(schema, x)` diff --git a/src/validation.jl b/src/validation.jl index a4466f4..2e49fe1 100644 --- a/src/validation.jl +++ b/src/validation.jl @@ -22,11 +22,15 @@ schema value: $(issue.val)""", end """ - validate(s::Schema, x) + validate(s::Schema, x; fail_fast::Bool = true) -Validate the object `x` against the Schema `s`. If valid, return `nothing`, else -return a `SingleIssue`. When printed, the returned `SingleIssue` describes the -reason why the validation failed. +Validate the object `x` against the Schema `s`. By default, if valid, return +`nothing`, else return a `SingleIssue`. When printed, the returned `SingleIssue` +describes the reason why the validation failed. + +Set `fail_fast = false` to collect all validation issues. In this mode, +`validate` returns a `Vector{SingleIssue}`. The vector is empty if validation +succeeds. Note that if `x` is a `String` in JSON format, you must use `JSON.parse(x)` @@ -65,14 +69,21 @@ schema key: required schema value: ["foo"] ``` """ -function validate(schema::Schema, x) +function validate(schema::Schema, x; fail_fast::Bool = true) + if !fail_fast + issues = SingleIssue[] + _validate!(issues, x, schema.data, "") + return issues + end return _validate(x, schema.data, "") end Base.isvalid(schema::Schema, x) = validate(schema, x) === nothing # Fallbacks for the opposite argument. -validate(x, schema::Schema) = validate(schema, x) +function validate(x, schema::Schema; fail_fast::Bool = true) + return validate(schema, x; fail_fast) +end Base.isvalid(x, schema::Schema) = isvalid(schema, x) function _validate(x, schema, path::String) @@ -97,6 +108,45 @@ function _validate_entry(x, schema::Bool, path::String) return end +function _validate!(issues::Vector{SingleIssue}, x, schema, path::String) + schema = _resolve_refs(schema) + return _validate_entry!(issues, x, schema, path) +end + +function _validate_entry!( + issues::Vector{SingleIssue}, + x, + schema::AbstractDict, + path::String, +) + for (k, v) in schema + if Symbol(k) in (:then, :else) + continue # Handled by the `if` keyword. + end + _validate!(issues, x, schema, Val{Symbol(k)}(), v, path) + end + return issues +end + +function _validate_entry!( + issues::Vector{SingleIssue}, + x, + schema::Bool, + path::String, +) + if !schema + push!(issues, SingleIssue(x, path, "schema", schema)) + end + return issues +end + +function _record!(issues::Vector{SingleIssue}, issue::SingleIssue) + push!(issues, issue) + return issues +end + +_record!(issues::Vector{SingleIssue}, ::Nothing) = issues + function _resolve_refs(schema::AbstractDict, explored_refs = Any[schema]) if !haskey(schema, "\$ref") return schema @@ -113,6 +163,17 @@ _resolve_refs(schema, explored_refs = Any[]) = schema # Default fallback _validate(::Any, ::Any, ::Val, ::Any, ::String) = nothing +function _validate!( + issues::Vector{SingleIssue}, + x, + schema, + key::Val, + val, + path::String, +) + return _record!(issues, _validate(x, schema, key, val, path)) +end + # JSON treats == between Bool and Number differently to Julia, so: # false != 0 # true != 1 @@ -150,6 +211,20 @@ function _validate(x, schema, ::Val{:allOf}, val::AbstractVector, path::String) return end +function _validate!( + issues::Vector{SingleIssue}, + x, + schema, + ::Val{:allOf}, + val::AbstractVector, + path::String, +) + for v in val + _validate!(issues, x, v, path) + end + return issues +end + # 9.2.1.2 function _validate(x, schema, ::Val{:anyOf}, val::AbstractVector, path::String) for v in val @@ -194,6 +269,21 @@ function _validate(x, schema, ::Val{:if}, val, path::String) return end +function _validate!( + issues::Vector{SingleIssue}, + x, + schema, + ::Val{:if}, + val, + path::String, +) + # ignore if without then or else + if haskey(schema, "then") || haskey(schema, "else") + return _if_then_else!(issues, x, schema, path) + end + return issues +end + # 9.2.2.2: then function _validate(x, schema, ::Val{:then}, val, path::String) # ignore then without if @@ -203,6 +293,17 @@ function _validate(x, schema, ::Val{:then}, val, path::String) return end +function _validate!( + issues::Vector{SingleIssue}, + x, + schema, + ::Val{:then}, + val, + path::String, +) + return issues # Handled by the `if` keyword. +end + # 9.2.2.3: else function _validate(x, schema, ::Val{:else}, val, path::String) # ignore else without if @@ -212,6 +313,17 @@ function _validate(x, schema, ::Val{:else}, val, path::String) return end +function _validate!( + issues::Vector{SingleIssue}, + x, + schema, + ::Val{:else}, + val, + path::String, +) + return issues # Handled by the `if` keyword. +end + """ _if_then_else(x, schema, path) @@ -245,6 +357,17 @@ function _if_then_else(x, schema, path) return end +function _if_then_else!(issues::Vector{SingleIssue}, x, schema, path) + if _validate(x, schema["if"], path) !== nothing + if haskey(schema, "else") + _validate!(issues, x, schema["else"], path) + end + elseif haskey(schema, "then") + _validate!(issues, x, schema["then"], path) + end + return issues +end + ### ### Checks for Arrays. ### @@ -269,6 +392,23 @@ function _validate( return _additional_items(x, schema, items, additionalItems, path) end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractVector, + schema, + ::Val{:items}, + val::AbstractDict, + path::String, +) + items = fill(false, length(x)) + for (i, xi) in enumerate(x) + _validate!(issues, xi, val, path * "[$(i)]") + items[i] = true + end + additionalItems = get(schema, "additionalItems", nothing) + return _additional_items!(issues, x, schema, items, additionalItems, path) +end + function _validate( x::AbstractVector, schema, @@ -291,6 +431,26 @@ function _validate( return _additional_items(x, schema, items, additionalItems, path) end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractVector, + schema, + ::Val{:items}, + val::AbstractVector, + path::String, +) + items = fill(false, length(x)) + for (i, xi) in enumerate(x) + if i > length(val) + break + end + _validate!(issues, xi, val[i], path * "[$(i)]") + items[i] = true + end + additionalItems = get(schema, "additionalItems", nothing) + return _additional_items!(issues, x, schema, items, additionalItems, path) +end + function _validate( x::AbstractVector, schema, @@ -326,6 +486,48 @@ end _additional_items(x, schema, items, val::Nothing, path) = nothing +function _additional_items!( + issues::Vector{SingleIssue}, + x, + schema, + items, + val, + path, +) + for i in 1:length(x) + if items[i] + continue # Validated against 'items'. + end + _validate!(issues, x[i], val, path * "[$(i)]") + end + return issues +end + +function _additional_items!( + issues::Vector{SingleIssue}, + x, + schema, + items, + val::Bool, + path, +) + if !val && !all(items) + push!(issues, SingleIssue(x, path, "additionalItems", val)) + end + return issues +end + +function _additional_items!( + issues::Vector{SingleIssue}, + x, + schema, + items, + val::Nothing, + path, +) + return issues +end + # 9.3.1.2 function _validate( x::AbstractVector, @@ -379,6 +581,22 @@ function _validate( return end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractDict, + schema, + ::Val{:properties}, + val::AbstractDict, + path::String, +) + for (k, v) in x + if haskey(val, k) + _validate!(issues, v, val[k], path * "[$(k)]") + end + end + return issues +end + # 9.3.2.2 function _validate( x::AbstractDict, @@ -393,7 +611,7 @@ function _validate( if match(r, k_x) === nothing continue end - ret = _validate(v_x, v_val, path * "[$(k_x)") + ret = _validate(v_x, v_val, path * "[$(k_x)]") if ret !== nothing return ret end @@ -402,6 +620,25 @@ function _validate( return end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractDict, + schema, + ::Val{:patternProperties}, + val::AbstractDict, + path::String, +) + for (k_val, v_val) in val + r = Regex(k_val) + for (k_x, v_x) in x + if match(r, k_x) !== nothing + _validate!(issues, v_x, v_val, path * "[$(k_x)]") + end + end + end + return issues +end + # 9.3.2.3 function _validate( x::AbstractDict, @@ -425,6 +662,26 @@ function _validate( return end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractDict, + schema, + ::Val{:additionalProperties}, + val::AbstractDict, + path::String, +) + properties = get(schema, "properties", Dict{String,Any}()) + patternProperties = get(schema, "patternProperties", Dict{String,Any}()) + for (k, v) in x + if k in keys(properties) || + any(r -> match(Regex(r), k) !== nothing, keys(patternProperties)) + continue + end + _validate!(issues, v, val, path * "[$(k)]") + end + return issues +end + function _validate( x::AbstractDict, schema, @@ -466,6 +723,20 @@ function _validate( return end +function _validate!( + issues::Vector{SingleIssue}, + x::AbstractDict, + schema, + ::Val{:propertyNames}, + val, + path::String, +) + for k in keys(x) + _validate!(issues, k, val, path) + end + return issues +end + ### ### Checks for generic types. ### diff --git a/test/runtests.jl b/test/runtests.jl index 41ae5d1..53f18b7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -210,6 +210,66 @@ end @test JSONSchema.diagnose(data_fail, schema) == fail_msg end +@testset "Validate all issues" begin + schema = JSONSchema.Schema( + Dict( + "properties" => Dict( + "Title" => Dict("type" => "string"), + "Desc" => Dict("type" => "string"), + ), + ), + ) + data = Dict("Title" => nothing, "Desc" => 15) + + @test JSONSchema.validate(schema, data) isa JSONSchema.SingleIssue + issues = JSONSchema.validate(schema, data; fail_fast = false) + @test issues isa Vector{JSONSchema.SingleIssue} + @test Set(issue.path for issue in issues) == Set(["[Title]", "[Desc]"]) + @test all(issue.reason == "type" for issue in issues) + + @test JSONSchema.validate(data, schema; fail_fast = false) == issues + @test isempty( + JSONSchema.validate(schema, Dict("Title" => "ok"); fail_fast = false), + ) + + allof_schema = JSONSchema.Schema( + Dict( + "allOf" => [ + Dict("properties" => + Dict("name" => Dict("type" => "string"))), + Dict("properties" => Dict("count" => Dict("minimum" => 2))), + ], + ), + ) + issues = JSONSchema.validate( + allof_schema, + Dict("name" => 1, "count" => 1); + fail_fast = false, + ) + @test Set((issue.path, issue.reason) for issue in issues) == + Set([("[name]", "type"), ("[count]", "minimum")]) + + conditional_schema = JSONSchema.Schema( + Dict( + "if" => Dict( + "properties" => Dict("kind" => Dict("const" => "full")), + ), + "then" => Dict( + "properties" => Dict( + "name" => Dict("type" => "string"), + "count" => Dict("type" => "integer"), + ), + ), + ), + ) + issues = JSONSchema.validate( + conditional_schema, + Dict("kind" => "full", "name" => 1, "count" => "many"); + fail_fast = false, + ) + @test Set(issue.path for issue in issues) == Set(["[name]", "[count]"]) +end + @testset "parentFileDirectory deprecation" begin schema = JSONSchema.Schema("{}"; parentFileDirectory = ".") @test typeof(schema) == Schema