fix(API): Return a request schema for decorator routes in /discover (backport to release-candidate/2.35.x) (#36497)

Co-authored-by: Uddish Verma <uddish.verma@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
n8n-assistant[bot]
2026-08-18 10:39:15 +00:00
committed by GitHub
parent 7f7f0a1964
commit fa781db207
4 changed files with 79 additions and 0 deletions
@@ -194,6 +194,19 @@ describe('buildDiscoverResponse', () => {
expect(createEndpoint?.requestSchema).toHaveProperty('type');
});
it('should include requestSchema for a decorator route when includeSchemas is true', async () => {
const result = await buildDiscoverResponse(['role:manage'] as ApiKeyScope[], {
includeSchemas: true,
});
const createEndpoint = result.resources.role?.endpoints.find(
(e) => e.operationId === 'createRole',
);
expect(createEndpoint).toBeDefined();
expect(createEndpoint?.requestSchema).toBeDefined();
expect(createEndpoint?.requestSchema).toHaveProperty('properties');
});
it('should not include requestSchema on GET endpoints even with includeSchemas', async () => {
const result = await buildDiscoverResponse(['tag:list'] as ApiKeyScope[], {
includeSchemas: true,
@@ -12,6 +12,7 @@ import {
scopesInRequirement,
toOpenApiPathTemplate,
} from '../../../public-api-route-resolver';
import { buildRequestBodyJsonSchema } from '../../openapi-gen/decorator-routes';
import { extractScopeFromEovHandlerChain } from '../../shared/public-api-scope-lookup';
import '../../controllers';
@@ -151,6 +152,7 @@ function buildDecoratorEndpoints(): EndpointInfo[] {
operationId: route.handlerName,
tag: route.tags?.[0] ?? 'Other',
scope: route.apiKeyScope ?? null,
requestSchema: buildRequestBodyJsonSchema(route),
}));
}
@@ -0,0 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import { resolvePublicApiRoutes } from '@/public-api/public-api-route-resolver';
import { buildRequestBodyJsonSchema, getDecoratorGeneratedOperations } from '../decorator-routes';
/**
* The build writes a request body schema into the spec. `/discover` builds the same schema at
* runtime, through `buildRequestBodyJsonSchema`. Nothing else checks that the two agree.
*/
const V1_DIR = path.resolve(__dirname, '../..');
// The build writes one spec file per route and names it after the handler method, so the handler
// name is what links a route to its file.
const SPEC_FILE_BY_HANDLER = new Map(
getDecoratorGeneratedOperations().map((operation) => [
operation.config.operationId,
operation.outputPath,
]),
);
function schemaInSpecFile(handlerName: string): unknown {
const specFile = SPEC_FILE_BY_HANDLER.get(handlerName);
if (!specFile) throw new Error(`The build generated no spec file for ${handlerName}`);
const spec = parse(fs.readFileSync(path.join(V1_DIR, specFile), 'utf8')) as {
requestBody?: { content?: Record<string, { schema?: unknown }> };
};
return spec.requestBody?.content?.['application/json']?.schema;
}
describe('buildRequestBodyJsonSchema', () => {
const routesWithBody = resolvePublicApiRoutes().filter((route) => route.requestBodyDto);
it.each(routesWithBody)('$handlerName matches its committed spec file', (route) => {
expect(buildRequestBodyJsonSchema(route)).toEqual(schemaInSpecFile(route.handlerName));
});
});
@@ -5,8 +5,10 @@ import './zod-extend';
// resolvePublicApiRoutes()
import '../controllers';
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
import type { ResponseDtoClass } from '@n8n/decorators';
import { isRecord } from '@n8n/utils/is-record';
import { UnexpectedError } from 'n8n-workflow';
import { z } from 'zod';
@@ -17,6 +19,8 @@ import {
toOpenApiPathTemplate,
} from '@/public-api/public-api-route-resolver';
const REQUEST_BODY_COMPONENT = 'RequestBody';
// Query fields backed by shared hand-written parameter files instead of being generated
const SHARED_PAGINATION_PARAMS: Record<string, { $ref: string }> = {
limit: { $ref: '../../../../shared/spec/parameters/limit.yml' },
@@ -137,6 +141,25 @@ function buildRequestBody(
};
}
/**
* Converts a route's `@Body` DTO into JSON Schema, using the same conversion the build runs when
* it writes the OpenAPI files. zod-to-openapi can only convert a schema that a registry holds, so
* this registers one under a throwaway name and then reads the result back out.
*/
export function buildRequestBodyJsonSchema(
route: ResolvedPublicApiRoute,
): Record<string, unknown> | undefined {
if (!route.requestBodyDto) return undefined;
const registry = new OpenAPIRegistry();
registry.register(REQUEST_BODY_COMPONENT, route.requestBodyDto.schema);
const { components } = new OpenApiGeneratorV3(registry.definitions).generateComponents();
const schema = components?.schemas?.[REQUEST_BODY_COMPONENT];
return isRecord(schema) ? schema : undefined;
}
/**
* Response set is derived from what `PublicApiControllerRegistry` actually does at runtime, not
* invented: the success status is the one `@ApiResponse` declares (and the same one the registry