Skip to content

openapi: a subtype a discriminator routes to keeps a union beside or on its allOf verbatim #1694

Open

Description

@OmarAlJarrah

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 allOf branch, the union already has a node of its own: the branch lowers at its pointer to the Union, which the model names as a Mixin. Every reader of the allOf'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.

Related: #1688, #594, #1682

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:P3Later emitters, M4 and up, 3.2-only, streaming, webhooks, rare constructstype:featureNew capability or enhancement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions