mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
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:
committed by
GitHub
parent
7f7f0a1964
commit
fa781db207
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user