Repository navigation
Description
Problem
A union on an allOf branch distributes as the same union written beside the allOf does (docs/compilers/openapi.md#union-beside-structure), except below a discriminated base, where it stays whole at every position, including one no route of that base names:
openapi: 3.1.0
info: {title: t, version: "1"}
paths: {}
components:
schemas:
Pet:
type: object
properties: {kind: {type: string}}
discriminator: {propertyName: kind}
Small: {type: object, properties: {size: {type: integer}}}
Big: {type: object, properties: {weight: {type: integer}}}
Holder:
type: object
properties:
beside:
allOf: [{$ref: '#/components/schemas/Pet'}]
oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}]
branch:
allOf:
- $ref: '#/components/schemas/Pet'
- oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}]morphic compile --verify exits 0. Holder/properties/beside is a union of two composed models, each with Base Pet, and no tag. Holder/properties/branch is a model with Base Pet and the oneOf kept under Unmodeled["openapi:allOf/1"]. Neither property is a component or a mapping target, so no route of Pet names either, and the two spellings say the same thing.
Cause
findBranchUnion (compilers/openapi/internal/schema/union.go) is read once per schema, without the position, and allOfDecides shares that reading. Whether a route names a schema is read from its position (subtypeDiscriminatorValue reads the pointer for an implicit name), so the branch reading declines below any discriminated ancestor, while the beside reading, which lowerCoDeclaredUnion makes at the position, asks whether a route names it.
Direction
A draft, either of:
- decide routedness from the schema alone: whether it is one of its document's component schemas, by identity, or a mapping target of an ancestor resolves to it;
- or give
allOfDecidesand the readers sharing it the position.
The first keeps the schema-keyed memos as they are and needs a test holding it to subtypeDiscriminatorValue on every schema of the corpus, since the two would be two readings of one rule.
Out of scope
Typing the union of a subtype a route does name: #1694.
Priority: P3, rule 3 (off the golden spec: valid-spec functionality). python3 routed.py openapi.json → 0 inline schemas composing a discriminated base with a union on a branch (spec @ed698981; it finds the one in a probe writing it), morphic @e8f35636b.
routed.py
import json, sys
doc = json.load(open(sys.argv[1]))
comps = doc.get("components", {}).get("schemas", {})
def target(ref):
p = "#/components/schemas/"
return ref[len(p):] if isinstance(ref, str) and ref.startswith(p) else None
def discriminated_ancestor(s, seen):
for e in s.get("allOf", []) or []:
if not isinstance(e, dict): continue
n = target(e.get("$ref"))
if n is None or n in seen or n not in comps: continue
seen.add(n)
t = comps[n]
if isinstance(t, dict) and (t.get("discriminator") or discriminated_ancestor(t, seen)):
return True
return False
def union(s): return isinstance(s, dict) and (s.get("oneOf") or s.get("anyOf"))
counts = {}
def walk(node, path):
if isinstance(node, dict):
if isinstance(node.get("allOf"), list) and discriminated_ancestor(node, set()):
beside = bool(union(node))
branch = any(union(e) and "$ref" not in e for e in node["allOf"] if isinstance(e, dict))
if beside or branch:
kind = "component" if path.startswith("/components/schemas/") and path.count("/") == 3 else "inline"
key = (kind, "beside" if beside else "branch")
counts[key] = counts.get(key, 0) + 1
print(kind, key[1], path)
for k, v in node.items(): walk(v, path + "/" + str(k).replace("~", "~0").replace("/", "~1"))
elif isinstance(node, list):
for i, v in enumerate(node): walk(v, path + "/" + str(i))
walk(doc, "")
print("counts", counts)
Activity
docs(compilers/openapi): cite the issues routed subtypes wait on