diff --git a/docs/src/models.md b/docs/src/models.md index ce5a03d..79c1644 100644 --- a/docs/src/models.md +++ b/docs/src/models.md @@ -8,7 +8,9 @@ Implemented model behavior includes: - objects, arrays, tuples, dictionaries, primitives, enums, and nullable types; - required, optional, and explicit-null values; -- `allOf`, `oneOf`, `anyOf`, and discriminators; +- `allOf`, `oneOf`, `anyOf`, and discriminators, whose `mapping` and + `defaultMapping` values may be same-document, relative, or cross-file URI + references resolved like `$ref`; - recursive models and recursive aliases; - `additionalProperties`, `patternProperties`, `propertyNames`, and closed objects; diff --git a/docs/src/pipeline.md b/docs/src/pipeline.md index 6413238..c9e43d1 100644 --- a/docs/src/pipeline.md +++ b/docs/src/pipeline.md @@ -123,6 +123,10 @@ The default retriever has conservative access rules: - HTTP redirects are not followed. - Unsupported URI schemes are rejected. +Discriminator `mapping` and `defaultMapping` URI references are retrieved under +the same rules as `$ref`, relative to the document that holds the +`discriminator`. + Pass an `OpenAPI.SchemaEngine.Resources.AbstractRetriever` with `retriever=...` when an application needs another retrieval policy or an in-memory resource store. Resource size and count limits still apply. diff --git a/src/normalize.jl b/src/normalize.jl index 771a4e0..7770ef8 100644 --- a/src/normalize.jl +++ b/src/normalize.jl @@ -655,6 +655,46 @@ function _portable_schema_ids(context::NormalizationContext, schemas) return output end +# JSON Pointer, relative to a schema object, of a discriminator mapping value. +# Planning derives the same key to look up the binding the compiler recorded. +function _discriminator_mapping_pointer(tag::AbstractString) + return string(Resources.JSONPointer(("discriminator", "mapping", String(tag)))) +end + +const _DISCRIMINATOR_DEFAULT_POINTER = "/discriminator/defaultMapping" + +# OAS 3.1.1 ยง4.8.25: a mapping value is either a schema name or a URI +# reference, and an ambiguous bare value such as "Cat" is a schema name; authors +# write "./Cat" to force a URI reference. Only URI-shaped values are references. +function _uri_reference_mapping_value(value::AbstractString) + return occursin('/', value) || occursin('#', value) || occursin(':', value) +end + +# `discriminator.mapping` values are URI references that live outside the JSON +# Schema vocabulary, so the schema engine does not see them as references. +# Declare them as optional references so the compiler resolves them against the +# schema's real base URI, retrieving cross-file targets like `\$ref`, before the +# graph is rebased onto portable identifiers. Planning reads the bindings. +function _discriminator_references(schema::AbstractDict) + discriminator = get(schema, "discriminator", nothing) + discriminator isa AbstractDict || return () + references = Tuple{String,String}[] + mapping = get(discriminator, "mapping", nothing) + if mapping isa AbstractDict + for tag in sort!(String[String(tag) for tag in keys(mapping)]) + value = mapping[tag] + value isa AbstractString && _uri_reference_mapping_value(value) || + continue + push!(references, (_discriminator_mapping_pointer(tag), String(value))) + end + end + default = get(discriminator, "defaultMapping", nothing) + if default isa AbstractString && _uri_reference_mapping_value(default) + push!(references, (_DISCRIMINATOR_DEFAULT_POINTER, String(default))) + end + return references +end + function _compile_schemas!(context::NormalizationContext) isempty(context.schema_cache) && return handles = sort( @@ -678,6 +718,7 @@ function _compile_schemas!(context::NormalizationContext) max_resources = context.resolver.max_resources, max_nodes = context.resolver.max_nodes, max_depth = context.resolver.max_depth, + extra_references = _discriminator_references, ) catch error location = error isa SchemaEngine.CompilationError ? error.location : first(roots) diff --git a/src/planning.jl b/src/planning.jl index 9d7bd9f..a210750 100644 --- a/src/planning.jl +++ b/src/planning.jl @@ -989,6 +989,52 @@ function _reference_view(view::SchemaView, reference::AbstractString) ) end +# Resolve a discriminator `mapping` or `defaultMapping` value. The schema +# compiler already resolved URI-shaped values (see `_discriminator_references`) +# against the pre-rebase base URI of the schema that owns the discriminator, +# retrieving cross-file targets, and recorded either the binding or a failure. +# Values the compiler was not asked about fall back to same-document lookup. +# Emit a diagnostic with `code` and return `nothing` when the value does not +# name a schema. +function _discriminator_target( + context, + owner::SchemaView, + pointer::String, + reference::AbstractString, + code::Symbol, + label::String, +) + compiled = owner.compiled + location = SourceLocation(owner.node.resource, owner.node.pointer) + if compiled !== nothing + target = SchemaEngine.reference_target(compiled, owner.node, pointer) + if target !== nothing + resource = Resources.resource(compiled.registry, target.resource) + value = Resources.resolve(resource.contents, target.pointer) + return SchemaView(value, target, owner.version, compiled) + end + failure = SchemaEngine.reference_failure(compiled, owner.node, pointer) + if failure !== nothing + _error!(context.bag, code, "cannot resolve $label: $failure", location) + return nothing + end + end + target = try + _reference_view(owner, reference) + catch error + _error!( + context.bag, + code, + "cannot resolve $label: $(sprint(showerror, error))", + location, + ) + return nothing + end + target === nothing && + _error!(context.bag, code, "$label does not resolve to a schema", location) + return target +end + function _plan_union!(context, view, suggested, mode, keyword) resolved = _resolved_view(view) union_owner = something(_keyword_owner(resolved, keyword), resolved) @@ -1035,27 +1081,15 @@ function _plan_union!(context, view, suggested, mode, keyword) get(discriminator, "mapping", nothing) isa AbstractDict for (tag, reference) in discriminator["mapping"] reference isa AbstractString || continue - target = try - _reference_view(resolved, reference) - catch error - _error!( - context.bag, - :invalid_discriminator_mapping, - "cannot resolve discriminator mapping $(repr(tag)): $(sprint(showerror, error))", - SourceLocation(resolved.node.resource, resolved.node.pointer), - ) - missing - end - target === missing && continue - if target === nothing - _error!( - context.bag, - :invalid_discriminator_mapping, - "discriminator mapping $(repr(tag)) does not resolve to a schema", - SourceLocation(resolved.node.resource, resolved.node.pointer), - ) - continue - end + target = _discriminator_target( + context, + discriminator_owner, + _discriminator_mapping_pointer(tag), + reference, + :invalid_discriminator_mapping, + "discriminator mapping $(repr(tag))", + ) + target === nothing && continue target_type = _type_for!( context, target, @@ -1071,27 +1105,15 @@ function _plan_union!(context, view, suggested, mode, keyword) default_mapping = nothing if discriminator isa AbstractDict && get(discriminator, "defaultMapping", nothing) isa AbstractString - target = try - _reference_view(resolved, discriminator["defaultMapping"]) - catch error - _error!( - context.bag, - :invalid_discriminator_default, - "cannot resolve discriminator defaultMapping: $(sprint(showerror, error))", - SourceLocation(resolved.node.resource, resolved.node.pointer), - ) - missing - end - if target === missing - nothing - elseif target === nothing - _error!( - context.bag, - :invalid_discriminator_default, - "discriminator defaultMapping does not resolve to a schema", - SourceLocation(resolved.node.resource, resolved.node.pointer), - ) - else + target = _discriminator_target( + context, + discriminator_owner, + _DISCRIMINATOR_DEFAULT_POINTER, + discriminator["defaultMapping"], + :invalid_discriminator_default, + "discriminator defaultMapping", + ) + if target !== nothing target_type = _type_for!(context, target, suggested * "Default", mode) push!(types, target_type) default_mapping = target.node => target_type diff --git a/src/schema_engine/compiled.jl b/src/schema_engine/compiled.jl index 2bfbd73..80ad054 100644 --- a/src/schema_engine/compiled.jl +++ b/src/schema_engine/compiled.jl @@ -27,8 +27,12 @@ struct PendingReference reference::String dialect::Dialect location::Resources.NodeId + required::Bool end +const ReferenceTable = Dict{Tuple{Resources.NodeId,String},Resources.NodeId} +const ReferenceFailures = Dict{Tuple{Resources.NodeId,String},String} + struct CompiledNode index::Int id::Resources.NodeId @@ -41,7 +45,9 @@ mutable struct Compiler{R<:Resources.AbstractRetriever} dialects::Dict{Resources.NodeId,Dialect} dialect_aliases::Dict{String,Dialect} recursive_anchors::Set{Resources.ResourceId} - references::Dict{Tuple{Resources.NodeId,String},Resources.NodeId} + references::ReferenceTable + reference_failures::ReferenceFailures + extra_references::Any regexes::Dict{String,Regex} evaluation_nodes::Dict{Resources.NodeId,CompiledNode} transitions::Dict{Tuple{Int,Tuple{Vararg{String}}},CompiledNode} @@ -62,6 +68,7 @@ function Compiler( max_resources::Integer, max_nodes::Integer, max_depth::Integer, + extra_references = nothing, ) where {R<:Resources.AbstractRetriever} max_resources > 0 || throw(ArgumentError("max_resources must be positive")) max_nodes > 0 || throw(ArgumentError("max_nodes must be positive")) @@ -71,7 +78,9 @@ function Compiler( Dict{Resources.NodeId,Dialect}(), Dict{String,Dialect}(), Set{Resources.ResourceId}(), - Dict{Tuple{Resources.NodeId,String},Resources.NodeId}(), + ReferenceTable(), + ReferenceFailures(), + extra_references, Dict{String,Regex}(), Dict{Resources.NodeId,CompiledNode}(), Dict{Tuple{Int,Tuple{Vararg{String}}},CompiledNode}(), @@ -109,7 +118,8 @@ struct CompiledSchema{R<:Resources.AbstractRetriever} transitions::Dict{Tuple{Int,Tuple{Vararg{String}}},CompiledNode} uses_annotations::Bool recursive_anchors::Set{Resources.ResourceId} - references::Dict{Tuple{Resources.NodeId,String},Resources.NodeId} + references::ReferenceTable + reference_failures::ReferenceFailures regexes::Dict{String,Regex} retriever::R end @@ -136,6 +146,7 @@ function Base.getproperty(schema::CompiledSchema, name::Symbol) :evaluation_nodes, :transitions, :references, + :reference_failures, :regexes, ) && return copy(getfield(schema, name)) name === :recursive_anchors && return copy(getfield(schema, name)) @@ -170,6 +181,34 @@ function reference_target( return reference_target(getfield(schemas, :template), source, keyword) end +""" + reference_failure(schema, source, keyword) + +Return the failure message recorded for an optional reference at `source`, or +`nothing` when the reference resolved or was never declared. Optional +references come from the `extra_references` compilation hook; failures to +retrieve or resolve them are recorded here instead of aborting compilation. +""" +function reference_failure( + schema::CompiledSchema, + source::Resources.NodeId, + keyword::AbstractString, +) + canonical = Resources.canonical(schema.registry, source) + return get( + getfield(schema, :reference_failures), + (canonical, String(keyword)), + nothing, + ) +end +function reference_failure( + schemas::CompiledSchemas, + source::Resources.NodeId, + keyword::AbstractString, +) + return reference_failure(getfield(schemas, :template), source, keyword) +end + function _directory_resource(parent_dir::AbstractString) path = abspath(expanduser(parent_dir)) endswith(path, Base.Filesystem.path_separator) || @@ -609,6 +648,40 @@ function _record_references!(compiler::Compiler, schema, node, schema_dialect) String(reference), schema_dialect, node, + true, + ), + ) + end + compiler.extra_references === nothing && return + for entry in compiler.extra_references(schema) + entry isa Tuple && length(entry) == 2 || throw( + CompilationError( + node, + "extra_references must return (pointer, reference) string pairs", + ), + ) + pointer, reference = entry + pointer isa AbstractString && reference isa AbstractString || throw( + CompilationError( + node, + "extra_references must return (pointer, reference) string pairs", + ), + ) + startswith(pointer, '/') || throw( + CompilationError( + node, + "extra_references pointers must be JSON Pointers relative to the schema", + ), + ) + push!( + compiler.pending, + PendingReference( + node.resource, + String(pointer), + String(reference), + schema_dialect, + node, + false, ), ) end @@ -1179,27 +1252,40 @@ function _resolve_pending!(compiler::Compiler) index = 1 while index <= length(compiler.pending) pending = compiler.pending[index] - reference = Resources.Reference(pending.base, pending.reference) + index += 1 + reference = try + Resources.Reference(pending.base, pending.reference) + catch err + _reference_failed!(compiler, pending, err) + continue + end if !haskey(compiler.registry, reference.resource) try _load_reference!(compiler, reference.resource, pending.dialect) catch err - throw( + # A retrieved document that fails to compile is an error in + # the graph itself, so it stays fatal for optional references. + err isa CompilationError && throw( CompilationError(pending.location, sprint(showerror, err)), ) + _reference_failed!(compiler, pending, err) + continue end end resolved = try Resources.resolve(compiler.registry, reference) catch err - throw(CompilationError(pending.location, sprint(showerror, err))) + _reference_failed!(compiler, pending, err) + continue end - (resolved.value isa AbstractDict || resolved.value isa Bool) || throw( - CompilationError( - pending.location, + if !(resolved.value isa AbstractDict || resolved.value isa Bool) + _reference_failed!( + compiler, + pending, "$(pending.keyword) does not resolve to an object or boolean schema", - ), - ) + ) + continue + end target = Resources.canonical(compiler.registry, resolved.id) if !haskey(compiler.dialects, target) target = _scan!( @@ -1212,11 +1298,17 @@ function _resolve_pending!(compiler::Compiler) end target = Resources.canonical(compiler.registry, target) compiler.references[(pending.location, pending.keyword)] = target - index += 1 end return end +function _reference_failed!(compiler::Compiler, pending::PendingReference, err) + message = err isa AbstractString ? String(err) : sprint(showerror, err) + pending.required && throw(CompilationError(pending.location, message)) + compiler.reference_failures[(pending.location, pending.keyword)] = message + return +end + function CompiledSchema( schema::Union{AbstractDict,Bool}; dialect::Union{Dialect,Symbol,AbstractString} = DRAFT7, @@ -1227,10 +1319,12 @@ function CompiledSchema( max_resources::Integer = 256, max_nodes::Integer = 1_000_000, max_depth::Integer = 512, + extra_references = nothing, ) default_dialect = SchemaEngine.dialect(dialect) retrieval = _resource_id(base_uri, parent_dir) - compiler = Compiler(retriever, max_resources, max_nodes, max_depth) + compiler = + Compiler(retriever, max_resources, max_nodes, max_depth, extra_references) _register_dialect_aliases!(compiler, dialect_aliases) root, frozen, schema_dialect = _compile_resource!(compiler, schema, retrieval, default_dialect) @@ -1247,6 +1341,7 @@ function CompiledSchema( compiler.uses_annotations, copy(compiler.recursive_anchors), copy(compiler.references), + copy(compiler.reference_failures), copy(compiler.regexes), retriever, ) @@ -1269,6 +1364,7 @@ function _compiled_schema(compiler::Compiler, root::Resources.NodeId) compiler.uses_annotations, copy(compiler.recursive_anchors), copy(compiler.references), + copy(compiler.reference_failures), copy(compiler.regexes), compiler.retriever, ) @@ -1296,6 +1392,18 @@ map each requested or canonical root to a `Dialect`, registered dialect symbol, or dialect URI. Roots not in the map use `dialect`. `dialect_aliases` maps application dialect URI strings to compatible built-in dialects without retrieving a meta-schema. + +`extra_references` optionally names additional reference strings that live +inside schema objects without being reference keywords of the dialect. It is +called with each scanned schema object and returns an iterable of +`(pointer, reference)` string pairs, where `pointer` is a JSON Pointer relative +to that schema object and `reference` is the URI reference stored there. Each +pair is resolved like `\$ref` against the schema's base URI, retrieving and +compiling the target resource when needed, and is then available through +`reference_target` under `pointer`. Unlike `\$ref`, a reference that cannot be +retrieved or resolved does not abort compilation; its message is recorded for +`reference_failure`. Documents that are retrieved but fail to compile remain +fatal. """ function CompiledSchemas( resources::AbstractVector{<:Resources.Resource}, @@ -1307,6 +1415,7 @@ function CompiledSchemas( max_resources::Integer = 256, max_nodes::Integer = 1_000_000, max_depth::Integer = 512, + extra_references = nothing, ) isempty(resources) && throw(ArgumentError("at least one resource is required")) @@ -1315,7 +1424,8 @@ function CompiledSchemas( length(resources) <= max_resources || throw(ArgumentError("initial resources exceed max_resources")) default_dialect = SchemaEngine.dialect(dialect) - compiler = Compiler(retriever, max_resources, max_nodes, max_depth) + compiler = + Compiler(retriever, max_resources, max_nodes, max_depth, extra_references) _register_dialect_aliases!(compiler, dialect_aliases) for resource in resources try @@ -1411,6 +1521,7 @@ function select(schemas::CompiledSchemas, requested::Resources.NodeId) template.uses_annotations, getfield(template, :recursive_anchors), getfield(template, :references), + getfield(template, :reference_failures), getfield(template, :regexes), template.retriever, ) @@ -1453,6 +1564,7 @@ function subschema(template::CompiledSchema, requested::Resources.NodeId) template.uses_annotations, getfield(template, :recursive_anchors), getfield(template, :references), + getfield(template, :reference_failures), getfield(template, :regexes), template.retriever, ) @@ -1485,9 +1597,11 @@ function CompiledSchema( max_resources::Integer = 256, max_nodes::Integer = 1_000_000, max_depth::Integer = 512, + extra_references = nothing, ) default_dialect = SchemaEngine.dialect(dialect) - compiler = Compiler(retriever, max_resources, max_nodes, max_depth) + compiler = + Compiler(retriever, max_resources, max_nodes, max_depth, extra_references) _register_dialect_aliases!(compiler, dialect_aliases) try _check_source!(compiler, resource.contents) @@ -1547,6 +1661,7 @@ function CompiledSchema( compiler.uses_annotations, copy(compiler.recursive_anchors), copy(compiler.references), + copy(compiler.reference_failures), copy(compiler.regexes), retriever, ) diff --git a/src/schema_engine/rebase.jl b/src/schema_engine/rebase.jl index e6a20f0..5bb90f6 100644 --- a/src/schema_engine/rebase.jl +++ b/src/schema_engine/rebase.jl @@ -30,7 +30,10 @@ function _rebased_reference( resource_ids::Dict{Resources.ResourceId,Resources.ResourceId}, ) resource = string(resource_ids[target.resource]) - if keyword == "\$ref" + # Plain references, including the optional references declared through + # the `extra_references` hook (keyed by a relative JSON Pointer), are + # rewritten to the canonical target location. + if keyword == "\$ref" || startswith(keyword, '/') pointer = string(target.pointer) return isempty(pointer) ? resource : resource * "#" * pointer end @@ -110,13 +113,31 @@ function _rewrite_resource_documents(template::CompiledSchema, resource_ids) document === nothing && continue schema = Resources.resolve(document, source.pointer) schema isa AbstractDict || continue - raw = get(schema, keyword, nothing) + container, key = _reference_slot(schema, keyword) + container === nothing && continue + raw = get(container, key, nothing) raw isa AbstractString || continue - schema[keyword] = _rebased_reference(keyword, raw, target, resource_ids) + container[key] = _rebased_reference(keyword, raw, target, resource_ids) end return documents end +# Locate the object holding a reference string and its key. Reference keywords +# live directly in the schema object; optional references from the +# `extra_references` hook are keyed by a JSON Pointer relative to the schema. +function _reference_slot(schema::AbstractDict, keyword::String) + startswith(keyword, '/') || return (schema, keyword) + tokens = Resources.JSONPointer(keyword).tokens + isempty(tokens) && return (nothing, keyword) + container = schema + for token in tokens[1:end-1] + container isa AbstractDict || return (nothing, keyword) + container = get(container, token, nothing) + end + container isa AbstractDict || return (nothing, keyword) + return (container, tokens[end]) +end + function _mapped_raw_node(resource_ids, node::Resources.NodeId) return Resources.NodeId(resource_ids[node.resource], node.pointer) end @@ -205,13 +226,18 @@ function _rebased_template(template, resource_ids, registry) for (key, node) in getfield(template, :transitions) transitions[key] = nodes_by_index[node.index] end - references = Dict( + references = ReferenceTable( ( _mapped_node(original_registry, resource_ids, source), keyword, ) => _mapped_node(original_registry, resource_ids, target) for ((source, keyword), target) in getfield(template, :references) ) + reference_failures = ReferenceFailures( + (_mapped_node(original_registry, resource_ids, source), keyword) => + message for + ((source, keyword), message) in getfield(template, :reference_failures) + ) recursive_anchors = Set( resource_ids[resource] for resource in getfield(template, :recursive_anchors) @@ -231,6 +257,7 @@ function _rebased_template(template, resource_ids, registry) template.uses_annotations, recursive_anchors, references, + reference_failures, copy(getfield(template, :regexes)), Resources.DisabledRetriever(), ) diff --git a/test/discriminators.jl b/test/discriminators.jl index ce9043e..845531d 100644 --- a/test/discriminators.jl +++ b/test/discriminators.jl @@ -110,6 +110,138 @@ Set(diagnostic.code for diagnostic in error.value.diagnostics) end + @testset "cross-file discriminator mappings" begin + variant(field) = OpenAPI.obj( + "type" => "object", + "required" => ["kind", field], + "properties" => OpenAPI.obj( + "kind" => OpenAPI.obj("type" => "string"), + field => OpenAPI.obj("type" => "boolean"), + ), + "additionalProperties" => false, + ) + pet_paths = OpenAPI.obj( + "/pets" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getPet", + "responses" => OpenAPI.obj( + "200" => OpenAPI.obj( + "description" => "a pet", + "content" => OpenAPI.obj( + "application/json" => OpenAPI.obj( + "schema" => OpenAPI.obj( + "\$ref" => "#/components/schemas/Pet", + ), + ), + ), + ), + ), + ), + ), + ) + # The union lives in its own file under `schemas/`. Mapping values are + # relative to that file, not to the root document, and mix whole-file, + # empty-fragment, and pointer-fragment targets. + function write_tree(directory, mapping) + schemas = joinpath(directory, "schemas") + mkdir(schemas) + write(joinpath(schemas, "cat.json"), JSON.json(variant("meows"))) + write(joinpath(schemas, "dog.json"), JSON.json(variant("barks"))) + write( + joinpath(schemas, "animals.json"), + JSON.json(OpenAPI.obj("\$defs" => OpenAPI.obj("Bird" => variant("flies")))), + ) + write( + joinpath(schemas, "pet.json"), + JSON.json( + OpenAPI.obj( + "oneOf" => Any[ + OpenAPI.obj("\$ref" => "./cat.json"), + OpenAPI.obj("\$ref" => "./dog.json"), + OpenAPI.obj("\$ref" => "./animals.json#/\$defs/Bird"), + ], + "discriminator" => OpenAPI.obj( + "propertyName" => "kind", + "mapping" => mapping, + ), + ), + ), + ) + document = minimal_openapi("3.1.1", pet_paths) + document["components"] = OpenAPI.obj( + "schemas" => OpenAPI.obj( + "Pet" => OpenAPI.obj("\$ref" => "./schemas/pet.json"), + ), + ) + root_path = joinpath(directory, "openapi.json") + write(root_path, JSON.json(document)) + return root_path + end + + root_path = write_tree( + mktempdir(), + OpenAPI.obj( + "feline" => "./cat.json", + "canine" => "./dog.json#", + "avian" => "./animals.json#/\$defs/Bird", + ), + ) + source = OpenAPI.client(root_path; name = "CrossFileDiscriminatorClient") + host = Module(:CrossFileDiscriminatorClientHost) + Base.include_string(host, source, "CrossFileDiscriminatorClient.jl") + client_module = + Base.invokelatest(getfield, host, :CrossFileDiscriminatorClient) + response_media = only(only(client_module._OP_getpet.responses).media) + decode_pet(json) = Base.invokelatest( + OpenAPI.Runtime._decode_body, + client_module.DEFAULT_CLIENT, + client_module.Pet, + "application/json", + Vector{UInt8}(codeunits(json)), + response_media.schema, + ) + cat = decode_pet("{\"kind\":\"feline\",\"meows\":true}") + dog = decode_pet("{\"kind\":\"canine\",\"barks\":true}") + bird = decode_pet("{\"kind\":\"avian\",\"flies\":true}") + @test cat.value.meows + @test dog.value.barks + @test bird.value.flies + @test length(Set(typeof.((cat.value, dog.value, bird.value)))) == 3 + @test_throws OpenAPI.Runtime.DecodeError decode_pet( + "{\"kind\":\"avian\",\"meows\":true}", + ) + + # The discriminator may also sit on a schema reached through `allOf` + # from another file; mapping values stay relative to the owning file. + inherited = minimal_openapi("3.1.1", pet_paths) + inherited["components"] = OpenAPI.obj( + "schemas" => OpenAPI.obj( + "Pet" => OpenAPI.obj( + "allOf" => Any[OpenAPI.obj("\$ref" => "./schemas/pet.json")], + ), + ), + ) + inherited_path = joinpath(dirname(root_path), "inherited.json") + write(inherited_path, JSON.json(inherited)) + @test length(OpenAPI.plan(inherited_path).models) == + length(OpenAPI.plan(root_path).models) + + # A mapping target that cannot be retrieved is a located planning + # diagnostic that names the missing file, not a generic failure. + typo_path = write_tree( + mktempdir(), + OpenAPI.obj("feline" => "./cta.json", "canine" => "./dog.json"), + ) + error = @test_throws OpenAPI.OpenAPIError OpenAPI.plan(typo_path) + diagnostic = only( + diagnostic for diagnostic in error.value.diagnostics if + diagnostic.code == :invalid_discriminator_mapping + ) + @test occursin("\"feline\"", diagnostic.message) + @test occursin("cta.json", diagnostic.message) + @test isempty(diagnostic.location.pointer) + end + @testset "implicit mappings and collision-safe Julia names" begin document = minimal_openapi( "3.1.1", diff --git a/test/schema_engine/compiled.jl b/test/schema_engine/compiled.jl index 9eab5b5..81354b6 100644 --- a/test/schema_engine/compiled.jl +++ b/test/schema_engine/compiled.jl @@ -629,3 +629,79 @@ end retriever, ) end + +@testset "Optional extra references" begin + retriever = Resources.MemoryRetriever( + Dict( + "https://example.com/a" => "{\"type\":\"string\"}", + "https://example.com/broken" => "{\"\$ref\":5}", + ), + ) + # Application-level reference strings that are not dialect keywords, keyed + # by their location inside the schema object. + links(schema) = + schema isa AbstractDict && haskey(schema, "x-links") ? + sort!([("/x-links/" * key, value) for (key, value) in schema["x-links"]]) : + () + schema = Dict( + "\$defs" => Dict("local" => Dict("type" => "integer")), + "oneOf" => Any[Dict("\$ref" => "https://example.com/a")], + "x-links" => Dict( + "first" => "https://example.com/a", + "local" => "#/\$defs/local", + "missing" => "https://example.com/none", + "value" => "#/\$defs/local/type", + ), + ) + compiled = SchemaEngine.CompiledSchema( + schema; + dialect = SchemaEngine.DRAFT202012, + base_uri = "https://example.com/root.json", + retriever, + extra_references = links, + ) + root = compiled.root + @test SchemaEngine.reference_target(compiled, root, "/x-links/first") == + Resources.NodeId( + Resources.ResourceId("https://example.com/a"), + Resources.JSONPointer(), + ) + local_node = Resources.NodeId(root.resource, Resources.JSONPointer("/\$defs/local")) + @test SchemaEngine.reference_target(compiled, root, "/x-links/local") == local_node + @test isvalid(SchemaEngine.subschema(compiled, local_node), 1) + @test SchemaEngine.reference_failure(compiled, root, "/x-links/first") === nothing + @test SchemaEngine.reference_failure(compiled, root, "\$ref") === nothing + + # Failures to retrieve or resolve an optional reference are recorded, not + # thrown, and leave no binding behind. + @test SchemaEngine.reference_target(compiled, root, "/x-links/missing") === nothing + missing_failure = SchemaEngine.reference_failure(compiled, root, "/x-links/missing") + @test missing_failure isa String + @test occursin("https://example.com/none", missing_failure) + value_failure = SchemaEngine.reference_failure(compiled, root, "/x-links/value") + @test value_failure isa String + @test occursin("object or boolean schema", value_failure) + @test compiled.reference_failures == Dict( + (root, "/x-links/missing") => missing_failure, + (root, "/x-links/value") => value_failure, + ) + + # `\$ref` stays fatal, and so does an optional target that was retrieved but + # does not compile: that is an error in the graph, not in the reference. + @test_throws SchemaEngine.CompilationError SchemaEngine.CompiledSchema( + Dict("\$ref" => "https://example.com/none"); + retriever, + ) + @test_throws SchemaEngine.CompilationError SchemaEngine.CompiledSchema( + Dict("x-links" => Dict("broken" => "https://example.com/broken")); + dialect = SchemaEngine.DRAFT202012, + retriever, + extra_references = links, + ) + # The hook must return relative JSON Pointers. + @test_throws SchemaEngine.CompilationError SchemaEngine.CompiledSchema( + Dict("x-links" => Dict("bad" => "#")); + dialect = SchemaEngine.DRAFT202012, + extra_references = schema -> [("x-links/bad", "#")], + ) +end diff --git a/test/schema_engine/rebase.jl b/test/schema_engine/rebase.jl index 7c42a0e..1e5ada0 100644 --- a/test/schema_engine/rebase.jl +++ b/test/schema_engine/rebase.jl @@ -101,3 +101,63 @@ value in recursive_samples ] end + +@testset "Rebasing optional extra references" begin + R = SchemaEngine.Resources + root_id = R.ResourceId("file:///private/build/openapi.json") + common_id = R.ResourceId("file:///private/build/common.json") + root = R.Resource( + root_id, + Dict( + "schemas" => Dict( + "Union" => Dict( + "oneOf" => Any[Dict("\$ref" => "./common.json#/\$defs/Count")], + "x-links" => Dict( + "count" => "./common.json#/\$defs/Count", + "missing" => "./nothing.json", + ), + ), + ), + ), + ) + common = R.Resource( + common_id, + Dict("\$defs" => Dict("Count" => Dict("type" => "integer"))), + ) + union_root = R.NodeId(root_id, R.JSONPointer("/schemas/Union")) + links(schema) = + schema isa AbstractDict && haskey(schema, "x-links") ? + sort!([("/x-links/" * key, value) for (key, value) in schema["x-links"]]) : + () + graph = SchemaEngine.CompiledSchemas( + [root, common], + [union_root]; + dialect = SchemaEngine.DRAFT202012, + extra_references = links, + ) + mapping = Dict( + root_id => R.ResourceId("https://portable.invalid/root.json"), + common_id => R.ResourceId("https://portable.invalid/common.json"), + ) + rebased = SchemaEngine.rebase(graph, mapping) + mapped_union = R.NodeId(mapping[root_id], R.JSONPointer("/schemas/Union")) + count_node = R.NodeId(mapping[common_id], R.JSONPointer("/\$defs/Count")) + + # Bindings and recorded failures follow the graph onto the new identifiers. + @test SchemaEngine.reference_target(rebased, mapped_union, "/x-links/count") == + count_node + @test SchemaEngine.reference_target(rebased, mapped_union, "/x-links/missing") === + nothing + failure = SchemaEngine.reference_failure(rebased, mapped_union, "/x-links/missing") + @test failure isa String + @test occursin("nothing.json", failure) + + # Serialized resource data uses only replacement identifiers for resolved + # optional references; unresolved strings are left as written. + document = R.resource(rebased.template.registry, mapping[root_id]).contents + rebased_links = document["schemas"]["Union"]["x-links"] + @test rebased_links["count"] == "https://portable.invalid/common.json#/\$defs/Count" + @test rebased_links["missing"] == "./nothing.json" + @test isvalid(SchemaEngine.select(rebased, union_root), 3) + @test !isvalid(SchemaEngine.select(rebased, union_root), "3") +end