Repository navigation
Description
Problem
A schema that composes a discriminated base through allOf is that base's subtype, and the base's discriminator routes to it by a mapping key or by its component name. Where that subtype also writes a union, beside its allOf or on one of its branches, the union is kept verbatim on the subtype's model:
openapi: 3.1.0
info: {title: t, version: "1"}
paths: {}
components:
schemas:
Pet:
type: object
required: [kind]
properties: {kind: {type: string}}
discriminator:
propertyName: kind
mapping: {dog: '#/components/schemas/Dog'}
Small: {type: object, properties: {size: {type: integer}}}
Big: {type: object, properties: {weight: {type: integer}}}
Dog:
allOf:
- $ref: '#/components/schemas/Pet'
- oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}]
Cat:
allOf: [{$ref: '#/components/schemas/Pet'}]
oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}]morphic compile --verify exits 0. Dog and Cat are each a model with Base Pet and their tag (dog, Cat), and the union is kept under Unmodeled["openapi:allOf/1"] and Unmodeled["openapi:oneOf"] (degraded_lowering):
info openapi/degraded-construct …/Dog/allOf/1: inline allOf branch is merged in place, and nothing reads the rest of it; the branch (oneOf) is kept verbatim under Unmodeled
info openapi/degraded-construct …/Cat: oneOf/anyOf co-declared with structural keywords intersects with them, and a discriminator on an ancestor routes to this schema as its subtype, which distributing it into a union would undo; union branches kept verbatim under Unmodeled
Nothing is lost and the document validates, but a typed consumer of Dog sees Pet's fields and none of size or weight.
Cause
docs/compilers/openapi.md#union-beside-structure (shape 6) declines distribution here. Distributed, Dog would be a Union of Pet ∧ Small and Pet ∧ Big, which is no subtype of Pet, so pass.Validate refuses the mapping (pass/discriminator-missing-variant), an implicit route leaves two variants sharing one tag, and a schema composing Dog stops being a subtype of Pet.
Direction
A draft: keep the subtype a Model under its base and compose the union by reference, as a Mixin naming a Union node, which docs/ir/models.md#composition-entries already allows. allOf: [{$ref: Pet}, {$ref: DogKinds}], with DogKinds a component holding the oneOf, lowers that way today.
- On an
allOfbranch, the union already has a node of its own: the branch lowers at its pointer to theUnion, which the model names as aMixin. Every reader of theallOf's branches must then agree the branch is a referent rather than merged in place (docs/compilers/openapi.md#allof-inline-residue), the multipart part reader among them. - Beside the
allOf, no schema holds the union alone, so the node would be minted in a namespace of its own (models: a synthesized node takes an ID apart from every pointer the source can name).
The alternative weighed and not proposed: let a model discriminator's mapping name a Union whose every variant is a subtype. It keeps the variants typed, but a schema composing Dog then has a Union for its Base and leaves the hierarchy, and every emitter must route one tag to several subtypes.
Out of scope
A union on an allOf branch of an inline schema below a discriminated base that no route names, which stays whole where the same union beside the allOf distributes: #1695.
Priority: P3, rule 3 (off the golden spec: valid-spec functionality an emitter needs). python3 routed.py openapi.json, which counts the schemas composing a discriminated base through allOf that write a union beside it or on an inline branch, → 0 (spec @ed698981; the same counter finds 3 in a probe writing each shape once), 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