fix(API): Require a JSON content type on decorator routes that take a body (#36748)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uddish Verma
2026-08-24 15:35:59 +00:00
committed by GitHub
parent fdad09e67f
commit 3bc56a4258
22 changed files with 275 additions and 4 deletions
@@ -0,0 +1,7 @@
import { ResponseError } from './abstract/response.error';
export class UnsupportedMediaTypeError extends ResponseError {
constructor(message: string, hint: string | undefined = undefined) {
super(message, 415, 415, hint);
}
}
@@ -15,6 +15,7 @@ export function markPublicApiController(controllerClass: Controller, basePath: `
}
export class WidgetBodyDto extends Z.class({ name: z.string() }) {}
export class OptionalWidgetBodyDto extends Z.class({ name: z.string().optional() }) {}
export class WidgetQueryDto extends Z.class({ q: z.string().optional() }) {}
export class WidgetResponseDto extends Z.class({ id: z.string() }) {}
/** Query DTO carrying a shared pagination field, for tests asserting `SHARED_PAGINATION_PARAMS` handling. */
@@ -15,7 +15,11 @@ import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import type { EventService } from '@/events/event.service';
import { markPublicApiController } from '@/public-api/__tests__/public-api-controller-test-utils';
import {
markPublicApiController,
OptionalWidgetBodyDto,
WidgetBodyDto,
} from '@/public-api/__tests__/public-api-controller-test-utils';
import { PublicApiControllerRegistry } from '@/public-api/public-api-controller.registry';
import type { AuthStrategyRegistry } from '@/services/auth-strategy.registry';
import type { LastActiveAtService } from '@/services/last-active-at.service';
@@ -28,6 +32,11 @@ describe('PublicApiControllerRegistry', () => {
function activate(): express.Express {
const app = express();
app.use(express.json());
// mirrors the app-wide bodyParser, which defaults an absent body to `{}`
app.use((req, _res, next) => {
req.body ??= {};
next();
});
const router = express.Router({ mergeParams: true });
new PublicApiControllerRegistry(
Container.get(ControllerRegistryMetadata),
@@ -101,7 +110,7 @@ describe('PublicApiControllerRegistry', () => {
});
describe('validation failures', () => {
class WidgetBodyDto extends Z.class({
class WidgetValidationDto extends Z.class({
name: z.string(),
active: z.undefined({ invalid_type_error: 'is read-only' }),
}) {}
@@ -111,7 +120,7 @@ describe('PublicApiControllerRegistry', () => {
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
create(_req: express.Request, _res: express.Response, @Body _body: WidgetBodyDto) {
create(_req: express.Request, _res: express.Response, @Body _body: WidgetValidationDto) {
return { ok: true };
}
}
@@ -125,4 +134,121 @@ describe('PublicApiControllerRegistry', () => {
expect(response.body.message).toBe('request/body/active is read-only');
});
});
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');
}
function registerBodyRoute() {
@Service()
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
method(_req: unknown, _res: unknown, @Body body: WidgetBodyDto) {
return body;
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
}
it('accepts application/json', async () => {
registerBodyRoute();
await request(activate())
.post('/widgets')
.set('Content-Type', 'application/json')
.send({ name: 'a' })
.expect(200);
});
it('accepts application/json with parameters', async () => {
registerBodyRoute();
await request(activate())
.post('/widgets')
.set('Content-Type', 'application/json; charset=utf-8')
.send({ name: 'a' })
.expect(200);
});
it.each([
['application/x-www-form-urlencoded', 'application/x-www-form-urlencoded'],
['application/xml', 'application/xml'],
['text/plain', 'text/plain'],
['application/octet-stream', 'application/octet-stream'],
['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('/widgets')
.set('Content-Type', sent)
.send('a')
.expect(415);
expect(response.body.message).toBe(`unsupported media type ${reported}`);
});
it('rejects a non-JSON media type when no body follows', async () => {
registerBodyRoute();
const response = await request(activate())
.post('/widgets')
.set('Content-Type', 'application/x-www-form-urlencoded')
.expect(415);
expect(response.body.message).toBe(
'unsupported media type application/x-www-form-urlencoded',
);
});
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('/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);
},
);
it.each(namesNoMediaType)('rejects %s when the body is required', async (_label, header) => {
registerBodyRoute();
const response = await postWithContentType(header).expect(415);
expect(response.body.message).toBe('unsupported media type undefined');
});
it('accepts application/json carrying an unrelated parameter', async () => {
registerBodyRoute();
await request(activate())
.post('/widgets')
.set('Content-Type', 'application/json; Foo=BAR')
.send({ name: 'a' })
.expect(200);
});
});
});
@@ -11,8 +11,11 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { userHasScopes } from '@/permissions.ee/check-access';
import { assertJsonContentType } from '@/public-api/public-api-media-type';
import {
apiKeyScopesSatisfy,
isDtoArg,
isRequestBodyRequired,
resolveRouteArgs,
resolveSuccessStatus,
} from '@/public-api/public-api-route-resolver';
@@ -59,7 +62,12 @@ export class PublicApiControllerRegistry {
route.successStatus,
);
const bodyDto = resolvedArgs.find((arg) => isDtoArg(arg, 'body'))?.dto;
const bodyRequired = bodyDto ? isRequestBodyRequired(bodyDto) : 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') {
@@ -0,0 +1,48 @@
import { UnsupportedMediaTypeError } from '@/errors/response-errors/unsupported-media-type.error';
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.
*/
function readMediaType(header: string): { mediaType: string; reported: string } {
const [rawMediaType, ...parameterParts] = header.split(';');
const mediaType = rawMediaType.trim().toLowerCase();
const parameters = new Map<string, string>();
for (const part of parameterParts) {
const separator = part.indexOf('=');
if (separator === -1) continue;
const name = part.slice(0, separator).trim().toLowerCase();
if (name === 'boundary') continue;
const value = part.slice(separator + 1);
parameters.set(name, name === 'charset' ? value.toLowerCase() : value);
}
const reported = [...parameters.entries()]
.sort(([a], [b]) => (a < b ? -1 : 1))
.reduce((out, [name, value]) => `${out}; ${name}=${value}`, mediaType);
return { mediaType, reported };
}
/**
* 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.
*/
export function assertJsonContentType(header: string | undefined, bodyRequired: boolean): void {
const { mediaType, reported } = readMediaType(header ?? '');
if (mediaType === '') {
if (bodyRequired) throw new UnsupportedMediaTypeError('unsupported media type undefined');
return;
}
if (mediaType !== JSON_MEDIA_TYPE) {
throw new UnsupportedMediaTypeError(`unsupported media type ${reported}`);
}
}
@@ -31,7 +31,7 @@ export type ResolvedRouteArg =
| { type: 'param'; key: string }
| { type: 'body' | 'query'; dto: ZodClass };
function isDtoArg(
export function isDtoArg(
arg: ResolvedRouteArg,
type: 'body' | 'query',
): arg is Extract<ResolvedRouteArg, { type: 'body' | 'query' }> {
@@ -116,6 +116,14 @@ export function resolveRouteArgs(
return resolved;
}
/**
* Whether a caller must send a body: an empty object being invalid means one is needed. Mirrors
* `requestBody.required` in the hand-written specs, without a second place to declare it.
*/
export function isRequestBodyRequired(dto: ZodClass): boolean {
return !dto.safeParse({}).success;
}
/** Every decorator route must state its success status via `@ApiResponse`. */
export function resolveSuccessStatus(
controllerName: string,
@@ -38,3 +38,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -8,6 +8,7 @@ x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
@@ -57,3 +58,5 @@ responses:
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -61,3 +61,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -8,6 +8,7 @@ x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
@@ -51,3 +52,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -14,6 +14,7 @@ parameters:
name: roleMappingRuleId
in: path
requestBody:
required: true
content:
application/json:
schema:
@@ -39,3 +40,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -45,3 +45,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -8,6 +8,7 @@ x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
@@ -46,3 +47,5 @@ responses:
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -14,6 +14,7 @@ parameters:
name: slug
in: path
requestBody:
required: true
content:
application/json:
schema:
@@ -50,3 +51,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -8,6 +8,7 @@ x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
@@ -1198,5 +1199,7 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
'422':
$ref: ../../../../shared/spec/responses/unprocessableEntity.yml
@@ -578,3 +578,5 @@ responses:
type: string
required:
- message
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -14,6 +14,7 @@ parameters:
name: workflowId
in: path
requestBody:
required: true
content:
application/json:
schema:
@@ -34,3 +35,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -14,6 +14,7 @@ parameters:
name: workflowId
in: path
requestBody:
required: true
content:
application/json:
schema:
@@ -41,3 +42,5 @@ responses:
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -20,6 +20,7 @@ import { z } from 'zod';
import {
markPublicApiController,
OptionalWidgetBodyDto,
WidgetArrayResponseDto,
WidgetBodyDto,
WidgetPaginationQueryDto,
@@ -133,6 +134,7 @@ describe('getDecoratorGeneratedOperations', () => {
expect(params?.shape).toHaveProperty('id');
expect(operation.config.request?.query).toBeDefined();
expect(operation.config.request?.body).toEqual({
required: true,
content: { 'application/json': { schema: WidgetBodyDto.schema } },
});
});
@@ -154,6 +156,7 @@ describe('getDecoratorGeneratedOperations', () => {
expect(operation.config.parameters).toBeUndefined();
expect(operation.config.request).toBeUndefined();
expect(operation.config.deprecated).toBeUndefined();
expect(operation.config.responses[415]).toBeUndefined();
expect(operation.config.responses[200]).toEqual({ description: 'Operation successful.' });
expect(operation.config.responses[401]).toEqual({
$ref: '../../../../shared/spec/responses/unauthorized.yml',
@@ -196,6 +199,34 @@ describe('getDecoratorGeneratedOperations', () => {
expect(() => getDecoratorGeneratedOperations()).toThrow(/ApiErrorResponse\(418\)/);
});
it('leaves the request body optional when every field is optional', () => {
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
method(_req: unknown, _res: unknown, @Body _body: OptionalWidgetBodyDto) {}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
const [operation] = getDecoratorGeneratedOperations();
expect(operation.config.request?.body?.required).toBeUndefined();
});
it('documents a 415 for a route that takes a request body', () => {
class WidgetsPublicController {
@Post('/')
@ApiResponse(200)
method(_req: unknown, _res: unknown, @Body _body: WidgetBodyDto) {}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
const [operation] = getDecoratorGeneratedOperations();
expect(operation.config.responses[415]).toEqual({
$ref: '../../../../shared/spec/responses/unsupportedMediaType.yml',
});
});
it('refs the shared response file for an error status declared without a body DTO', () => {
class WidgetsPublicController {
@Get('/')
@@ -14,6 +14,7 @@ import { z } from 'zod';
import type { ResolvedPublicApiRoute } from '@/public-api/public-api-route-resolver';
import {
isRequestBodyRequired,
resolvePublicApiRoutes,
scopeRequirementToString,
toOpenApiPathTemplate,
@@ -36,6 +37,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' },
415: { $ref: '../../../../shared/spec/responses/unsupportedMediaType.yml' },
422: { $ref: '../../../../shared/spec/responses/unprocessableEntity.yml' },
503: { $ref: '../../../../shared/spec/responses/serviceUnavailable.yml' },
} as const satisfies Record<number, { $ref: string }>;
@@ -57,6 +59,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record<DocumentedErrorStatus, string>
403: 'Forbidden',
404: 'The specified resource was not found.',
409: 'Conflict',
415: 'Unsupported media type.',
422: 'Unprocessable Entity',
503: 'The requested service is temporarily unavailable.',
};
@@ -156,6 +159,7 @@ function buildRequestBody(
if (!route.requestBodyDto) return undefined;
return {
...(isRequestBodyRequired(route.requestBodyDto) ? { required: true } : {}),
content: {
'application/json': {
schema: route.requestBodyDto.schema,
@@ -215,6 +219,9 @@ function buildResponses(
if (route.requestBodyDto ?? route.requestQueryDto) {
responses[400] = ERROR_RESPONSE_REFS[400];
}
if (route.requestBodyDto) {
responses[415] = ERROR_RESPONSE_REFS[415];
}
responses[401] = ERROR_RESPONSE_REFS[401];
if (route.apiKeyScope) {
responses[403] = ERROR_RESPONSE_REFS[403];
@@ -10,6 +10,8 @@ Forbidden:
$ref: './forbidden.yml'
PaymentRequired:
$ref: './paymentRequired.yml'
UnsupportedMediaType:
$ref: './unsupportedMediaType.yml'
UnprocessableEntity:
$ref: './unprocessableEntity.yml'
ServiceUnavailable:
@@ -0,0 +1 @@
description: Unsupported media type.