From 5c04d2a3d3ed7f7336c9bbc7a294eb8464422698 Mon Sep 17 00:00:00 2001 From: tan Date: Sat, 12 Sep 2026 14:42:45 +0530 Subject: [PATCH] fix: resolve cross-file discriminator mapping references `discriminator.mapping` and `defaultMapping` values are URI references that live outside the JSON Schema vocabulary, so the schema compiler never saw them. Planning resolved them lazily after the compiled graph had been rebased onto portable identifiers, so any relative or cross-file value failed with `invalid_discriminator_mapping` even though the same URI worked in `$ref`. Mapping values were also resolved relative to the union schema rather than the schema that owns the discriminator. The schema engine gains a generic `extra_references` compilation hook: callers name additional reference strings inside schema objects, keyed by a JSON Pointer relative to the schema. They are resolved like `$ref` before rebasing, retrieving target resources when needed, and recorded in the reference table. Failures to retrieve or resolve them are recorded for `reference_failure` instead of aborting compilation; retrieved documents that fail to compile stay fatal. Rebasing carries the bindings and failures across and rewrites the resolved strings. Normalization declares URI-shaped mapping values through the hook (bare schema names are left alone per OAS 3.1.1), and planning reads the recorded binding for the discriminator owner's node, reporting recorded failures as located diagnostics. Fixes #114 --- docs/src/models.md | 4 +- docs/src/pipeline.md | 4 + src/normalize.jl | 41 ++++++++++ src/planning.jl | 106 ++++++++++++++---------- src/schema_engine/compiled.jl | 145 +++++++++++++++++++++++++++++---- src/schema_engine/rebase.jl | 35 +++++++- test/discriminators.jl | 132 ++++++++++++++++++++++++++++++ test/schema_engine/compiled.jl | 76 +++++++++++++++++ test/schema_engine/rebase.jl | 60 ++++++++++++++ 9 files changed, 541 insertions(+), 62 deletions(-) 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