fix(API): Give a union of object schemas a root type: object in the generated spec (no-changelog) (#40409)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Uddish Verma
2026-10-08 15:24:14 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6a41a5ed39
commit 77a692a4bb
28 changed files with 144 additions and 8 deletions
@@ -129,6 +129,7 @@ responses:
- kind
- nodeType
- filePath
type: object
settings:
type: object
additionalProperties: {}
@@ -244,6 +245,7 @@ responses:
- kind
- nodeType
- filePath
type: object
settings:
type: object
additionalProperties: {}
@@ -362,6 +364,7 @@ responses:
- kind
- nodeType
- filePath
type: object
settings:
type: object
additionalProperties: {}
@@ -480,6 +483,7 @@ responses:
- kind
- nodeType
- filePath
type: object
settings:
type: object
additionalProperties: {}
@@ -595,6 +599,7 @@ responses:
- kind
- nodeType
- filePath
type: object
settings:
type: object
additionalProperties: {}
@@ -54,6 +54,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -54,6 +54,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -60,6 +60,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -61,6 +61,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -54,6 +54,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -54,6 +54,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -60,6 +60,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -61,6 +61,7 @@ requestBody:
required:
- kind
- value
type: object
required:
- id
- action
@@ -56,6 +56,7 @@ requestBody:
- username
- password
additionalProperties: false
type: object
required:
- name
- type
@@ -58,6 +58,7 @@ requestBody:
- username
- password
additionalProperties: false
type: object
additionalProperties: false
minProperties: 1
responses:
@@ -366,6 +366,7 @@ requestBody:
- type
- dsn
description: 'A log streaming destination. The `type` field selects the variant and its type-specific fields: `webhook`, `syslog`, or `sentry`.'
type: object
responses:
'200':
description: Operation successful.
@@ -372,6 +372,7 @@ responses:
- dsn
- id
description: 'A log streaming destination. The `type` field selects the variant and its type-specific fields: `webhook`, `syslog`, or `sentry`.'
type: object
description: The configured log streaming destinations.
required:
- data
@@ -358,6 +358,7 @@ requestBody:
- type
- dsn
description: 'A log streaming destination. The `type` field selects the variant and its type-specific fields: `webhook`, `syslog`, or `sentry`.'
type: object
responses:
'200':
description: Operation successful.
@@ -0,0 +1,55 @@
import { addObjectTypeToObjectUnions } from '../object-union-type';
const webhook = { type: 'object', properties: { url: { type: 'string' } } };
const syslog = { type: 'object', properties: { host: { type: 'string' } } };
describe('addObjectTypeToObjectUnions', () => {
it('adds `type: object` to a oneOf whose members are all objects', () => {
const schema = { oneOf: [webhook, syslog], description: 'A destination' };
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({
type: 'object',
oneOf: [webhook, syslog],
description: 'A destination',
});
});
it('leaves a oneOf with a non-object member unchanged', () => {
const schema = { oneOf: [webhook, { type: 'array', items: {} }] };
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({ oneOf: [webhook, { type: 'array', items: {} }] });
});
it('leaves a oneOf with a $ref member unchanged', () => {
const schema = { oneOf: [webhook, { $ref: '#/components/schemas/Syslog' }] };
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({ oneOf: [webhook, { $ref: '#/components/schemas/Syslog' }] });
});
it('normalizes a nested union under items', () => {
const schema = { type: 'array', items: { oneOf: [webhook, syslog] } };
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({ type: 'array', items: { type: 'object', oneOf: [webhook, syslog] } });
});
it('normalizes a union under a property named like a data key', () => {
const schema = {
type: 'object',
properties: { default: { oneOf: [webhook, syslog] }, enum: { oneOf: [webhook, syslog] } },
};
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({
type: 'object',
properties: {
default: { type: 'object', oneOf: [webhook, syslog] },
enum: { type: 'object', oneOf: [webhook, syslog] },
},
});
});
it('does not rewrite example data', () => {
const schema = { type: 'object', example: { oneOf: [webhook, syslog] } };
addObjectTypeToObjectUnions(schema);
expect(schema).toEqual({ type: 'object', example: { oneOf: [webhook, syslog] } });
});
});
@@ -20,7 +20,7 @@ import {
toOpenApiPathTemplate,
} from '@/public-api/public-api-route-resolver';
import { stripUntypedNullable } from './untyped-nullable';
import { applySchemaModifiers } from './schema-modifiers';
const REQUEST_BODY_COMPONENT = 'RequestBody';
@@ -194,9 +194,7 @@ export function buildRequestBodyJsonSchema(
const { components } = new OpenApiGeneratorV3(registry.definitions).generateComponents();
const schema = components?.schemas?.[REQUEST_BODY_COMPONENT];
// Keep in sync with `buildArtifactsFromRegistry`, both convert the same DTOs to OpenAPI
// and `/discover` serves this schema straight from a route at runtime.
stripUntypedNullable(schema);
applySchemaModifiers(schema);
return isRecord(schema) ? schema : undefined;
}
@@ -15,7 +15,7 @@ import {
type NamedResponseDto,
type SchemaResolver,
} from './decorator-routes';
import { stripUntypedNullable } from './untyped-nullable';
import { applySchemaModifiers } from './schema-modifiers';
const COMPONENT_SCHEMA_REF = /^#\/components\/schemas\/(.+)$/;
const SHARED_SCHEMA_DIR = 'shared/spec/schemas';
@@ -90,9 +90,7 @@ export function buildArtifactsFromRegistry(
info: { title: 'throwaway', version: '0.0.0' },
});
// zod-to-openapi emits `z.unknown()`/`z.any()` as `{ nullable: true }` with no `type` which
// express-openapi-validator rejects at schema-compile time once bundled.
stripUntypedNullable(document);
applySchemaModifiers(document);
const artifacts: GeneratedArtifact[] = [];
@@ -0,0 +1,43 @@
import { isRecord } from '@n8n/utils/is-record';
const DATA_VALUED_KEYS = new Set(['example', 'examples', 'default', 'enum']);
// The keys inside these objects are field names, not keywords. Check every value, so a field
// named `example` or `default` is not skipped as sample data.
const SCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', '$defs', 'definitions']);
/**
* zod-to-openapi writes a discriminated union as a `oneOf` with no `type`. `.openapi()` cannot add
* the `type` without replacing the union, so this function adds it to the generated document.
*/
export function addObjectTypeToObjectUnions(node: unknown): void {
if (Array.isArray(node)) {
node.forEach((item) => addObjectTypeToObjectUnions(item));
return;
}
if (!isRecord(node)) {
return;
}
const members = node.oneOf;
if (
node.type === undefined &&
Array.isArray(members) &&
members.length > 0 &&
members.every((member) => isRecord(member) && member.type === 'object')
) {
node.type = 'object';
}
for (const [key, value] of Object.entries(node)) {
if (SCHEMA_MAP_KEYS.has(key) && isRecord(value)) {
Object.values(value).forEach((schema) => addObjectTypeToObjectUnions(schema));
continue;
}
if (DATA_VALUED_KEYS.has(key)) {
continue;
}
addObjectTypeToObjectUnions(value);
}
}
@@ -0,0 +1,10 @@
import { addObjectTypeToObjectUnions } from './object-union-type';
import { stripUntypedNullable } from './untyped-nullable';
const SCHEMA_MODIFIERS = [stripUntypedNullable, addObjectTypeToObjectUnions];
export function applySchemaModifiers(node: unknown): void {
for (const modify of SCHEMA_MODIFIERS) {
modify(node);
}
}
@@ -454,6 +454,7 @@ oneOf:
required:
- kind
- project
type: object
sourceValue:
type: string
maxLength: 1000
@@ -496,6 +497,7 @@ oneOf:
- name
- scope
- consumers
type: object
accessRequirements:
type: array
items:
@@ -1253,6 +1255,7 @@ oneOf:
- from
- to
- destructive
type: object
minItems: 1
required:
- kind
@@ -1261,6 +1264,7 @@ oneOf:
- name
- consumers
- changes
type: object
warnings:
type: array
items:
@@ -1367,3 +1371,4 @@ oneOf:
- configId
- git
- status
type: object
@@ -443,6 +443,7 @@ oneOf:
- to
- destructive
description: A single change `dataTableSchemaConflictPolicy=overwrite` would make (or made) to a matched target table's schema. `destructive` operations delete the data in a column.
type: object
description: 'For `schema-incompatible`: the changes `overwrite` would make to the target table.'
missingScope:
type: string
@@ -642,3 +643,4 @@ oneOf:
- name
- violations
description: 'A workflow the content-import policy refused. Takes down the whole package rather than skipping the workflow: the import rewrites cross-workflow references to the ids each workflow would get, so dropping one leaves the workflows that call it pointing at a row nothing ever wrote.'
type: object
@@ -352,3 +352,4 @@ oneOf:
- dsn
- id
description: 'A log streaming destination. The `type` field selects the variant and its type-specific fields: `webhook`, `syslog`, or `sentry`.'
type: object
@@ -47,6 +47,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action
@@ -49,6 +49,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action
@@ -44,6 +44,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action
@@ -47,6 +47,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action
@@ -43,6 +43,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action
@@ -43,6 +43,7 @@ properties:
required:
- kind
- value
type: object
required:
- id
- action