From 5b62b9554b4623dae0982cc30dd7b268dd8d09b8 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 3 Feb 2026 10:44:20 -0500 Subject: [PATCH 1/2] fix(theme): handle nested discriminators in allOf structures Add support for discriminators nested within allOf schemas. Previously, discriminators inside allOf items were not properly detected, and the discriminator property lookup only checked top-level schema properties. Changes: - Add findProperty() helper to recursively search for properties in nested oneOf/anyOf/allOf structures - Update DiscriminatorNode to use findProperty() for discriminator property lookup - Update SchemaNode to check for discriminators in allOf items and merge schemas before processing - Return null instead of empty "object" SchemaItem when properties are empty after discriminator processing - Add test case for nested discriminator in allOf This fixes scenarios like: ```yaml allOf: - $ref: CommonProps - oneOf: [VariantA, VariantB] discriminator: propertyName: type ``` Fixes #1302 --- demo/examples/tests/discriminator.yaml | 291 ++++++++++++++++++ .../src/theme/Schema/index.tsx | 86 +++++- 2 files changed, 360 insertions(+), 17 deletions(-) diff --git a/demo/examples/tests/discriminator.yaml b/demo/examples/tests/discriminator.yaml index 186db492c..2b67a1ba8 100644 --- a/demo/examples/tests/discriminator.yaml +++ b/demo/examples/tests/discriminator.yaml @@ -350,6 +350,73 @@ paths: schema: $ref: "#/components/schemas/BaseEmptySubschema" + /discriminator-nested-allof: + post: + tags: + - discriminator + summary: Nested Discriminator in allOf + description: | + Tests discriminators nested within allOf structures, where the discriminator + and its property definition are inside an allOf item rather than at the top level. + This is a common pattern when combining shared properties with discriminated unions. + + Schema: + ```yaml + allOf: + - $ref: '#/components/schemas/CommonProps' + - oneOf: + - $ref: '#/components/schemas/NestedVariantA' + - $ref: '#/components/schemas/NestedVariantB' + discriminator: + propertyName: variantType + mapping: + variant-a: '#/components/schemas/NestedVariantA' + variant-b: '#/components/schemas/NestedVariantB' + ``` + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NestedDiscriminatorBase" + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/NestedDiscriminatorBase" + + /discriminator-doubly-nested: + post: + tags: + - discriminator + summary: Doubly Nested Discriminators (Issue #1302 Full Scenario) + description: | + Tests the full scenario from issue #1302 with doubly-nested discriminators: + - Outer level: allOf with discriminator on "type" selecting TypeA/TypeB/TypeC + - TypeA: has allOf with its own nested discriminator on "mode" + - TypeB: has allOf with its own nested discriminator on "mode" + - TypeC: has discriminator on "mode" at root level (NOT in allOf) + + This tests: + 1. Discriminators nested in allOf are properly detected + 2. Inner discriminators work after selecting outer type + 3. TypeC pattern (discriminator at root) still works + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DoublyNestedBase" + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/DoublyNestedBase" + components: schemas: BaseBasic: @@ -569,3 +636,227 @@ components: type: object allOf: - $ref: "#/components/schemas/BaseEmptySubschema" + + # Schemas for nested discriminator test + CommonProps: + type: object + properties: + id: + type: string + format: uuid + description: Unique identifier + createdAt: + type: string + format: date-time + description: Creation timestamp + required: + - id + + NestedDiscriminatorBase: + allOf: + - $ref: "#/components/schemas/CommonProps" + - oneOf: + - $ref: "#/components/schemas/NestedVariantA" + - $ref: "#/components/schemas/NestedVariantB" + discriminator: + propertyName: variantType + mapping: + variant-a: "#/components/schemas/NestedVariantA" + variant-b: "#/components/schemas/NestedVariantB" + + NestedVariantA: + type: object + properties: + variantType: + type: string + enum: ["variant-a"] + configA: + type: object + properties: + settingA1: + type: string + settingA2: + type: integer + required: + - variantType + + NestedVariantB: + type: object + properties: + variantType: + type: string + enum: ["variant-b"] + configB: + type: object + properties: + settingB1: + type: boolean + settingB2: + type: array + items: + type: string + required: + - variantType + + # Test case for doubly-nested discriminators (issue #1302 full scenario) + # Outer: allOf with discriminator on "type" + # Inner: TypeA and TypeB each have their own discriminator on "mode" + DoublyNestedBase: + allOf: + - $ref: "#/components/schemas/OuterCommonProps" + - oneOf: + - $ref: "#/components/schemas/OuterTypeA" + - $ref: "#/components/schemas/OuterTypeB" + - $ref: "#/components/schemas/OuterTypeC" + discriminator: + propertyName: type + mapping: + type-a: "#/components/schemas/OuterTypeA" + type-b: "#/components/schemas/OuterTypeB" + type-c: "#/components/schemas/OuterTypeC" + + OuterCommonProps: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + + # TypeA: has allOf with its own nested discriminator + OuterTypeA: + title: TypeA + allOf: + - $ref: "#/components/schemas/InnerCommonProps" + - oneOf: + - $ref: "#/components/schemas/ModeA1" + - $ref: "#/components/schemas/ModeA2" + discriminator: + propertyName: mode + mapping: + mode-a1: "#/components/schemas/ModeA1" + mode-a2: "#/components/schemas/ModeA2" + properties: + type: + type: string + enum: ["type-a"] + required: + - type + + # TypeB: also has allOf with its own nested discriminator + OuterTypeB: + title: TypeB + allOf: + - $ref: "#/components/schemas/InnerCommonProps" + - oneOf: + - $ref: "#/components/schemas/ModeB1" + - $ref: "#/components/schemas/ModeB2" + discriminator: + propertyName: mode + mapping: + mode-b1: "#/components/schemas/ModeB1" + mode-b2: "#/components/schemas/ModeB2" + properties: + type: + type: string + enum: ["type-b"] + required: + - type + + # TypeC: has discriminator at root (NOT in allOf) + OuterTypeC: + title: TypeC + type: object + oneOf: + - $ref: "#/components/schemas/ModeC1" + - $ref: "#/components/schemas/ModeC2" + discriminator: + propertyName: mode + mapping: + mode-c1: "#/components/schemas/ModeC1" + mode-c2: "#/components/schemas/ModeC2" + properties: + type: + type: string + enum: ["type-c"] + required: + - type + + InnerCommonProps: + type: object + properties: + timestamp: + type: string + format: date-time + + ModeA1: + type: object + properties: + mode: + type: string + enum: ["mode-a1"] + settingA1: + type: string + required: + - mode + + ModeA2: + type: object + properties: + mode: + type: string + enum: ["mode-a2"] + settingA2: + type: number + required: + - mode + + ModeB1: + type: object + properties: + mode: + type: string + enum: ["mode-b1"] + settingB1: + type: boolean + required: + - mode + + ModeB2: + type: object + properties: + mode: + type: string + enum: ["mode-b2"] + settingB2: + type: array + items: + type: string + required: + - mode + + ModeC1: + type: object + properties: + mode: + type: string + enum: ["mode-c1"] + settingC1: + type: integer + required: + - mode + + ModeC2: + type: object + properties: + mode: + type: string + enum: ["mode-c2"] + settingC2: + type: object + properties: + nested: + type: string + required: + - mode diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx index 62dd06546..37c59d789 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx @@ -44,6 +44,47 @@ const mergeAllOf = (allOf: any) => { return mergedSchemas ?? {}; }; +/** + * Recursively searches for a property in a schema, including nested + * oneOf, anyOf, and allOf structures. This is needed for discriminators + * where the property definition may be in a nested schema. + */ +const findProperty = ( + schema: SchemaObject, + propertyName: string +): SchemaObject | undefined => { + // Check direct properties first + if (schema.properties?.[propertyName]) { + return schema.properties[propertyName]; + } + + // Search in oneOf schemas + if (schema.oneOf) { + for (const subschema of schema.oneOf) { + const found = findProperty(subschema as SchemaObject, propertyName); + if (found) return found; + } + } + + // Search in anyOf schemas + if (schema.anyOf) { + for (const subschema of schema.anyOf) { + const found = findProperty(subschema as SchemaObject, propertyName); + if (found) return found; + } + } + + // Search in allOf schemas + if (schema.allOf) { + for (const subschema of schema.allOf) { + const found = findProperty(subschema as SchemaObject, propertyName); + if (found) return found; + } + } + + return undefined; +}; + interface MarkdownProps { text: string | undefined; } @@ -313,16 +354,10 @@ const Properties: React.FC = ({ discriminator["mapping"] = inferredMapping; } if (Object.keys(schema.properties as {}).length === 0) { - return ( - - ); + // If properties are empty (e.g., after discriminator property removal in oneOf), + // don't render an empty "object" placeholder - just return nothing + // This prevents confusing empty object displays in discriminated unions + return null; } return ( @@ -442,10 +477,9 @@ const DiscriminatorNode: React.FC = ({ let discriminatedSchemas: any = {}; let inferredMapping: any = {}; - // default to empty object if no parent-level properties exist - const discriminatorProperty = schema.properties - ? schema.properties![discriminator.propertyName] - : {}; + // Search for the discriminator property in the schema, including nested structures + const discriminatorProperty = + findProperty(schema, discriminator.propertyName) ?? {}; if (schema.allOf) { const mergedSchemas = mergeAllOf(schema) as SchemaObject; @@ -997,12 +1031,30 @@ const SchemaNode: React.FC = ({ return null; } - if (schema.discriminator) { - const { discriminator } = schema; + // Merge allOf first if present, so discriminators in nested schemas are properly handled + // This ensures discriminator property lookups work for schemas like: + // allOf: [{ $ref: CommonProps }, { oneOf: [...], discriminator: {...} }] + let workingSchema = schema; + if (schema.allOf && !schema.discriminator) { + // Check if any allOf item has a discriminator that should be hoisted + const discriminatorItem = schema.allOf.find( + (item: any) => item.discriminator + ); + if (discriminatorItem) { + workingSchema = mergeAllOf(schema) as SchemaObject; + // Preserve the discriminator from the nested schema + if (!workingSchema.discriminator && discriminatorItem.discriminator) { + workingSchema.discriminator = discriminatorItem.discriminator; + } + } + } + + if (workingSchema.discriminator) { + const { discriminator } = workingSchema; return ( ); From 121d37925a6217b57d2cca8fc61bbbcda34a05db Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 17 Feb 2026 15:19:07 -0500 Subject: [PATCH 2/2] fix(theme): improve nested discriminator handling and examples Handle recursively nested discriminators in composed schemas while preserving intentional empty-object rendering, and add demo specs covering deep allOf discriminator chains and explicit empty object properties. Co-authored-by: Cursor --- demo/examples/tests/allOf.yaml | 33 ++++++++ demo/examples/tests/discriminator.yaml | 77 +++++++++++++++++++ .../src/theme/Schema/index.tsx | 76 +++++++++++++----- 3 files changed, 167 insertions(+), 19 deletions(-) diff --git a/demo/examples/tests/allOf.yaml b/demo/examples/tests/allOf.yaml index 1f229f3b7..d5b7932e5 100644 --- a/demo/examples/tests/allOf.yaml +++ b/demo/examples/tests/allOf.yaml @@ -510,6 +510,39 @@ paths: name: type: string + /allof-empty-object-properties: + get: + tags: + - allOf + summary: allOf with explicit empty object properties + description: | + Demonstrates an intentional empty object schema (properties: {}) to ensure + the renderer still shows the object shape rather than hiding it. + + Schema: + ```yaml + type: object + properties: + metadata: + type: object + properties: {} + name: + type: string + ``` + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: object + properties: + metadata: + type: object + properties: {} + name: + type: string + /allof-multiple-oneof: post: tags: diff --git a/demo/examples/tests/discriminator.yaml b/demo/examples/tests/discriminator.yaml index 2b67a1ba8..f8884ff4a 100644 --- a/demo/examples/tests/discriminator.yaml +++ b/demo/examples/tests/discriminator.yaml @@ -417,6 +417,32 @@ paths: schema: $ref: "#/components/schemas/DoublyNestedBase" + /discriminator-deeply-nested-allof: + post: + tags: + - discriminator + summary: Deeply Nested Discriminator in allOf chains + description: | + Tests discriminator discovery through nested allOf chains: + - top-level allOf + - inner allOf + - oneOf with discriminator + + This validates recursive discriminator lookup beyond first-level allOf items. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeepNestedDiscriminatorBase" + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/DeepNestedDiscriminatorBase" + components: schemas: BaseBasic: @@ -664,6 +690,57 @@ components: variant-a: "#/components/schemas/NestedVariantA" variant-b: "#/components/schemas/NestedVariantB" + DeepNestedDiscriminatorBase: + allOf: + - $ref: "#/components/schemas/CommonProps" + - allOf: + - $ref: "#/components/schemas/DeepLayerCommon" + - oneOf: + - $ref: "#/components/schemas/DeepNestedVariantA" + - $ref: "#/components/schemas/DeepNestedVariantB" + discriminator: + propertyName: deepVariantType + mapping: + deep-a: "#/components/schemas/DeepNestedVariantA" + deep-b: "#/components/schemas/DeepNestedVariantB" + + DeepLayerCommon: + type: object + properties: + layer: + type: string + enum: ["inner"] + required: + - layer + + DeepNestedVariantA: + type: object + properties: + deepVariantType: + type: string + enum: ["deep-a"] + deepAConfig: + type: object + properties: + enabled: + type: boolean + required: + - deepVariantType + + DeepNestedVariantB: + type: object + properties: + deepVariantType: + type: string + enum: ["deep-b"] + deepBConfig: + type: object + properties: + threshold: + type: number + required: + - deepVariantType + NestedVariantA: type: object properties: diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx index 37c59d789..051150e28 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx @@ -85,6 +85,39 @@ const findProperty = ( return undefined; }; +/** + * Recursively searches for a discriminator in a schema, including nested + * oneOf, anyOf, and allOf structures. + */ +const findDiscriminator = (schema: SchemaObject): any | undefined => { + if (schema.discriminator) { + return schema.discriminator; + } + + if (schema.oneOf) { + for (const subschema of schema.oneOf) { + const found = findDiscriminator(subschema as SchemaObject); + if (found) return found; + } + } + + if (schema.anyOf) { + for (const subschema of schema.anyOf) { + const found = findDiscriminator(subschema as SchemaObject); + if (found) return found; + } + } + + if (schema.allOf) { + for (const subschema of schema.allOf) { + const found = findDiscriminator(subschema as SchemaObject); + if (found) return found; + } + } + + return undefined; +}; + interface MarkdownProps { text: string | undefined; } @@ -354,10 +387,21 @@ const Properties: React.FC = ({ discriminator["mapping"] = inferredMapping; } if (Object.keys(schema.properties as {}).length === 0) { - // If properties are empty (e.g., after discriminator property removal in oneOf), - // don't render an empty "object" placeholder - just return nothing - // This prevents confusing empty object displays in discriminated unions - return null; + // Hide placeholder only for discriminator cleanup artifacts; preserve + // empty object rendering for schemas that intentionally define no properties. + if (discriminator) { + return null; + } + return ( + + ); } return ( @@ -1031,22 +1075,16 @@ const SchemaNode: React.FC = ({ return null; } - // Merge allOf first if present, so discriminators in nested schemas are properly handled - // This ensures discriminator property lookups work for schemas like: - // allOf: [{ $ref: CommonProps }, { oneOf: [...], discriminator: {...} }] + // Resolve discriminator recursively so nested oneOf/anyOf/allOf compositions + // can still render discriminator tabs. let workingSchema = schema; - if (schema.allOf && !schema.discriminator) { - // Check if any allOf item has a discriminator that should be hoisted - const discriminatorItem = schema.allOf.find( - (item: any) => item.discriminator - ); - if (discriminatorItem) { - workingSchema = mergeAllOf(schema) as SchemaObject; - // Preserve the discriminator from the nested schema - if (!workingSchema.discriminator && discriminatorItem.discriminator) { - workingSchema.discriminator = discriminatorItem.discriminator; - } - } + const resolvedDiscriminator = + schema.discriminator ?? findDiscriminator(schema); + if (schema.allOf && !schema.discriminator && resolvedDiscriminator) { + workingSchema = mergeAllOf(schema) as SchemaObject; + } + if (!workingSchema.discriminator && resolvedDiscriminator) { + workingSchema.discriminator = resolvedDiscriminator; } if (workingSchema.discriminator) {