diff --git a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.service.test.ts b/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.service.test.ts index 79941f1b60c..0af4321298a 100644 --- a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.service.test.ts +++ b/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.service.test.ts @@ -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, diff --git a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts index e173cc30c0d..b9625921516 100644 --- a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts +++ b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts @@ -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), })); } diff --git a/packages/cli/src/public-api/v1/openapi-gen/__tests__/request-body-schema-parity.test.ts b/packages/cli/src/public-api/v1/openapi-gen/__tests__/request-body-schema-parity.test.ts new file mode 100644 index 00000000000..8dc42161f09 --- /dev/null +++ b/packages/cli/src/public-api/v1/openapi-gen/__tests__/request-body-schema-parity.test.ts @@ -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 }; + }; + + 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)); + }); +}); diff --git a/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts b/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts index 09f52742589..45e639e4687 100644 --- a/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts +++ b/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts @@ -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 = { 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 | 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