Skip to content

openapi: a union on an allOf branch below a discriminated base stays whole where no route names the schema #1695

Open

Description

@OmarAlJarrah

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 allOfDecides and 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.

Related: #1688, #1682

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)
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:bugDefect in shipped behavior

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions