Highlight discriminator properties in oneOf, allOf, anyOf objects (#3691)

This commit is contained in:
spastorelli
2025-09-29 17:09:21 +02:00
committed by GitHub
parent 1e4e54e752
commit 8e99871004
14 changed files with 153 additions and 84 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Highlight discriminator properties in oneOf, allOf, anyOf objects
@@ -192,7 +192,7 @@
.openapi-schema-name { .openapi-schema-name {
/* To make double click on the property name select only the name, /* To make double click on the property name select only the name,
we disable selection on the parent and re-enable it on the children. */ we disable selection on the parent and re-enable it on the children. */
@apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5; @apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5 items-center;
} }
.openapi-schema-name .openapi-deprecated { .openapi-schema-name .openapi-deprecated {
@@ -207,6 +207,10 @@
@apply line-through opacity-9; @apply line-through opacity-9;
} }
.openapi-schema-discriminator {
@apply text-primary-subtle/9 text-[0.813rem] lowercase;
}
.openapi-schema-required { .openapi-schema-required {
@apply text-warning-subtle text-[0.813rem] lowercase; @apply text-warning-subtle text-[0.813rem] lowercase;
} }
@@ -22,17 +22,20 @@ describe('getSchemaAlternatives', () => {
}, },
], ],
}) })
).toEqual([ ).toEqual({
{ type: 'oneOf',
type: 'number', schemas: [
}, {
{ type: 'number',
type: 'boolean', },
}, {
{ type: 'boolean',
type: 'string', },
}, {
]); type: 'string',
},
],
});
}); });
it('merges string enum', () => { it('merges string enum', () => {
@@ -54,13 +57,16 @@ describe('getSchemaAlternatives', () => {
}, },
], ],
}) })
).toEqual([ ).toEqual({
{ type: 'oneOf',
type: 'string', schemas: [
enum: ['a', 'b', 'c', 'd'], {
nullable: true, type: 'string',
}, enum: ['a', 'b', 'c', 'd'],
]); nullable: true,
},
],
});
}); });
it('merges objects with allOf', () => { it('merges objects with allOf', () => {
@@ -93,26 +99,29 @@ describe('getSchemaAlternatives', () => {
}, },
], ],
}) })
).toEqual([ ).toEqual({
{ type: 'allOf',
type: 'object', schemas: [
properties: { {
name: { type: 'object',
type: 'string', properties: {
}, name: {
map: { type: 'string',
type: 'string', },
}, map: {
description: { type: 'string',
type: 'string', },
}, description: {
externalId: { type: 'string',
type: 'string', },
externalId: {
type: 'string',
},
}, },
required: ['name', 'map', 'externalId'],
}, },
required: ['name', 'map', 'externalId'], ],
}, });
]);
}); });
it('should not flatten oneOf and allOf', () => { it('should not flatten oneOf and allOf', () => {
@@ -134,21 +143,24 @@ describe('getSchemaAlternatives', () => {
}, },
], ],
}) })
).toEqual([ ).toEqual({
{ type: 'oneOf',
allOf: [ schemas: [
{ {
type: 'number', allOf: [
}, {
{ type: 'number',
type: 'boolean', },
}, {
], type: 'boolean',
}, },
{ ],
type: 'string', },
}, {
]); type: 'string',
},
],
});
}); });
it('should stop at circular references', () => { it('should stop at circular references', () => {
@@ -162,11 +174,14 @@ describe('getSchemaAlternatives', () => {
a.anyOf?.push(a); a.anyOf?.push(a);
expect(getSchemaAlternatives(a)).toEqual([ expect(getSchemaAlternatives(a)).toEqual({
{ type: 'anyOf',
type: 'string', schemas: [
}, {
a, type: 'string',
]); },
a,
],
});
}); });
}); });
+49 -24
View File
@@ -23,6 +23,7 @@ type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
export interface OpenAPISchemaPropertyEntry { export interface OpenAPISchemaPropertyEntry {
propertyName?: string; propertyName?: string;
required?: boolean | null; required?: boolean | null;
isDiscriminatorProperty?: boolean;
schema: OpenAPIV3.SchemaObject; schema: OpenAPIV3.SchemaObject;
} }
@@ -69,17 +70,19 @@ function OpenAPISchemaProperty(
); );
} }
if (alternatives) { if (alternatives?.schemas) {
const { schemas, discriminator } = alternatives;
return ( return (
<div className="openapi-schema-alternatives"> <div className="openapi-schema-alternatives">
{alternatives.map((alternativeSchema, index) => ( {schemas.map((alternativeSchema, index) => (
<div key={index} className="openapi-schema-alternative"> <div key={index} className="openapi-schema-alternative">
<OpenAPISchemaAlternative <OpenAPISchemaAlternative
schema={alternativeSchema} schema={alternativeSchema}
discriminator={discriminator}
circularRefs={circularRefs} circularRefs={circularRefs}
context={context} context={context}
/> />
{index < alternatives.length - 1 ? ( {index < schemas.length - 1 ? (
<OpenAPISchemaAlternativeSeparator <OpenAPISchemaAlternativeSeparator
schema={schema} schema={schema}
context={context} context={context}
@@ -228,11 +231,12 @@ export function OpenAPIRootSchemaFromServer(props: {
*/ */
function OpenAPISchemaAlternative(props: { function OpenAPISchemaAlternative(props: {
schema: OpenAPIV3.SchemaObject; schema: OpenAPIV3.SchemaObject;
discriminator: OpenAPIV3.DiscriminatorObject | undefined;
circularRefs: CircularRefsIds; circularRefs: CircularRefsIds;
context: OpenAPIClientContext; context: OpenAPIClientContext;
}) { }) {
const { schema, circularRefs, context } = props; const { schema, discriminator, circularRefs, context } = props;
const properties = getSchemaProperties(schema); const properties = getSchemaProperties(schema, discriminator);
return properties?.length ? ( return properties?.length ? (
<OpenAPIDisclosure <OpenAPIDisclosure
@@ -359,7 +363,7 @@ export function OpenAPISchemaPresentation(props: {
context: OpenAPIClientContext; context: OpenAPIClientContext;
}) { }) {
const { const {
property: { schema, propertyName, required }, property: { schema, propertyName, required, isDiscriminatorProperty },
context, context,
} = props; } = props;
@@ -372,6 +376,7 @@ export function OpenAPISchemaPresentation(props: {
schema={schema} schema={schema}
type={getSchemaTitle(schema)} type={getSchemaTitle(schema)}
propertyName={propertyName} propertyName={propertyName}
isDiscriminatorProperty={isDiscriminatorProperty}
required={required} required={required}
context={context} context={context}
/> />
@@ -414,13 +419,19 @@ export function OpenAPISchemaPresentation(props: {
/** /**
* Get the sub-properties of a schema. * Get the sub-properties of a schema.
*/ */
function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISchemaPropertyEntry[] { function getSchemaProperties(
schema: OpenAPIV3.SchemaObject,
discriminator?: OpenAPIV3.DiscriminatorObject | undefined
): null | OpenAPISchemaPropertyEntry[] {
// check array AND schema.items as this is sometimes null despite what the type indicates // check array AND schema.items as this is sometimes null despite what the type indicates
if (schema.type === 'array' && schema.items && !checkIsReference(schema.items)) { if (schema.type === 'array' && schema.items && !checkIsReference(schema.items)) {
const items = schema.items; const items = schema.items;
const itemProperties = getSchemaProperties(items); const itemProperties = getSchemaProperties(items);
if (itemProperties) { if (itemProperties) {
return itemProperties; return itemProperties.map((prop) => ({
...prop,
isDiscriminatorProperty: discriminator?.propertyName === prop.propertyName,
}));
} }
// If the items are a primitive type, we don't need to display them // If the items are a primitive type, we don't need to display them
@@ -451,6 +462,7 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
required: Array.isArray(schema.required) required: Array.isArray(schema.required)
? schema.required.includes(propertyName) ? schema.required.includes(propertyName)
: undefined, : undefined,
isDiscriminatorProperty: discriminator?.propertyName === propertyName,
schema: propertySchema, schema: propertySchema,
}); });
}); });
@@ -471,14 +483,20 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
type AlternativeType = 'oneOf' | 'allOf' | 'anyOf'; type AlternativeType = 'oneOf' | 'allOf' | 'anyOf';
type SchemaAlternatives = {
type: AlternativeType;
schemas: OpenAPIV3.SchemaObject[];
discriminator?: OpenAPIV3.DiscriminatorObject;
} | null;
/** /**
* Get the alternatives to display for a schema. * Get the alternatives to display for a schema.
*/ */
export function getSchemaAlternatives( export function getSchemaAlternatives(
schema: OpenAPIV3.SchemaObject, schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject> = new Set() ancestors: Set<OpenAPIV3.SchemaObject> = new Set()
): OpenAPIV3.SchemaObject[] | null { ): SchemaAlternatives {
// Search for alternatives in the items property if it exists // Check for nested alternatives in `items`
if ( if (
schema.items && schema.items &&
('oneOf' in schema.items || 'allOf' in schema.items || 'anyOf' in schema.items) ('oneOf' in schema.items || 'allOf' in schema.items || 'anyOf' in schema.items)
@@ -487,20 +505,21 @@ export function getSchemaAlternatives(
} }
const alternatives: const alternatives:
| [AlternativeType, (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[]] | [
AlternativeType,
(OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[],
OpenAPIV3.DiscriminatorObject?,
]
| null = (() => { | null = (() => {
if (schema.anyOf) { if (schema.anyOf) {
return ['anyOf', schema.anyOf]; return ['anyOf', schema.anyOf, schema.discriminator];
} }
if (schema.oneOf) { if (schema.oneOf) {
return ['oneOf', schema.oneOf]; return ['oneOf', schema.oneOf, schema.discriminator];
} }
if (schema.allOf) { if (schema.allOf) {
return ['allOf', schema.allOf]; return ['allOf', schema.allOf, schema.discriminator];
} }
return null; return null;
})(); })();
@@ -508,11 +527,17 @@ export function getSchemaAlternatives(
return null; return null;
} }
const [type, schemas] = alternatives; const [type, schemas, discriminator] = alternatives;
return mergeAlternatives(
return {
type, type,
flattenAlternatives(type, schemas, new Set(ancestors).add(schema)) schemas:
); mergeAlternatives(
type,
flattenAlternatives(type, schemas, new Set(ancestors).add(schema))
) ?? [],
discriminator,
};
} }
/** /**
@@ -610,10 +635,10 @@ function flattenAlternatives(
} }
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) { if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) {
const schemas = getSchemaAlternatives(schemaOrRef, ancestors); const alternatives = getSchemaAlternatives(schemaOrRef, ancestors);
if (schemas) { if (alternatives?.schemas) {
acc.push( acc.push(
...schemas.map((schema) => ({ ...alternatives.schemas.map((schema) => ({
...schema, ...schema,
required: mergeRequiredFields(schema, latestAncestor), required: mergeRequiredFields(schema, latestAncestor),
})) }))
@@ -7,6 +7,7 @@ interface OpenAPISchemaNameProps {
schema?: OpenAPIV3.SchemaObject; schema?: OpenAPIV3.SchemaObject;
propertyName?: string | React.JSX.Element; propertyName?: string | React.JSX.Element;
required?: boolean | null; required?: boolean | null;
isDiscriminatorProperty?: boolean;
type?: string; type?: string;
context: OpenAPIClientContext; context: OpenAPIClientContext;
} }
@@ -16,7 +17,7 @@ interface OpenAPISchemaNameProps {
* It includes the property name, type, required and deprecated status. * It includes the property name, type, required and deprecated status.
*/ */
export function OpenAPISchemaName(props: OpenAPISchemaNameProps) { export function OpenAPISchemaName(props: OpenAPISchemaNameProps) {
const { schema, type, propertyName, required, context } = props; const { schema, type, propertyName, required, isDiscriminatorProperty, context } = props;
const additionalItems = schema && getAdditionalItems(schema, context); const additionalItems = schema && getAdditionalItems(schema, context);
@@ -27,9 +28,18 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) {
{propertyName} {propertyName}
</span> </span>
) : null} ) : null}
{isDiscriminatorProperty ? (
<span className="openapi-schema-discriminator">
{t(context.translation, 'discriminator')}
</span>
) : null}
{type || additionalItems ? ( {type || additionalItems ? (
<span> <span>
{type ? <span className="openapi-schema-type">{type}</span> : null} {schema?.const ? (
<span className="openapi-schema-type">const: {schema?.const}</span>
) : type ? (
<span className="openapi-schema-type">{type}</span>
) : null}
{additionalItems ? ( {additionalItems ? (
<span className="openapi-schema-type">{additionalItems}</span> <span className="openapi-schema-type">{additionalItems}</span>
) : null} ) : null}
@@ -5,6 +5,7 @@ export const de = {
stability_experimental: 'Experimentell', stability_experimental: 'Experimentell',
stability_alpha: 'Alpha', stability_alpha: 'Alpha',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: 'Diskriminator',
copy_to_clipboard: 'In die Zwischenablage kopieren', copy_to_clipboard: 'In die Zwischenablage kopieren',
copied: 'Kopiert', copied: 'Kopiert',
no_content: 'Kein Inhalt', no_content: 'Kein Inhalt',
@@ -5,6 +5,7 @@ export const en = {
stability_experimental: 'Experimental', stability_experimental: 'Experimental',
stability_alpha: 'Alpha', stability_alpha: 'Alpha',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: 'Discriminator',
copy_to_clipboard: 'Copy to clipboard', copy_to_clipboard: 'Copy to clipboard',
copied: 'Copied', copied: 'Copied',
no_content: 'No content', no_content: 'No content',
@@ -5,6 +5,7 @@ export const es = {
stability_experimental: 'Experimental', stability_experimental: 'Experimental',
stability_alpha: 'Alfa', stability_alpha: 'Alfa',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: 'Discriminador',
copy_to_clipboard: 'Copiar al portapapeles', copy_to_clipboard: 'Copiar al portapapeles',
copied: 'Copiado', copied: 'Copiado',
no_content: 'Sin contenido', no_content: 'Sin contenido',
@@ -5,6 +5,7 @@ export const fr = {
stability_experimental: 'Expérimental', stability_experimental: 'Expérimental',
stability_alpha: 'Alpha', stability_alpha: 'Alpha',
stability_beta: 'Bêta', stability_beta: 'Bêta',
discriminator: 'Discriminateur',
copy_to_clipboard: 'Copier dans le presse-papiers', copy_to_clipboard: 'Copier dans le presse-papiers',
copied: 'Copié', copied: 'Copié',
no_content: 'Aucun contenu', no_content: 'Aucun contenu',
@@ -5,6 +5,7 @@ export const ja = {
stability_experimental: '実験的', stability_experimental: '実験的',
stability_alpha: 'アルファ', stability_alpha: 'アルファ',
stability_beta: 'ベータ', stability_beta: 'ベータ',
discriminator: '識別子',
copy_to_clipboard: 'クリップボードにコピー', copy_to_clipboard: 'クリップボードにコピー',
copied: 'コピー済み', copied: 'コピー済み',
no_content: 'コンテンツなし', no_content: 'コンテンツなし',
@@ -5,6 +5,7 @@ export const nl = {
stability_experimental: 'Experimenteel', stability_experimental: 'Experimenteel',
stability_alpha: 'Alfa', stability_alpha: 'Alfa',
stability_beta: 'Bèta', stability_beta: 'Bèta',
discriminator: 'Discriminator',
copy_to_clipboard: 'Kopiëren naar klembord', copy_to_clipboard: 'Kopiëren naar klembord',
copied: 'Gekopieerd', copied: 'Gekopieerd',
no_content: 'Geen inhoud', no_content: 'Geen inhoud',
@@ -5,6 +5,7 @@ export const no = {
stability_experimental: 'Eksperimentell', stability_experimental: 'Eksperimentell',
stability_alpha: 'Alfa', stability_alpha: 'Alfa',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: 'Diskriminator',
copy_to_clipboard: 'Kopier til utklippstavle', copy_to_clipboard: 'Kopier til utklippstavle',
copied: 'Kopiert', copied: 'Kopiert',
no_content: 'Ingen innhold', no_content: 'Ingen innhold',
@@ -5,6 +5,7 @@ export const pt_br = {
stability_experimental: 'Experimental', stability_experimental: 'Experimental',
stability_alpha: 'Alfa', stability_alpha: 'Alfa',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: 'Discriminador',
copy_to_clipboard: 'Copiar para a área de transferência', copy_to_clipboard: 'Copiar para a área de transferência',
copied: 'Copiado', copied: 'Copiado',
no_content: 'Sem conteúdo', no_content: 'Sem conteúdo',
@@ -5,6 +5,7 @@ export const zh = {
stability_experimental: '实验性', stability_experimental: '实验性',
stability_alpha: 'Alpha', stability_alpha: 'Alpha',
stability_beta: 'Beta', stability_beta: 'Beta',
discriminator: '判别器',
copy_to_clipboard: '复制到剪贴板', copy_to_clipboard: '复制到剪贴板',
copied: '已复制', copied: '已复制',
no_content: '无内容', no_content: '无内容',