Skip to content

Commit 743cbe4

Browse files
authored
fix: resolve cross-file discriminator mapping references (#115)
`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
1 parent 62818a5 commit 743cbe4

9 files changed

Lines changed: 541 additions & 62 deletions

File tree

docs/src/models.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ Implemented model behavior includes:
88

99
- objects, arrays, tuples, dictionaries, primitives, enums, and nullable types;
1010
- required, optional, and explicit-null values;
11-
- `allOf`, `oneOf`, `anyOf`, and discriminators;
11+
- `allOf`, `oneOf`, `anyOf`, and discriminators, whose `mapping` and
12+
`defaultMapping` values may be same-document, relative, or cross-file URI
13+
references resolved like `$ref`;
1214
- recursive models and recursive aliases;
1315
- `additionalProperties`, `patternProperties`, `propertyNames`, and closed
1416
objects;

docs/src/pipeline.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ The default retriever has conservative access rules:
123123
- HTTP redirects are not followed.
124124
- Unsupported URI schemes are rejected.
125125

126+
Discriminator `mapping` and `defaultMapping` URI references are retrieved under
127+
the same rules as `$ref`, relative to the document that holds the
128+
`discriminator`.
129+
126130
Pass an `OpenAPI.SchemaEngine.Resources.AbstractRetriever` with `retriever=...`
127131
when an application needs another retrieval policy or an in-memory resource
128132
store. Resource size and count limits still apply.

src/normalize.jl

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,46 @@ function _portable_schema_ids(context::NormalizationContext, schemas)
655655
return output
656656
end
657657

658+
# JSON Pointer, relative to a schema object, of a discriminator mapping value.
659+
# Planning derives the same key to look up the binding the compiler recorded.
660+
function _discriminator_mapping_pointer(tag::AbstractString)
661+
return string(Resources.JSONPointer(("discriminator", "mapping", String(tag))))
662+
end
663+
664+
const _DISCRIMINATOR_DEFAULT_POINTER = "/discriminator/defaultMapping"
665+
666+
# OAS 3.1.1 §4.8.25: a mapping value is either a schema name or a URI
667+
# reference, and an ambiguous bare value such as "Cat" is a schema name; authors
668+
# write "./Cat" to force a URI reference. Only URI-shaped values are references.
669+
function _uri_reference_mapping_value(value::AbstractString)
670+
return occursin('/', value) || occursin('#', value) || occursin(':', value)
671+
end
672+
673+
# `discriminator.mapping` values are URI references that live outside the JSON
674+
# Schema vocabulary, so the schema engine does not see them as references.
675+
# Declare them as optional references so the compiler resolves them against the
676+
# schema's real base URI, retrieving cross-file targets like `\$ref`, before the
677+
# graph is rebased onto portable identifiers. Planning reads the bindings.
678+
function _discriminator_references(schema::AbstractDict)
679+
discriminator = get(schema, "discriminator", nothing)
680+
discriminator isa AbstractDict || return ()
681+
references = Tuple{String,String}[]
682+
mapping = get(discriminator, "mapping", nothing)
683+
if mapping isa AbstractDict
684+
for tag in sort!(String[String(tag) for tag in keys(mapping)])
685+
value = mapping[tag]
686+
value isa AbstractString && _uri_reference_mapping_value(value) ||
687+
continue
688+
push!(references, (_discriminator_mapping_pointer(tag), String(value)))
689+
end
690+
end
691+
default = get(discriminator, "defaultMapping", nothing)
692+
if default isa AbstractString && _uri_reference_mapping_value(default)
693+
push!(references, (_DISCRIMINATOR_DEFAULT_POINTER, String(default)))
694+
end
695+
return references
696+
end
697+
658698
function _compile_schemas!(context::NormalizationContext)
659699
isempty(context.schema_cache) && return
660700
handles = sort(
@@ -678,6 +718,7 @@ function _compile_schemas!(context::NormalizationContext)
678718
max_resources = context.resolver.max_resources,
679719
max_nodes = context.resolver.max_nodes,
680720
max_depth = context.resolver.max_depth,
721+
extra_references = _discriminator_references,
681722
)
682723
catch error
683724
location = error isa SchemaEngine.CompilationError ? error.location : first(roots)

src/planning.jl

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,52 @@ function _reference_view(view::SchemaView, reference::AbstractString)
989989
)
990990
end
991991

992+
# Resolve a discriminator `mapping` or `defaultMapping` value. The schema
993+
# compiler already resolved URI-shaped values (see `_discriminator_references`)
994+
# against the pre-rebase base URI of the schema that owns the discriminator,
995+
# retrieving cross-file targets, and recorded either the binding or a failure.
996+
# Values the compiler was not asked about fall back to same-document lookup.
997+
# Emit a diagnostic with `code` and return `nothing` when the value does not
998+
# name a schema.
999+
function _discriminator_target(
1000+
context,
1001+
owner::SchemaView,
1002+
pointer::String,
1003+
reference::AbstractString,
1004+
code::Symbol,
1005+
label::String,
1006+
)
1007+
compiled = owner.compiled
1008+
location = SourceLocation(owner.node.resource, owner.node.pointer)
1009+
if compiled !== nothing
1010+
target = SchemaEngine.reference_target(compiled, owner.node, pointer)
1011+
if target !== nothing
1012+
resource = Resources.resource(compiled.registry, target.resource)
1013+
value = Resources.resolve(resource.contents, target.pointer)
1014+
return SchemaView(value, target, owner.version, compiled)
1015+
end
1016+
failure = SchemaEngine.reference_failure(compiled, owner.node, pointer)
1017+
if failure !== nothing
1018+
_error!(context.bag, code, "cannot resolve $label: $failure", location)
1019+
return nothing
1020+
end
1021+
end
1022+
target = try
1023+
_reference_view(owner, reference)
1024+
catch error
1025+
_error!(
1026+
context.bag,
1027+
code,
1028+
"cannot resolve $label: $(sprint(showerror, error))",
1029+
location,
1030+
)
1031+
return nothing
1032+
end
1033+
target === nothing &&
1034+
_error!(context.bag, code, "$label does not resolve to a schema", location)
1035+
return target
1036+
end
1037+
9921038
function _plan_union!(context, view, suggested, mode, keyword)
9931039
resolved = _resolved_view(view)
9941040
union_owner = something(_keyword_owner(resolved, keyword), resolved)
@@ -1035,27 +1081,15 @@ function _plan_union!(context, view, suggested, mode, keyword)
10351081
get(discriminator, "mapping", nothing) isa AbstractDict
10361082
for (tag, reference) in discriminator["mapping"]
10371083
reference isa AbstractString || continue
1038-
target = try
1039-
_reference_view(resolved, reference)
1040-
catch error
1041-
_error!(
1042-
context.bag,
1043-
:invalid_discriminator_mapping,
1044-
"cannot resolve discriminator mapping $(repr(tag)): $(sprint(showerror, error))",
1045-
SourceLocation(resolved.node.resource, resolved.node.pointer),
1046-
)
1047-
missing
1048-
end
1049-
target === missing && continue
1050-
if target === nothing
1051-
_error!(
1052-
context.bag,
1053-
:invalid_discriminator_mapping,
1054-
"discriminator mapping $(repr(tag)) does not resolve to a schema",
1055-
SourceLocation(resolved.node.resource, resolved.node.pointer),
1056-
)
1057-
continue
1058-
end
1084+
target = _discriminator_target(
1085+
context,
1086+
discriminator_owner,
1087+
_discriminator_mapping_pointer(tag),
1088+
reference,
1089+
:invalid_discriminator_mapping,
1090+
"discriminator mapping $(repr(tag))",
1091+
)
1092+
target === nothing && continue
10591093
target_type = _type_for!(
10601094
context,
10611095
target,
@@ -1071,27 +1105,15 @@ function _plan_union!(context, view, suggested, mode, keyword)
10711105
default_mapping = nothing
10721106
if discriminator isa AbstractDict &&
10731107
get(discriminator, "defaultMapping", nothing) isa AbstractString
1074-
target = try
1075-
_reference_view(resolved, discriminator["defaultMapping"])
1076-
catch error
1077-
_error!(
1078-
context.bag,
1079-
:invalid_discriminator_default,
1080-
"cannot resolve discriminator defaultMapping: $(sprint(showerror, error))",
1081-
SourceLocation(resolved.node.resource, resolved.node.pointer),
1082-
)
1083-
missing
1084-
end
1085-
if target === missing
1086-
nothing
1087-
elseif target === nothing
1088-
_error!(
1089-
context.bag,
1090-
:invalid_discriminator_default,
1091-
"discriminator defaultMapping does not resolve to a schema",
1092-
SourceLocation(resolved.node.resource, resolved.node.pointer),
1093-
)
1094-
else
1108+
target = _discriminator_target(
1109+
context,
1110+
discriminator_owner,
1111+
_DISCRIMINATOR_DEFAULT_POINTER,
1112+
discriminator["defaultMapping"],
1113+
:invalid_discriminator_default,
1114+
"discriminator defaultMapping",
1115+
)
1116+
if target !== nothing
10951117
target_type = _type_for!(context, target, suggested * "Default", mode)
10961118
push!(types, target_type)
10971119
default_mapping = target.node => target_type

0 commit comments

Comments
 (0)