feat(API): Support multipart request bodies on Public API controller routes (no-changelog) (#40074)

This commit is contained in:
Sam Wooler 2026-10-05 15:08:34 +00:00 committed by GitHub
parent 02f08035b4
commit 5f73c7cdcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1092 additions and 114 deletions

View File

@ -112,7 +112,7 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. |
| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. `@Body` is JSON by default; `@Body({ mediaType: 'multipart/form-data', uploadLimits })` takes a `multipart/form-data` body instead — see [Request body media types](reference.md#request-body-media-types). |
| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). |
## Authorization (easy to get wrong)

View File

@ -103,6 +103,49 @@ scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
unchanged. Tests alone cannot show this — see
[Verifying a migration](#verifying-a-migration).
## Request body media types
`@Body` defaults to `application/json`; the body is already parsed by the
app-wide `bodyParser` before the registry sees it. Declaring
`@Body({ mediaType: 'multipart/form-data', uploadLimits })` instead takes a
`multipart/form-data` body:
- `uploadLimits: () => MultipartUploadLimits` is a thunk, read once the route
handles its first request (not at startup) — read live config inside it
(e.g. `Container.get(GlobalConfig)`), don't inline a literal.
- The route's `@Body` DTO validates text fields merged with uploaded files,
not `req.body` alone. A text field and a file share the same flat object;
a file field uses `publicApiUploadedFileSchema` (`@n8n/api-types`) — a
multer-file-shaped schema that documents itself as `{ type: 'string',
format: 'binary' }`. Several files under the same field name become an
array.
- An unknown field (one the DTO doesn't declare, on a `{ strict: true }` DTO)
fails with `Unexpected form field "<name>"` — not the default "unrecognized
keys" wording.
- Multer's own parsing errors map to the same statuses express-openapi-validator
used: `413` for a size/count limit, `400` for any other multer error, `500`
— unmasked, since this is a `ResponseError` — for anything else (a
malformed body; a missing boundary gives `400` with `multipart file(s)
required`).
- The body is parsed **after** every auth/scope/license/quota gate and
**before** controller/route middlewares — a caller those gates would reject
never has their (possibly huge) body read off the socket, and a middleware
that reads `req.body` sees it already parsed.
- `/discover` shows no request schema for a multipart route (same as a legacy
multipart route today) — a client can't assume a JSON schema it never gets.
- The generator documents the body under the `multipart/form-data` content
key (not `application/json`) and adds `413` alongside `415` to the route's
documented responses automatically.
Everything above lives in `packages/cli/src/public-api/media-types/`, one
handler per media type (`REQUEST_BODY_HANDLERS` in
`media-types/request-body/index.ts`). Adding a further media type (e.g.
`application/octet-stream`) means: add it to `RequestBodyMediaOptions` in
`@n8n/decorators`'s `controller/types.ts`, write a handler implementing
`RequestBodyHandler` in `media-types/request-body/`, and register it in
`REQUEST_BODY_HANDLERS` — the registry, resolver, generator and `/discover`
need no change, since they all read the handler, not the media type.
## Migrating legacy EOV endpoints
Legacy `express-openapi-validator` endpoints live under
@ -146,8 +189,10 @@ not templates.
entry from the `off` allowlists for `no-repository-in-public-api-handler` and
`require-public-api-controller` in `packages/cli/eslint.config.mjs` (shrink-only
— never extend them).
- For complex legacy-only, multipart, or non-standard endpoints, study the
nearest existing handler first.
- A `multipart/form-data` legacy endpoint migrates onto `@Body({ mediaType:
'multipart/form-data', uploadLimits })` — see
[Request body media types](#request-body-media-types). For any other
non-standard endpoint, study the nearest existing handler first.
- Keep each field in its original position when you extract a request shape shared
by two routes, and destructure out the ones a route doesn't take. The generator
emits properties in shape order, so a moved field rewrites the `*.generated.yml`

View File

@ -231,6 +231,7 @@ export {
} from './schemas/source-controlled-file.schema';
export { policyViolationSchema, type PolicyViolation } from './schemas/policy-violation.schema';
export { publicApiUploadedFileSchema } from './schemas/public-api-uploaded-file.schema';
export {
policyCheckFailureSchema,

View File

@ -0,0 +1,43 @@
import { publicApiUploadedFileSchema } from '../public-api-uploaded-file.schema';
describe('publicApiUploadedFileSchema', () => {
const multerFile = {
fieldname: 'package',
originalname: 'export.n8np',
encoding: '7bit',
mimetype: 'application/gzip',
size: 1234,
buffer: Buffer.from('hello'),
destination: '',
filename: '',
path: '',
};
it('accepts a multer file object and strips extra keys', () => {
const result = publicApiUploadedFileSchema.safeParse(multerFile);
assert(result.success, 'Expected multer file object to be valid');
expect(result.data).toStrictEqual({
fieldname: 'package',
originalname: 'export.n8np',
mimetype: 'application/gzip',
size: 1234,
buffer: multerFile.buffer,
});
});
it('produces an invalid_type issue with received undefined for a missing value', () => {
const result = publicApiUploadedFileSchema.safeParse(undefined);
assert(!result.success, 'Expected undefined to be invalid');
expect(result.error.issues[0]).toStrictEqual({
code: 'invalid_type',
expected: 'object',
message: 'Required',
path: [],
received: 'undefined',
});
});
});

View File

@ -0,0 +1,15 @@
import '../openapi-extend';
import { z } from 'zod';
/**
* Matches a multer file object where extra keys (e.g. `encoding`) are accepted and stripped.
*/
export const publicApiUploadedFileSchema = z
.object({
fieldname: z.string(),
originalname: z.string(),
mimetype: z.string(),
size: z.number(),
buffer: z.instanceof(Uint8Array),
})
.openapi({ type: 'string', format: 'binary' });

View File

@ -48,6 +48,56 @@ describe('Args Decorators', () => {
});
});
describe('@Body options', () => {
const resolve = (controllerClass: Controller) =>
controllerRegistryMetadata.getRouteMetadata(controllerClass, 'testMethod').args[0];
it('sets no `required` or `media` for the bare form', () => {
class TestController {
testMethod(@Body _body: unknown) {}
}
expect(resolve(TestController as Controller)).toEqual({ type: 'body' });
});
it('sets `required` without `media` for `@Body({ required: true })`', () => {
class TestController {
testMethod(@Body({ required: true }) _body: unknown) {}
}
expect(resolve(TestController as Controller)).toEqual({ type: 'body', required: true });
});
it('sets `media` for `@Body({ mediaType: "multipart/form-data", uploadLimits })`', () => {
const uploadLimits = () => ({ fileSize: 1024 });
class TestController {
testMethod(@Body({ mediaType: 'multipart/form-data', uploadLimits }) _body: unknown) {}
}
expect(resolve(TestController as Controller)).toEqual({
type: 'body',
media: { mediaType: 'multipart/form-data', uploadLimits },
});
});
it('combines `required` with a declared media type', () => {
const uploadLimits = () => ({});
class TestController {
testMethod(
@Body({ required: true, mediaType: 'multipart/form-data', uploadLimits }) _body: unknown,
) {}
}
expect(resolve(TestController as Controller)).toEqual({
type: 'body',
required: true,
media: { mediaType: 'multipart/form-data', uploadLimits },
});
});
});
describe('@Param decorator', () => {
it('should set param arg with key at correct parameter index', () => {
class TestController {

View File

@ -2,7 +2,7 @@ import { Container } from '@n8n/di';
import type { ZodTypeAny } from 'zod';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Arg, Controller } from './types';
import type { Arg, Controller, RequestBodyMedia } from './types';
const ArgDecorator =
(arg: Arg): ParameterDecorator =>
@ -14,8 +14,15 @@ const ArgDecorator =
routeMetadata.args[parameterIndex] = arg;
};
export interface BodyOptions {
required?: boolean;
export type BodyOptions = { required?: boolean } & (RequestBodyMedia | { mediaType?: undefined });
function stripExtraPropertiesFromRequestBodyMedia(options: RequestBodyMedia): RequestBodyMedia {
switch (options.mediaType) {
case 'application/json':
return { mediaType: 'application/json' };
case 'multipart/form-data':
return { mediaType: 'multipart/form-data', uploadLimits: options.uploadLimits };
}
}
/** Injects the request body into the handler */
@ -35,12 +42,18 @@ export function Body(
return ArgDecorator({ type: 'body' })(targetOrOptions as object, propertyKey, parameterIndex);
}
// Factory form e.g. `@Body() body: MyDto` or `@Body({ required: true }) body: MyDto`
// Factory form e.g. `@Body() body: MyDto` or `@Body({ required: true })`
const options = targetOrOptions as BodyOptions | undefined;
return ArgDecorator({
type: 'body',
...(options?.required !== undefined && { required: options.required }),
});
const arg: Arg = { type: 'body' };
if (options?.required !== undefined) {
arg.required = options.required;
}
if (options?.mediaType !== undefined) {
arg.media = stripExtraPropertiesFromRequestBodyMedia(options);
}
return ArgDecorator(arg);
}
/** Injects the request query into the handler */

View File

@ -25,6 +25,10 @@ export type {
ErrorResponse,
HandlerName,
Method,
MultipartUploadLimits,
RequestBodyMedia,
RequestBodyMediaOptions,
RequestBodyMediaType,
ResponseDtoClass,
RouteMetadata,
StaticRouterMetadata,

View File

@ -24,8 +24,35 @@ export interface ErrorResponse {
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
/** Multer limits for a `multipart/form-data` `@Body`. Every key matches multer's own `limits` option. */
export interface MultipartUploadLimits {
fieldNameSize?: number;
fieldSize?: number;
fields?: number;
fileSize?: number;
files?: number;
parts?: number;
headerPairs?: number;
}
/**
* Accepted options for `@Body` media type. This is where supported media types are first
* registered.
*/
export interface RequestBodyMediaOptions {
'application/json': object;
'multipart/form-data': { uploadLimits: () => MultipartUploadLimits };
}
export type RequestBodyMediaType = keyof RequestBodyMediaOptions;
/** One request-body media type, tagged with `mediaType` and carrying that type's own options. */
export type RequestBodyMedia = {
[K in RequestBodyMediaType]: { mediaType: K } & RequestBodyMediaOptions[K];
}[RequestBodyMediaType];
export type Arg =
| { type: 'body'; required?: boolean }
| { type: 'body'; required?: boolean; media?: RequestBodyMedia }
| { type: 'query' }
| { type: 'param'; key: string; schema?: ZodTypeAny };

View File

@ -0,0 +1,54 @@
import type { GlobalConfig } from '@n8n/config';
import { Body, ControllerRegistryMetadata, Post, RestController } from '@n8n/decorators';
import { Container } from '@n8n/di';
import express from 'express';
import { mock } from 'vitest-mock-extended';
import type { AuthService } from '@/auth/auth.service';
import { ControllerRegistry } from '@/controller.registry';
import type { License } from '@/license';
import type { LastActiveAtService } from '@/services/last-active-at.service';
import { RateLimitService } from '@/services/rate-limit.service';
/**
* Guard test suite ensuring Internal API controllers cannot declare a multipart `@Body`.
* This is only supported for Public API controllers. We may change that in future.
*/
describe('ControllerRegistry - @Body media guard', () => {
const authService = mock<AuthService>();
const lastActiveAtService = mock<LastActiveAtService>();
function activate(): void {
authService.createAuthMiddleware.mockImplementation(() => async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
new ControllerRegistry(
mock<License>(),
authService,
mock<GlobalConfig>({ endpoints: { rest: 'rest' } }),
Container.get(ControllerRegistryMetadata),
lastActiveAtService,
new RateLimitService(),
).activate(express());
}
beforeEach(() => {
Container.set(ControllerRegistryMetadata, new ControllerRegistryMetadata());
});
it('throws for an internal route declaring a multipart @Body', () => {
@RestController('/test')
// @ts-expect-error tsc complains about unused class
class TestController {
@Post('/')
method(@Body({ mediaType: 'multipart/form-data', uploadLimits: () => ({}) }) _body: unknown) {
return {};
}
}
expect(() => activate()).toThrow(
"TestController.method declares @Body({ mediaType: 'multipart/form-data' }), which only " +
'@PublicApiController routes support.',
);
});
});

View File

@ -29,6 +29,7 @@ import { AbstractServer } from './abstract-server';
import { CorsService } from './services/cors-service';
import { LastActiveAtService } from './services/last-active-at.service';
import { RateLimitService } from './services/rate-limit.service';
import type { BodyArg } from './public-api/public-api-route-resolver';
@Service()
export class ControllerRegistry {
@ -80,6 +81,22 @@ export class ControllerRegistry {
// Register regular routes
for (const [handlerName, route] of metadata.routes) {
// Guard against internal API using non-JSON body media types.
// We may support non-JSON media types in the future, but for now they are only supported
// on `@PublicApiController` routes.
const nonJsonBodyArg = route.args.find(
(arg): arg is BodyArg =>
arg?.type === 'body' &&
arg.media !== undefined &&
arg.media.mediaType !== 'application/json',
);
if (nonJsonBodyArg) {
throw new UnexpectedError(
`${controllerClass.name}.${handlerName} declares @Body({ mediaType: ` +
`'${nonJsonBodyArg.media?.mediaType}' }), which only @PublicApiController routes support.`,
);
}
// Original handler logic for non-router routes
const argTypes = Reflect.getMetadata(
'design:paramtypes',

View File

@ -1,4 +1,4 @@
import { Z } from '@n8n/api-types';
import { publicApiUploadedFileSchema, Z } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import type { EventService } from '@n8n/backend-services';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
@ -10,12 +10,13 @@ import {
ControllerRegistryMetadata,
Deprecated,
Get,
Middleware,
Param,
Post,
ProjectScope,
RequiresUserQuota,
} from '@n8n/decorators';
import type { Controller } from '@n8n/decorators';
import type { Controller, MultipartUploadLimits } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import express from 'express';
import request from 'supertest';
@ -311,61 +312,77 @@ describe('PublicApiControllerRegistry', () => {
});
describe('request media type', () => {
function registerOptionalBodyRoute() {
@Service()
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
method(_req: unknown, _res: unknown, @Body body: OptionalWidgetBodyDto) {
return body;
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
const namesNoMediaType: Array<[string, string | undefined]> = [
['a request with no Content-Type', undefined],
['a request with an empty Content-Type', ''],
['a request with a whitespace Content-Type', ' '],
];
function postWithContentType(header: string | undefined, path: string) {
const pending = request(activate()).post(`/api/v1/widgets/${path}`);
return header === undefined ? pending : pending.set('Content-Type', header);
}
function registerBodyRoute() {
class PackageBodyDto extends Z.class(
{ package: publicApiUploadedFileSchema, workflowConflictPolicy: z.string() },
{ strict: true },
) {}
function registerWidgetsController() {
@Service()
class WidgetsPublicController {
@Post('/')
@Post('/required')
@ApiResponse(200)
method(_req: unknown, _res: unknown, @Body body: WidgetBodyDto) {
required(_req: unknown, _res: unknown, @Body body: WidgetBodyDto) {
return body;
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
}
function registerRequiredOptionalBodyRoute() {
@Service()
class WidgetsPublicController {
@Post('/')
@Post('/optional')
@ApiResponse(200)
method(
optional(_req: unknown, _res: unknown, @Body body: OptionalWidgetBodyDto) {
return body;
}
@Post('/required-optional')
@ApiResponse(200)
requiredOptional(
_req: unknown,
_res: unknown,
@Body({ required: true }) body: OptionalWidgetBodyDto,
) {
return body;
}
@Post('/upload')
@ApiResponse(200)
upload(
_req: unknown,
_res: unknown,
@Body({ mediaType: 'multipart/form-data', uploadLimits: () => ({}) })
body: PackageBodyDto,
) {
return { workflowConflictPolicy: body.workflowConflictPolicy, size: body.package.size };
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
}
it('accepts application/json', async () => {
registerBodyRoute();
beforeEach(() => {
registerWidgetsController();
});
it('accepts application/json', async () => {
await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required')
.set('Content-Type', 'application/json')
.send({ name: 'a' })
.expect(200);
});
it('accepts application/json with parameters', async () => {
registerBodyRoute();
await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required')
.set('Content-Type', 'application/json; charset=utf-8')
.send({ name: 'a' })
.expect(200);
@ -379,10 +396,8 @@ describe('PublicApiControllerRegistry', () => {
['text/PlAiN; charset=UTF-8', 'text/plain; charset=utf-8'],
['multipart/form-data; boundary=XYZ', 'multipart/form-data'],
])('rejects %s with 415', async (sent, reported) => {
registerBodyRoute();
const response = await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required')
.set('Content-Type', sent)
.send('a')
.expect(415);
@ -391,10 +406,8 @@ describe('PublicApiControllerRegistry', () => {
});
it('rejects a non-JSON media type when no body follows', async () => {
registerBodyRoute();
const response = await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required')
.set('Content-Type', 'application/x-www-form-urlencoded')
.expect(415);
@ -403,31 +416,15 @@ describe('PublicApiControllerRegistry', () => {
);
});
const namesNoMediaType: Array<[string, string | undefined]> = [
['a request with no Content-Type', undefined],
['a request with an empty Content-Type', ''],
['a request with a whitespace Content-Type', ' '],
];
function postWithContentType(header: string | undefined) {
const pending = request(activate()).post('/api/v1/widgets');
return header === undefined ? pending : pending.set('Content-Type', header);
}
it.each(namesNoMediaType)(
'accepts %s when every body field is optional',
async (_label, header) => {
registerOptionalBodyRoute();
await postWithContentType(header).expect(200);
await postWithContentType(header, 'optional').expect(200);
},
);
it.each(namesNoMediaType)('rejects %s when the body is required', async (_label, header) => {
registerBodyRoute();
const response = await postWithContentType(header).expect(415);
const response = await postWithContentType(header, 'required').expect(415);
expect(response.body.message).toBe('unsupported media type undefined');
});
@ -435,33 +432,178 @@ describe('PublicApiControllerRegistry', () => {
it.each(namesNoMediaType)(
'rejects %s when @Body({ required: true }) overrides an otherwise-optional DTO',
async (_label, header) => {
registerRequiredOptionalBodyRoute();
const response = await postWithContentType(header).expect(415);
const response = await postWithContentType(header, 'required-optional').expect(415);
expect(response.body.message).toBe('unsupported media type undefined');
},
);
it('accepts application/json with an empty object when @Body({ required: true }) is set', async () => {
registerRequiredOptionalBodyRoute();
await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required-optional')
.set('Content-Type', 'application/json')
.send({})
.expect(200);
});
it('accepts application/json carrying an unrelated parameter', async () => {
registerBodyRoute();
await request(activate())
.post('/api/v1/widgets')
.post('/api/v1/widgets/required')
.set('Content-Type', 'application/json; Foo=BAR')
.send({ name: 'a' })
.expect(200);
});
describe('multipart request bodies', () => {
it('parses a multipart body, merging the uploaded file with text fields', async () => {
const response = await request(activate())
.post('/api/v1/widgets/upload')
.field('workflowConflictPolicy', 'new-version')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(200);
expect(response.body).toEqual({ workflowConflictPolicy: 'new-version', size: 5 });
});
it('rejects a JSON body with 415 for a multipart-only route', async () => {
const response = await request(activate())
.post('/api/v1/widgets/upload')
.set('Content-Type', 'application/json')
.send({ workflowConflictPolicy: 'new-version' })
.expect(415);
expect(response.body.message).toBe('unsupported media type application/json');
});
it('rejects a request missing the required file with 400', async () => {
const response = await request(activate())
.post('/api/v1/widgets/upload')
.field('workflowConflictPolicy', 'new-version')
.expect(400);
expect(response.body.message).toBe("request/body must have required property 'package'");
});
it('rejects an unknown form field with 400', async () => {
const response = await request(activate())
.post('/api/v1/widgets/upload')
.field('workflowConflictPolicy', 'new-version')
.field('evil', 'x')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(400);
expect(response.body.message).toBe('Unexpected form field "evil"');
});
function registerUploadLimitedRoute(uploadLimits: () => MultipartUploadLimits) {
@Service()
class WidgetsUploadLimitedPublicController {
@Post('/upload-limited')
@ApiResponse(200)
method(
_req: unknown,
_res: unknown,
@Body({ mediaType: 'multipart/form-data', uploadLimits }) body: PackageBodyDto,
) {
return { workflowConflictPolicy: body.workflowConflictPolicy, size: body.package.size };
}
}
markPublicApiController(WidgetsUploadLimitedPublicController as Controller, '/widgets');
}
it('rejects an oversized file with 413', async () => {
registerUploadLimitedRoute(() => ({ fileSize: 2 }));
const response = await request(activate())
.post('/api/v1/widgets/upload-limited')
.field('workflowConflictPolicy', 'new-version')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(413);
expect(response.body.message).toBe('File too large');
});
it('keeps 401 ahead of multipart parsing for an unauthenticated caller', async () => {
authStrategyRegistry.authenticate.mockResolvedValue(false);
registerUploadLimitedRoute(() => ({ fileSize: 2 }));
// The file is bigger than the configured limit; a 413 here would mean the body was
// parsed before authentication ran.
await request(activate())
.post('/api/v1/widgets/upload-limited')
.field('workflowConflictPolicy', 'new-version')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(401);
});
it('keeps the scope check ahead of multipart parsing', async () => {
authStrategyRegistry.authenticate.mockImplementation(async (req: AuthenticatedRequest) => {
req.user = authenticatedUser;
req.tokenGrant = { scopes: [], apiKeyScopes: [], subject: authenticatedUser };
return true;
});
@Service()
class WidgetsUploadScopedPublicController {
@Post('/upload-scoped')
@ApiResponse(200)
@ApiKeyScope('workflow:import')
method(
_req: unknown,
_res: unknown,
@Body({ mediaType: 'multipart/form-data', uploadLimits: () => ({ fileSize: 2 }) })
body: PackageBodyDto,
) {
return body;
}
}
markPublicApiController(WidgetsUploadScopedPublicController as Controller, '/widgets');
// Same oversized file as above: a 413 here would mean the body was parsed before the
// scope check ran.
const response = await request(activate())
.post('/api/v1/widgets/upload-scoped')
.field('workflowConflictPolicy', 'new-version')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
it('hands the parsed body to a controller middleware, not only to the handler', async () => {
const middlewareSaw: unknown[] = [];
@Service()
class WidgetsUploadMiddlewarePublicController {
@Middleware()
capture(req: express.Request, _res: express.Response, next: express.NextFunction) {
middlewareSaw.push(req.body);
next();
}
@Post('/upload-middleware')
@ApiResponse(200)
method(
_req: unknown,
_res: unknown,
@Body({ mediaType: 'multipart/form-data', uploadLimits: () => ({}) })
body: PackageBodyDto,
) {
return { ok: true, size: body.package.size };
}
}
markPublicApiController(WidgetsUploadMiddlewarePublicController as Controller, '/widgets');
await request(activate())
.post('/api/v1/widgets/upload-middleware')
.field('workflowConflictPolicy', 'new-version')
.attach('package', Buffer.from('hello'), 'export.n8np')
.expect(200);
expect(middlewareSaw).toHaveLength(1);
expect(middlewareSaw[0]).toMatchObject({ workflowConflictPolicy: 'new-version' });
});
});
});
describe('@RequiresUserQuota', () => {

View File

@ -24,6 +24,8 @@ import {
WidgetQueryDto,
WidgetResponseDto,
} from '@/public-api/__tests__/public-api-controller-test-utils';
import { JSON_REQUEST_BODY_MEDIA } from '@/public-api/media-types/request-body';
import { jsonRequestBody } from '@/public-api/media-types/request-body/json.request-body';
import {
apiKeyScopesSatisfy,
@ -61,7 +63,14 @@ describe('public-api-route-resolver', () => {
method(@Body _body: WidgetBodyDto) {}
}
expect(resolve(TestController as Controller)).toEqual([{ type: 'body', dto: WidgetBodyDto }]);
expect(resolve(TestController as Controller)).toEqual([
{
type: 'body',
dto: WidgetBodyDto,
media: JSON_REQUEST_BODY_MEDIA,
handler: jsonRequestBody,
},
]);
});
it('resolves a query arg to its Zod DTO via design:paramtypes reflection', () => {
@ -80,7 +89,12 @@ describe('public-api-route-resolver', () => {
}
expect(resolve(TestController as Controller)).toEqual([
{ type: 'body', dto: WidgetArrayResponseDto },
{
type: 'body',
dto: WidgetArrayResponseDto,
media: JSON_REQUEST_BODY_MEDIA,
handler: jsonRequestBody,
},
]);
});
@ -95,7 +109,12 @@ describe('public-api-route-resolver', () => {
expect(resolve(TestController as Controller)).toEqual([
{ type: 'param', key: 'id' },
{ type: 'body', dto: WidgetBodyDto },
{
type: 'body',
dto: WidgetBodyDto,
media: JSON_REQUEST_BODY_MEDIA,
handler: jsonRequestBody,
},
{ type: 'query', dto: WidgetQueryDto },
]);
});

View File

@ -0,0 +1,25 @@
import { UnsupportedMediaTypeError } from '@n8n/errors';
import { assertContentType } from '../content-type';
describe('assertContentType', () => {
it('ignores parameters and casing when matching', () => {
expect(
assertContentType({
header: 'Application/JSON; charset=utf-8',
expected: 'application/json',
bodyRequired: true,
}),
).toBe(true);
});
it('still throws for a mismatched media type when the body is optional', () => {
expect(() =>
assertContentType({
header: 'text/plain',
expected: 'multipart/form-data',
bodyRequired: false,
}),
).toThrow(UnsupportedMediaTypeError);
});
});

View File

@ -1,7 +1,6 @@
import type { RequestBodyMediaType } from '@n8n/decorators';
import { UnsupportedMediaTypeError } from '@n8n/errors';
const JSON_MEDIA_TYPE = 'application/json';
/**
* The media type, plus the string the legacy validator reports: lower-cased, parameters sorted by
* name, `boundary` left out.
@ -11,12 +10,18 @@ function readMediaType(header: string): { mediaType: string; reported: string }
const mediaType = rawMediaType.trim().toLowerCase();
const parameters = new Map<string, string>();
// Parameter sorting and returning of the reported string is kept only for parity with the
// EOV handler — we may be able to remove and simplify this method in future.
for (const part of parameterParts) {
const separator = part.indexOf('=');
if (separator === -1) continue;
if (separator === -1) {
continue;
}
const name = part.slice(0, separator).trim().toLowerCase();
if (name === 'boundary') continue;
if (name === 'boundary') {
continue;
}
const value = part.slice(separator + 1);
parameters.set(name, name === 'charset' ? value.toLowerCase() : value);
@ -30,19 +35,29 @@ function readMediaType(header: string): { mediaType: string; reported: string }
}
/**
* The legacy validator accepted only JSON. It reported a header that names no media type — absent,
* empty, or whitespace — as the literal `undefined`, and rejected it only when the body was
* required. Migrated routes keep both behaviours and the messages that came with them.
* Check a request's `Content-Type` against the media type `@Body` declares.
*/
export function assertJsonContentType(header: string | undefined, bodyRequired: boolean): void {
export function assertContentType({
header,
expected,
bodyRequired,
}: {
header: string | undefined;
expected: RequestBodyMediaType;
bodyRequired: boolean;
}): boolean {
const { mediaType, reported } = readMediaType(header ?? '');
if (mediaType === '') {
if (bodyRequired) throw new UnsupportedMediaTypeError('unsupported media type undefined');
return;
if (bodyRequired) {
throw new UnsupportedMediaTypeError('unsupported media type undefined');
}
return false;
}
if (mediaType !== JSON_MEDIA_TYPE) {
if (mediaType !== expected) {
throw new UnsupportedMediaTypeError(`unsupported media type ${reported}`);
}
return true;
}

View File

@ -0,0 +1,23 @@
import type { Request } from 'express';
import { z, ZodError } from 'zod';
import { jsonRequestBody } from '../json.request-body';
describe('jsonRequestBody', () => {
it('reads req.body unchanged', () => {
const req = { body: { a: 1 } } as unknown as Request;
expect(jsonRequestBody.readInput(req)).toBe(req.body);
});
it('formats a validation error the same way the shared field-level formatter does', () => {
try {
z.object({ name: z.string() }).parse({});
} catch (error) {
assert(error instanceof ZodError, 'expected a ZodError');
expect(jsonRequestBody.formatValidationError(error)).toBe(
"request/body must have required property 'name'",
);
}
});
});

View File

@ -0,0 +1,155 @@
import { BadRequestError, ContentTooLargeError, InternalServerError } from '@n8n/errors';
import type { Request } from 'express';
import { MulterError } from 'multer';
import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import { multipartRequestBody, toPublicApiError } from '../multipart.request-body';
function fakeFile(fieldname: string): Express.Multer.File {
return mock<Express.Multer.File>({ fieldname });
}
describe('toPublicApiError', () => {
it.each([
['LIMIT_FILE_SIZE', 'File too large'],
['LIMIT_FILE_COUNT', 'Too many files'],
['LIMIT_PART_COUNT', 'Too many parts'],
] as const)('maps %s to a 413 ContentTooLargeError', (code, message) => {
const error = toPublicApiError(new MulterError(code));
expect(error).toBeInstanceOf(ContentTooLargeError);
expect(error.message).toBe(message);
});
it('maps LIMIT_UNEXPECTED_FILE to a 500 InternalServerError', () => {
const error = toPublicApiError(new MulterError('LIMIT_UNEXPECTED_FILE'));
expect(error).toBeInstanceOf(InternalServerError);
expect(error.message).toBe('Unexpected field');
});
it('maps any other multer error to a 400 BadRequestError', () => {
const error = toPublicApiError(new MulterError('LIMIT_FIELD_VALUE'));
expect(error).toBeInstanceOf(BadRequestError);
expect(error.message).toBe('Field value too long');
});
it('maps a missing-boundary error to the legacy 400 message', () => {
const error = toPublicApiError(new Error('Multipart: Boundary not found'));
expect(error).toBeInstanceOf(BadRequestError);
expect(error.message).toBe('multipart file(s) required');
});
it('wraps an unrecognized error in an unmasked 500 InternalServerError', () => {
const error = toPublicApiError(new Error('Unexpected end of form'));
expect(error).toBeInstanceOf(InternalServerError);
expect(error.message).toBe('Unexpected end of form');
});
it('stringifies a thrown non-Error value', () => {
const error = toPublicApiError('disk exploded');
expect(error).toBeInstanceOf(InternalServerError);
expect(error.message).toBe('disk exploded');
});
});
describe('multipartRequestBody', () => {
describe('readInput', () => {
it('merges text fields with files', () => {
const req = {
body: { workflowConflictPolicy: 'new-version' },
files: [fakeFile('package')],
} as unknown as Request;
const input = multipartRequestBody.readInput(req) as Record<string, unknown>;
expect(input.workflowConflictPolicy).toBe('new-version');
expect(input.package).toMatchObject({ fieldname: 'package' });
});
it('collects several files under the same field name into an array', () => {
const req = {
body: {},
files: [fakeFile('selectedWorkflowIds'), fakeFile('selectedWorkflowIds')],
} as unknown as Request;
const input = multipartRequestBody.readInput(req) as Record<string, unknown>;
expect(Array.isArray(input.selectedWorkflowIds)).toBe(true);
expect((input.selectedWorkflowIds as unknown[]).length).toBe(2);
});
it('merges a files map (req.files as a Record) the same way as an array', () => {
const req = {
body: {},
files: { package: [fakeFile('package')] },
} as unknown as Request;
const input = multipartRequestBody.readInput(req) as Record<string, unknown>;
expect(input.package).toMatchObject({ fieldname: 'package' });
});
it('handles a request with no files at all', () => {
const req = { body: { a: '1' }, files: undefined } as unknown as Request;
expect(multipartRequestBody.readInput(req)).toEqual({ a: '1' });
});
it('turns a part literally named __proto__ into an own key rather than the object prototype', () => {
// Matches how multer actually builds `req.body` (`Object.create(null)`, then an assignment
// per field) - a plain `{ __proto__: 'evil' }` literal wouldn't reproduce the same hazard,
// since object-literal syntax special-cases that key as a prototype-setter instead of a key.
const body: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
body.__proto__ = 'evil';
const req = { body, files: undefined } as unknown as Request;
const input = multipartRequestBody.readInput(req) as Record<string, unknown>;
expect(Object.getPrototypeOf(input)).toBeNull();
expect(Object.prototype.hasOwnProperty.call(input, '__proto__')).toBe(true);
expect(input.__proto__).toBe('evil');
});
it('turns a file part named __proto__ into an own key as well', () => {
const req = {
body: {},
files: [fakeFile('__proto__')],
} as unknown as Request;
const input = multipartRequestBody.readInput(req) as Record<string, unknown>;
expect(Object.prototype.hasOwnProperty.call(input, '__proto__')).toBe(true);
expect(input.__proto__).toMatchObject({ fieldname: '__proto__' });
});
});
describe('formatValidationError', () => {
it('reports an unrecognized key as an unexpected form field', () => {
const schema = z.object({ workflowConflictPolicy: z.string() }).strict();
const result = schema.safeParse({ workflowConflictPolicy: 'new-version', evil: 'x' });
assert(!result.success, 'expected an unrecognized-key error');
expect(multipartRequestBody.formatValidationError(result.error)).toBe(
'Unexpected form field "evil"',
);
});
it('falls back to the default formatter for any other issue', () => {
const schema = z.object({ workflowConflictPolicy: z.string() }).strict();
const result = schema.safeParse({});
assert(!result.success, 'expected a required-property error');
expect(multipartRequestBody.formatValidationError(result.error)).toBe(
"request/body must have required property 'workflowConflictPolicy'",
);
});
});
});

View File

@ -0,0 +1,18 @@
import type { RequestBodyMedia, RequestBodyMediaType } from '@n8n/decorators';
import { jsonRequestBody } from './json.request-body';
import { multipartRequestBody } from './multipart.request-body';
import type { RequestBodyHandler } from './types';
export type { RequestBodyHandler } from './types';
/**
* One handler per request-body media type `@Body` can declare.
*/
export const REQUEST_BODY_HANDLERS: Record<RequestBodyMediaType, RequestBodyHandler> = {
'application/json': jsonRequestBody,
'multipart/form-data': multipartRequestBody,
};
/** The media type every route had before `@Body` could declare one - the default for a route that doesn't. */
export const JSON_REQUEST_BODY_MEDIA: RequestBodyMedia = { mediaType: 'application/json' };

View File

@ -0,0 +1,22 @@
import type { ZodError } from 'zod';
import { formatValidationError } from '@/public-api/public-api-validation-error';
import type { RequestBodyHandler } from './types';
/**
* The default request body handler if a media type is not specified on a route in `@Body`.
*/
export const jsonRequestBody: RequestBodyHandler = {
mediaType: 'application/json',
errorStatuses: [415],
discoverable: true,
readInput(req) {
return req.body;
},
formatValidationError(error: ZodError) {
return formatValidationError('body', error);
},
};

View File

@ -0,0 +1,11 @@
import type { MultipartUploadLimits } from '@n8n/decorators';
import type { RequestHandler } from 'express';
/**
* Builds the multer middleware that parses a `multipart/form-data` body, mirroring
* express-openapi-validator's `fileUploader`.
*/
export async function loadMultipartParser(limits: MultipartUploadLimits): Promise<RequestHandler> {
const { default: multer } = await import('multer');
return multer({ storage: multer.memoryStorage(), limits }).any();
}

View File

@ -0,0 +1,122 @@
import {
BadRequestError,
ContentTooLargeError,
InternalServerError,
UnexpectedError,
} from '@n8n/errors';
import type { Request } from 'express';
import type { MulterError } from 'multer';
import type { ZodError } from 'zod';
import { formatValidationError } from '@/public-api/public-api-validation-error';
import { loadMultipartParser } from './multipart-parser';
import type { RequestBodyHandler } from './types';
// Same regexes express-openapi-validator 5.5.3 used in legacy EOV handlers.
const PAYLOAD_TOO_BIG_CODE = /LIMIT_(FILE|PART)_(SIZE|COUNT)/;
const UNEXPECTED_FILE_CODE = /LIMIT_UNEXPECTED_FILE/;
const MISSING_BOUNDARY = /Multipart: Boundary not found/i;
function isMulterError(error: unknown): error is MulterError {
return error instanceof Error && error.name === 'MulterError';
}
/**
* Maps a multer parsing error to a serializable public API error matching the legacy EOV handlers.
*/
export function toPublicApiError(error: unknown): Error {
if (isMulterError(error)) {
if (PAYLOAD_TOO_BIG_CODE.test(error.code)) {
return new ContentTooLargeError(error.message);
}
if (UNEXPECTED_FILE_CODE.test(error.code)) {
return new InternalServerError(error.message);
}
return new BadRequestError(error.message);
}
const message = error instanceof Error ? error.message : String(error);
if (MISSING_BOUNDARY.test(message)) {
return new BadRequestError('multipart file(s) required');
}
return new InternalServerError(message);
}
function listFiles(files: Request['files']): Express.Multer.File[] {
if (!files) return [];
return Array.isArray(files) ? files : Object.values(files).flat();
}
/**
* Merges a multipart body's text fields and uploaded files into one object for the route's `@Body`
* DTO to validate. A field with several files under the same name becomes an array; a file wins over
* a text field sent under the same name.
*
* Built with `Object.create(null)` so a field named `__proto__` becomes an enumerable key instead of
* reassigning the object's prototype - the strict DTO then rejects it, instead of it silently vanishing.
*/
function mergeMultipartInput(req: Request): Record<string, unknown> {
const input: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(req.body ?? {})) {
input[key] = value;
}
const filesByField = new Map<string, Express.Multer.File[]>();
for (const file of listFiles(req.files)) {
const existing = filesByField.get(file.fieldname);
if (existing) {
existing.push(file);
} else {
filesByField.set(file.fieldname, [file]);
}
}
for (const [fieldname, files] of filesByField) {
input[fieldname] = files.length === 1 ? files[0] : files;
}
return input;
}
export const multipartRequestBody: RequestBodyHandler = {
mediaType: 'multipart/form-data',
// 500 is reachable via `toPublicApiError`
errorStatuses: [413, 415, 500],
discoverable: false,
async parseBody(media, req, res) {
if (media.mediaType !== 'multipart/form-data') {
throw new UnexpectedError(
'multipartRequestBody.parseBody called with a non-multipart media type',
);
}
const parse = await loadMultipartParser(media.uploadLimits());
await new Promise<void>((resolve, reject) => {
void parse(req, res, (error?: unknown) => {
if (error) {
reject(toPublicApiError(error));
return;
}
resolve();
});
});
},
readInput(req) {
return mergeMultipartInput(req);
},
formatValidationError(error: ZodError) {
const [issue] = error.errors;
if (issue?.code === 'unrecognized_keys' && issue.keys.length > 0) {
return `Unexpected form field "${issue.keys[0]}"`;
}
return formatValidationError('body', error);
},
};

View File

@ -0,0 +1,18 @@
import type { RequestBodyMedia, RequestBodyMediaType } from '@n8n/decorators';
import type { Request, Response } from 'express';
import type { ZodError } from 'zod';
export interface RequestBodyHandler {
/** The OpenAPI content key, and the `Content-Type` a route declaring this handler must match. */
readonly mediaType: RequestBodyMediaType;
/** Error statuses this media type can cause; documented on every route that declares it. */
readonly errorStatuses: readonly number[];
/** Whether `/discover` shows a request schema for a route declaring this media type. */
readonly discoverable: boolean;
/** Parses the body into whatever shape `readInput` later reads back. **/
parseBody?(media: RequestBodyMedia, req: Request, res: Response): Promise<void>;
/** The value the route's `@Body` DTO validates. */
readInput(req: Request): unknown;
/** The public message for a value that failed the `@Body` DTO's `.safeParse()`. */
formatValidationError(error: ZodError): string;
}

View File

@ -4,7 +4,12 @@ import type { BooleanLicenseFeature } from '@n8n/constants';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import type { AuthenticatedRequest } from '@n8n/db';
import { ControllerRegistryMetadata } from '@n8n/decorators';
import type { AccessScope, ApiKeyScopeRequirement, Controller } from '@n8n/decorators';
import type {
AccessScope,
ApiKeyScopeRequirement,
Controller,
RequestBodyMedia,
} from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import type { Request, RequestHandler, Response, Router } from 'express';
import { Router as createRouter } from 'express';
@ -12,11 +17,12 @@ import { z } from 'zod';
import type { ZodTypeAny } from 'zod';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { BadRequestError } from '@n8n/errors';
import { BadRequestError, UnsupportedMediaTypeError } from '@n8n/errors';
import { License } from '@/license';
import { assertContentType } from '@/public-api/media-types/content-type';
import type { RequestBodyHandler } from '@/public-api/media-types/request-body';
import { userHasScopes } from '@/permissions.ee/check-access';
import { USER_QUOTA_FORBIDDEN_MESSAGE } from '@/public-api/constants';
import { assertJsonContentType } from '@/public-api/public-api-media-type';
import type { ValidatedParamArg } from '@/public-api/public-api-route-resolver';
import {
apiKeyScopesSatisfy,
@ -86,25 +92,31 @@ export class PublicApiControllerRegistry {
);
const bodyArg = findBodyArg(resolvedArgs);
const bodyDto = bodyArg?.dto;
const bodyRequired = bodyDto ? (bodyArg?.required ?? isRequestBodyRequired(bodyDto)) : false;
const bodyRequired = bodyArg
? (bodyArg.required ?? isRequestBodyRequired(bodyArg.dto))
: false;
const handler = async (req: Request, res: Response) => {
if (bodyDto) assertJsonContentType(req.headers['content-type'], bodyRequired);
const args: unknown[] = [req, res];
for (const arg of resolvedArgs) {
if (arg.type === 'param') {
args.push(
arg.schema ? parsePathParam(arg.key, arg.schema, req.params) : req.params[arg.key],
);
continue;
}
const isBodyArg = arg.type === 'body' && bodyArg;
const input = isBodyArg ? bodyArg.handler.readInput(req) : req[arg.type];
const output = arg.dto.safeParse(input);
if (output.success) {
args.push(output.data);
} else {
const output = arg.dto.safeParse(req[arg.type]);
if (output.success) {
args.push(output.data);
} else {
throw new BadRequestError(formatValidationError(arg.type, output.error));
}
const message = isBodyArg
? bodyArg.handler.formatValidationError(output.error)
: formatValidationError(arg.type, output.error);
throw new BadRequestError(message);
}
}
@ -154,6 +166,11 @@ export class PublicApiControllerRegistry {
middlewares.push(this.createUserQuotaMiddleware());
}
// After every access gate, the body's Content-Type is checked and then parsed.
if (bodyArg) {
middlewares.push(this.createBodyMiddleware(bodyArg.media, bodyRequired, bodyArg.handler));
}
middlewares.push(...controllerMiddlewares, ...(route.middlewares ?? []));
const finalHandler: RequestHandler = async (req, res, next) => {
@ -237,6 +254,43 @@ export class PublicApiControllerRegistry {
};
}
private createBodyMiddleware(
media: RequestBodyMedia,
bodyRequired: boolean,
handler: RequestBodyHandler,
): RequestHandler {
return async (req, res, next) => {
let matched: boolean;
try {
matched = assertContentType({
header: req.headers['content-type'],
expected: media.mediaType,
bodyRequired,
});
} catch (error) {
sendPublicApiErrorResponse(
res,
error instanceof UnsupportedMediaTypeError ? error : new Error(String(error)),
);
return;
}
if (!matched || !handler.parseBody) {
next();
return;
}
try {
await handler.parseBody(media, req, res);
} catch (error) {
sendPublicApiErrorResponse(res, error instanceof Error ? error : new Error(String(error)));
return;
}
next();
};
}
/**
* Rejects a path param that breaks its `@Param` schema, ahead of the scope middlewares. The
* handler parses the params again to bind its arguments; by then they are known to be valid.

View File

@ -7,6 +7,7 @@ import type {
ErrorResponse,
HandlerName,
Method,
RequestBodyMedia,
ResponseDtoClass,
SuccessStatus,
} from '@n8n/decorators';
@ -16,6 +17,12 @@ import type { ApiKeyScope } from '@n8n/permissions';
import { UnexpectedError } from 'n8n-workflow';
import type { ZodTypeAny } from 'zod';
import type { RequestBodyHandler } from '@/public-api/media-types/request-body';
import {
JSON_REQUEST_BODY_MEDIA,
REQUEST_BODY_HANDLERS,
} from '@/public-api/media-types/request-body';
export const HTTP_METHODS = [
'get',
'post',
@ -29,7 +36,15 @@ export const HTTP_METHODS = [
export type HttpMethod = (typeof HTTP_METHODS)[number];
export type ParamArg = { type: 'param'; key: string; schema?: ZodTypeAny };
type BodyArg = { type: 'body'; dto: ZodClass; required?: boolean };
export type BodyArg = {
type: 'body';
dto: ZodClass;
required?: boolean;
/** Defaults to JSON when `@Body` declares no media type. */
media: RequestBodyMedia;
/** Resolved once here from `media`, so callers don't each look it up in `REQUEST_BODY_HANDLERS` themselves. */
handler: RequestBodyHandler;
};
type QueryArg = { type: 'query'; dto: ZodClass };
/** A `ParamArg` whose `@Param` declared a schema, so its value is validated before the handler. */
@ -62,6 +77,10 @@ export interface ResolvedPublicApiRoute {
requestBodyDto?: ZodClass;
/** Explicit `@Body({ required })` override; falls back to `isRequestBodyRequired` when unset. */
requestBodyRequired?: boolean;
/** The request body's media type; defaults to JSON when `@Body` declares none. Set only when `requestBodyDto` is. */
requestBodyMedia?: RequestBodyMedia;
/** The request body's media-type handler, resolved from `requestBodyMedia`. Set only when `requestBodyDto` is. */
requestBodyHandler?: RequestBodyHandler;
requestQueryDto?: ZodClass;
responseDto?: ResponseDtoClass;
/** Success status declared via `@ApiResponse` - always present, see `resolveSuccessStatus`. */
@ -126,10 +145,13 @@ export function resolveRouteArgs(
}
if (arg.type === 'body') {
const media = arg.media ?? JSON_REQUEST_BODY_MEDIA;
resolved.push({
type: 'body',
dto: paramType,
...(arg.required !== undefined && { required: arg.required }),
media,
handler: REQUEST_BODY_HANDLERS[media.mediaType],
});
continue;
}
@ -254,6 +276,8 @@ export function resolvePublicApiRoutes(): ResolvedPublicApiRoute[] {
const requestBodyArg = findBodyArg(args);
const requestBodyDto = requestBodyArg?.dto;
const requestBodyRequired = requestBodyArg?.required;
const requestBodyMedia = requestBodyArg?.media;
const requestBodyHandler = requestBodyArg?.handler;
const requestQueryDto = findQueryArg(args)?.dto;
const joined = `${prefix}${route.path}`.replace(/\/+/g, '/');
@ -268,6 +292,8 @@ export function resolvePublicApiRoutes(): ResolvedPublicApiRoute[] {
args,
requestBodyDto,
requestBodyRequired,
requestBodyMedia,
requestBodyHandler,
requestQueryDto,
responseDto: route.responseDto,
successStatus: resolveSuccessStatus(controllerClass.name, handlerName, route.successStatus),

View File

@ -139,7 +139,11 @@ function buildDecoratorEndpoints(): EndpointInfo[] {
operationId: route.handlerName,
tag: route.tags?.[0] ?? 'Other',
scope: route.apiKeyScope ?? null,
requestSchema: buildRequestBodyJsonSchema(route),
// A non-JSON body shows no request schema (`discoverable: false` on its handler), so a
// client doesn't assume one.
requestSchema: route.requestBodyHandler?.discoverable
? buildRequestBodyJsonSchema(route)
: undefined,
}));
}

View File

@ -260,6 +260,38 @@ describe('getDecoratorGeneratedOperations', () => {
});
});
it('documents a multipart body under its own content key, with 413, 415, and 500 responses', () => {
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
method(
_req: unknown,
_res: unknown,
@Body({ mediaType: 'multipart/form-data', uploadLimits: () => ({}) }) body: WidgetBodyDto,
) {
return body;
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
const [operation] = getDecoratorGeneratedOperations();
expect(operation.config.request?.body?.content).toEqual({
'multipart/form-data': { schema: WidgetBodyDto.schema },
});
expect(operation.config.responses[413]).toEqual({
$ref: '../../../../shared/spec/responses/contentTooLarge.yml',
});
expect(operation.config.responses[415]).toEqual({
$ref: '../../../../shared/spec/responses/unsupportedMediaType.yml',
});
// Multer's parsing errors can themselves 500 (an unmasked parse failure, or
// LIMIT_UNEXPECTED_FILE) - see multipart.request-body.ts's `toPublicApiError`.
expect(operation.config.responses[500]).toEqual({
$ref: '../../../../shared/spec/responses/internalServerError.yml',
});
});
it('refs the shared response file for an error status declared without a body DTO', () => {
class WidgetsPublicController {
@Get('/')

View File

@ -8,7 +8,8 @@ import { buildRequestBodyJsonSchema, getDecoratorGeneratedOperations } from '../
/**
* 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.
* runtime, through `buildRequestBodyJsonSchema`, for a route whose media type is `discoverable`
* (JSON; not multipart). Nothing else checks that the two agree.
*/
const V1_DIR = path.resolve(__dirname, '../..');
@ -21,7 +22,7 @@ const SPEC_FILE_BY_HANDLER = new Map(
]),
);
function schemaInSpecFile(handlerName: string): unknown {
function schemaInSpecFile(handlerName: string, mediaType: string): unknown {
const specFile = SPEC_FILE_BY_HANDLER.get(handlerName);
if (!specFile) throw new Error(`The build generated no spec file for ${handlerName}`);
@ -29,13 +30,19 @@ function schemaInSpecFile(handlerName: string): unknown {
requestBody?: { content?: Record<string, { schema?: unknown }> };
};
return spec.requestBody?.content?.['application/json']?.schema;
return spec.requestBody?.content?.[mediaType]?.schema;
}
describe('buildRequestBodyJsonSchema', () => {
const routesWithBody = resolvePublicApiRoutes().filter((route) => route.requestBodyDto);
const discoverableRoutesWithBody = resolvePublicApiRoutes().filter(
(route) => route.requestBodyDto && route.requestBodyHandler?.discoverable,
);
it.each(routesWithBody)('$handlerName matches its committed spec file', (route) => {
expect(buildRequestBodyJsonSchema(route)).toEqual(schemaInSpecFile(route.handlerName));
it.each(discoverableRoutesWithBody)('$handlerName matches its committed spec file', (route) => {
assert(route.requestBodyMedia, 'expected a route with a body to have a resolved media type');
expect(buildRequestBodyJsonSchema(route)).toEqual(
schemaInSpecFile(route.handlerName, route.requestBodyMedia.mediaType),
);
});
});

View File

@ -39,6 +39,7 @@ export const ERROR_RESPONSE_REFS = {
403: { $ref: '../../../../shared/spec/responses/forbidden.yml' },
404: { $ref: '../../../../shared/spec/responses/notFound.yml' },
409: { $ref: '../../../../shared/spec/responses/conflict.yml' },
413: { $ref: '../../../../shared/spec/responses/contentTooLarge.yml' },
415: { $ref: '../../../../shared/spec/responses/unsupportedMediaType.yml' },
422: { $ref: '../../../../shared/spec/responses/unprocessableEntity.yml' },
500: { $ref: '../../../../shared/spec/responses/internalServerError.yml' },
@ -62,6 +63,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record<DocumentedErrorStatus, string>
403: 'Forbidden',
404: 'The specified resource was not found.',
409: 'Conflict',
413: 'Content too large.',
415: 'Unsupported media type.',
422: 'Unprocessable Entity',
500: 'Internal server error.',
@ -156,18 +158,20 @@ function buildPathParams(route: ResolvedPublicApiRoute): z.AnyZodObject | undefi
return Object.keys(shape).length ? z.object(shape) : undefined;
}
/** A route's request body, straight from its `@Body` DTO - no field-splitting needed like query has. */
function buildRequestBody(
route: ResolvedPublicApiRoute,
): NonNullable<RouteConfig['request']>['body'] {
if (!route.requestBodyDto) return undefined;
if (!route.requestBodyDto || !route.requestBodyHandler) {
return undefined;
}
const required = route.requestBodyRequired ?? isRequestBodyRequired(route.requestBodyDto);
const handler = route.requestBodyHandler;
return {
...(required ? { required: true } : {}),
content: {
'application/json': {
[handler.mediaType]: {
schema: route.requestBodyDto.schema,
},
},
@ -229,8 +233,17 @@ function buildResponses(
if (route.requestBodyDto ?? route.requestQueryDto) {
responses[400] = ERROR_RESPONSE_REFS[400];
}
if (route.requestBodyDto) {
responses[415] = ERROR_RESPONSE_REFS[415];
if (route.requestBodyDto && route.requestBodyHandler) {
const handler = route.requestBodyHandler;
for (const status of handler.errorStatuses) {
if (!isDocumentedErrorStatus(status)) {
throw new UnexpectedError(
`Request-body media type ${handler.mediaType} declares undocumented error status ` +
`${status} - add a shared response file and register it in ERROR_RESPONSE_REFS.`,
);
}
responses[status] = ERROR_RESPONSE_REFS[status];
}
}
responses[401] = ERROR_RESPONSE_REFS[401];
if (route.apiKeyScope) {

View File

@ -12,6 +12,8 @@ PaymentRequired:
$ref: './paymentRequired.yml'
UnsupportedMediaType:
$ref: './unsupportedMediaType.yml'
ContentTooLarge:
$ref: './contentTooLarge.yml'
UnprocessableEntity:
$ref: './unprocessableEntity.yml'
InternalServerError:

View File

@ -0,0 +1 @@
description: Content too large.