mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
Merge branch 'master' into eval-mock-notes-data-only
Conflict in workflow-analysis.ts: keep master's binary-file rule (with #40725's "text" sentence) and this branch's rules 3-5. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -109,7 +109,7 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
|
||||
| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
|
||||
| `@ApiKeyScope('res:action')` | API-key grant check. |
|
||||
| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
|
||||
| `@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. |
|
||||
| `@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 or a binary body — throws. For a binary response body (returned as `{ body, headers? }`), use `@ApiResponse(status, { mediaType, description?, headers? })` — see [Binary response bodies](reference.md#binary-response-bodies). |
|
||||
| `@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. `@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). |
|
||||
|
||||
@@ -146,6 +146,35 @@ handler per media type (`REQUEST_BODY_HANDLERS` in
|
||||
`REQUEST_BODY_HANDLERS` — the registry, resolver, generator and `/discover`
|
||||
need no change, since they all read the handler, not the media type.
|
||||
|
||||
## Binary response bodies
|
||||
|
||||
`@ApiResponse(status, { mediaType, description?, headers? })` declares a
|
||||
success body that the controller method returns as `{ body, headers? }`, for
|
||||
example an `application/gzip` stream. The framework treats options whose
|
||||
`mediaType` is in `BINARY_RESPONSE_MEDIA_TYPES` as a binary response.
|
||||
`mediaType` accepts only those types.
|
||||
|
||||
- `body` is a `Buffer` or a `Readable`. The method returns the result and never
|
||||
writes to `res`.
|
||||
- `headers` holds the response headers. Every header declared in `headers` must
|
||||
be in the result. A missing header fails the request with a `500`. Matching is
|
||||
case-insensitive.
|
||||
- The registry checks the headers, then sets the declared status, the headers,
|
||||
and `Content-Type: <mediaType>`. A header in the result cannot replace the
|
||||
`Content-Type`.
|
||||
- A stream's first chunk is read before any header is set. So a stream that
|
||||
fails before its first chunk gives the normal JSON error response, with no
|
||||
binary `Content-Type`. A method that throws before it returns does the same.
|
||||
- After the first chunk is sent, an error ends the response. A client that
|
||||
disconnects is not an error, and the framework destroys the stream.
|
||||
- A method that returns no binary body fails with a `500`.
|
||||
- The generator documents the body as `{ type: string, format: binary }`
|
||||
under the `mediaType` content key, with the description and headers.
|
||||
The description defaults to `Operation successful.`.
|
||||
|
||||
The runtime part is `sendBinaryResponse` in
|
||||
`packages/cli/src/public-api/media-types/binary-response.ts`.
|
||||
|
||||
## Migrating legacy EOV endpoints
|
||||
|
||||
Legacy `express-openapi-validator` endpoints live under
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod';
|
||||
import { ApiResponse } from '../api-response';
|
||||
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
|
||||
import { Get } from '../route';
|
||||
import type { Controller } from '../types';
|
||||
import type { BinaryResponse, Controller } from '../types';
|
||||
|
||||
const ExampleDto = Z.class({
|
||||
id: z.string(),
|
||||
@@ -64,4 +64,62 @@ describe('@ApiResponse Decorator', () => {
|
||||
expect(route.successStatus).toBe(204);
|
||||
expect(route.responseDto).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should store binary response metadata and leave responseDto undefined', () => {
|
||||
const binaryOptions: BinaryResponse = {
|
||||
mediaType: 'application/gzip',
|
||||
description: 'Compressed archive',
|
||||
headers: {
|
||||
'X-Archive-Size': { description: 'Size of the archive' },
|
||||
},
|
||||
};
|
||||
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, binaryOptions)
|
||||
async handler() {}
|
||||
}
|
||||
|
||||
const route = controllerRegistryMetadata.getRouteMetadata(
|
||||
TestController as Controller,
|
||||
'handler',
|
||||
);
|
||||
expect(route.binaryResponse).toEqual(binaryOptions);
|
||||
expect(route.successStatus).toBe(200);
|
||||
expect(route.responseDto).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject a binary response with an unsupported media type', () => {
|
||||
expect(() => {
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'text/csv' } as never)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('unsupported binary media type "text/csv"');
|
||||
});
|
||||
|
||||
it('should reject a body that is neither a response DTO nor binary options', () => {
|
||||
expect(() => {
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {} as never)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('neither a response DTO nor binary options');
|
||||
});
|
||||
|
||||
it('should reject 204 with binary response', () => {
|
||||
expect(() => {
|
||||
const binaryOptions = { mediaType: 'application/gzip' } as never;
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(204, binaryOptions)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('declares a 204 @ApiResponse with a binary body');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller, ResponseDtoClass, SuccessStatus } from './types';
|
||||
import {
|
||||
BINARY_RESPONSE_MEDIA_TYPES,
|
||||
type BinaryResponse,
|
||||
type Controller,
|
||||
type ResponseDtoClass,
|
||||
type SuccessStatus,
|
||||
} from './types';
|
||||
|
||||
function hasMediaType(body: unknown): body is { mediaType: unknown } {
|
||||
return typeof body === 'object' && body !== null && 'mediaType' in body;
|
||||
}
|
||||
|
||||
function isBinaryResponse(body: unknown): body is BinaryResponse {
|
||||
return (
|
||||
hasMediaType(body) &&
|
||||
BINARY_RESPONSE_MEDIA_TYPES.some((mediaType) => mediaType === body.mediaType)
|
||||
);
|
||||
}
|
||||
|
||||
function isResponseDto(body: unknown): body is ResponseDtoClass {
|
||||
return (
|
||||
(typeof body === 'function' || (typeof body === 'object' && body !== null)) && 'parse' in body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares what a route returns on success: its HTTP status, and its public output DTO if it sends a
|
||||
* body. Only one @ApiResponse decorator should be present per endpoint otherwise an error will be thrown.
|
||||
* Declares what a route returns on success: its HTTP status, and either its public output DTO or a
|
||||
* binary body that the controller method returns.
|
||||
*
|
||||
* Only one @ApiResponse decorator should be present per endpoint otherwise an error will be thrown.
|
||||
*/
|
||||
export const ApiResponse =
|
||||
(status: SuccessStatus, dto?: ResponseDtoClass): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
export function ApiResponse(status: SuccessStatus, dto?: ResponseDtoClass): MethodDecorator;
|
||||
export function ApiResponse(
|
||||
status: Exclude<SuccessStatus, 204>,
|
||||
binary: BinaryResponse,
|
||||
): MethodDecorator;
|
||||
export function ApiResponse(
|
||||
status: SuccessStatus,
|
||||
body?: ResponseDtoClass | BinaryResponse,
|
||||
): MethodDecorator {
|
||||
return (target, handlerName) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
@@ -23,12 +55,32 @@ export const ApiResponse =
|
||||
}
|
||||
|
||||
// HTTP 204 No Content responses must not have a body
|
||||
if (status === 204 && dto !== undefined) {
|
||||
if (status === 204 && body !== undefined) {
|
||||
const bodyKind = isBinaryResponse(body) ? 'a binary body' : 'a response DTO';
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares a 204 @ApiResponse with a response DTO - a 204 response must not have a body`,
|
||||
`${String(handlerName)} declares a 204 @ApiResponse with ${bodyKind} - a 204 response must not have a body`,
|
||||
);
|
||||
}
|
||||
|
||||
routeMetadata.successStatus = status;
|
||||
routeMetadata.responseDto = dto;
|
||||
|
||||
if (isBinaryResponse(body)) {
|
||||
routeMetadata.binaryResponse = body;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMediaType(body)) {
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares an unsupported binary media type "${String(body.mediaType)}" - supported: ${BINARY_RESPONSE_MEDIA_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (body !== undefined && !isResponseDto(body)) {
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares an @ApiResponse body that is neither a response DTO nor binary options`,
|
||||
);
|
||||
}
|
||||
|
||||
routeMetadata.responseDto = body;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export type {
|
||||
AccessScope,
|
||||
ApiKeyScopeRequirement,
|
||||
Arg,
|
||||
BinaryResponse,
|
||||
BinaryResponseMediaType,
|
||||
BinaryResult,
|
||||
Controller,
|
||||
CorsOptions,
|
||||
DeprecationInfo,
|
||||
@@ -30,6 +33,7 @@ export type {
|
||||
RequestBodyMediaOptions,
|
||||
RequestBodyMediaType,
|
||||
ResponseDtoClass,
|
||||
ResponseHeader,
|
||||
RouteMetadata,
|
||||
StaticRouterMetadata,
|
||||
SuccessStatus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ZodClass } from '@n8n/api-types';
|
||||
import type { BooleanLicenseFeature } from '@n8n/constants';
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type { ApiKeyScope, Scope } from '@n8n/permissions';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { RequestHandler, Router } from 'express';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
@@ -14,6 +15,24 @@ export type ApiKeyScopeRequirement =
|
||||
|
||||
export type ResponseDtoClass = Pick<ZodClass, 'parse'>;
|
||||
|
||||
export const BINARY_RESPONSE_MEDIA_TYPES = ['application/gzip'] as const;
|
||||
export type BinaryResponseMediaType = (typeof BINARY_RESPONSE_MEDIA_TYPES)[number];
|
||||
|
||||
export interface ResponseHeader {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BinaryResponse {
|
||||
mediaType: BinaryResponseMediaType;
|
||||
description?: string;
|
||||
headers?: Record<string, ResponseHeader>;
|
||||
}
|
||||
|
||||
export interface BinaryResult {
|
||||
body: Buffer | Readable;
|
||||
headers?: Record<string, string | number | readonly string[]>;
|
||||
}
|
||||
|
||||
export type SuccessStatus = 200 | 201 | 202 | 204;
|
||||
|
||||
export interface ErrorResponse {
|
||||
@@ -99,6 +118,8 @@ export interface RouteMetadata {
|
||||
accessScope?: AccessScope;
|
||||
apiKeyScope?: ApiKeyScopeRequirement;
|
||||
responseDto?: ResponseDtoClass;
|
||||
/** Mutually exclusive with `responseDto`. */
|
||||
binaryResponse?: BinaryResponse;
|
||||
/** OpenAPI HTTP status sent on success, and documented as such. */
|
||||
successStatus?: SuccessStatus;
|
||||
/** OpenAPI operation summary. */
|
||||
|
||||
@@ -1883,6 +1883,48 @@ describe('EvalExecutionService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the embedded text into a synthesized PDF', async () => {
|
||||
const hints = makeEmptyHints();
|
||||
hints.triggerContent = {
|
||||
body: {},
|
||||
binary: {
|
||||
attachment_0: {
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'invoice.pdf',
|
||||
text: 'Invoice Number: INV-2026-0042',
|
||||
},
|
||||
},
|
||||
};
|
||||
generateMockHintsMock.mockResolvedValue(hints);
|
||||
|
||||
await service.executeWithLlmMock('wf-1', makeUser());
|
||||
|
||||
const runData = workflowRunner.run.mock.calls[0][0];
|
||||
const item = runData.pinData?.['Webhook']?.[0];
|
||||
const pdf = Buffer.from(item?.binary?.attachment_0.data ?? '', 'base64');
|
||||
expect(pdf.toString('latin1')).toContain('(Invoice Number: INV-2026-0042) Tj');
|
||||
});
|
||||
|
||||
it('does not add the requirement key when the embedded map names other files', async () => {
|
||||
const hints = makeEmptyHints();
|
||||
hints.triggerContent = {
|
||||
body: {},
|
||||
binary: { CV_0: { mimeType: 'application/pdf', fileName: 'cv.pdf' } },
|
||||
};
|
||||
generateMockHintsMock.mockResolvedValue(hints);
|
||||
detectBinaryDependenciesMock.mockReturnValueOnce({
|
||||
propertyName: 'data',
|
||||
contentType: 'application/octet-stream',
|
||||
filename: 'input.bin',
|
||||
});
|
||||
|
||||
await service.executeWithLlmMock('wf-1', makeUser());
|
||||
|
||||
const runData = workflowRunner.run.mock.calls[0][0];
|
||||
const item = runData.pinData?.['Webhook']?.[0];
|
||||
expect(Object.keys(item?.binary ?? {})).toEqual(['CV_0']);
|
||||
});
|
||||
|
||||
it('does not create pin data when triggerContent is empty', async () => {
|
||||
const hints = makeEmptyHints();
|
||||
hints.triggerContent = {};
|
||||
|
||||
@@ -270,6 +270,23 @@ describe('createLlmMockHandler', () => {
|
||||
expect(sniffed?.ext).toBe('pdf');
|
||||
});
|
||||
|
||||
it('should write textBody into a binary PDF response', async () => {
|
||||
llmSubmits({
|
||||
type: 'binary',
|
||||
contentType: 'application/pdf',
|
||||
filename: 'invoice.pdf',
|
||||
textBody: 'Invoice Number: INV-2026-0042',
|
||||
});
|
||||
const handler = createLlmMockHandler();
|
||||
const result = await callHandler(handler);
|
||||
|
||||
const sniffed = await fileTypeFromBuffer(result.body as Buffer);
|
||||
expect(sniffed?.mime).toBe('application/pdf');
|
||||
expect((result.body as Buffer).toString('latin1')).toContain(
|
||||
'(Invoice Number: INV-2026-0042) Tj',
|
||||
);
|
||||
});
|
||||
|
||||
it('should populate content-disposition and content-length headers for binary responses', async () => {
|
||||
llmSubmits({ type: 'binary', contentType: 'image/png', filename: 'logo.png' });
|
||||
const handler = createLlmMockHandler();
|
||||
|
||||
@@ -1139,6 +1139,32 @@ describe('generateMockHints', () => {
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should unwrap trigger content that comes back as an n8n item', async () => {
|
||||
const binary = { CV_0: { mimeType: 'application/pdf', fileName: 'cv.pdf' } };
|
||||
mockAgentResponses(
|
||||
JSON.stringify({
|
||||
globalContext: '',
|
||||
triggerContent: { json: { 'Full Name': 'Ada Lovelace' }, binary },
|
||||
nodeHints: { Slack: 'post a message' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await generateMockHints({ workflow, nodeNames: ['Schedule', 'Slack'] });
|
||||
|
||||
expect(result.triggerContent).toEqual({ 'Full Name': 'Ada Lovelace', binary });
|
||||
});
|
||||
|
||||
it('should keep trigger content whose json field sits next to other fields', async () => {
|
||||
const triggerContent = { json: { id: 1 }, headers: {}, body: {} };
|
||||
mockAgentResponses(
|
||||
JSON.stringify({ globalContext: '', triggerContent, nodeHints: { Slack: 'post' } }),
|
||||
);
|
||||
|
||||
const result = await generateMockHints({ workflow, nodeNames: ['Schedule', 'Slack'] });
|
||||
|
||||
expect(result.triggerContent).toEqual(triggerContent);
|
||||
});
|
||||
|
||||
it('should retry when the first attempt returns empty triggerContent, then succeed', async () => {
|
||||
const generate = mockAgentResponses(
|
||||
JSON.stringify({ globalContext: '', triggerContent: {}, nodeHints: { Slack: 'foo' } }),
|
||||
|
||||
@@ -973,7 +973,12 @@ export class EvalExecutionService {
|
||||
const item: INodeExecutionData = { json: triggerContent as IDataObject };
|
||||
const binary: IBinaryKeyData = {};
|
||||
|
||||
if (binaryRequirement) {
|
||||
// The embedded files are what the trigger emits; the requirement only fills in when they name none.
|
||||
const embeddedKeys = Object.keys(embedded);
|
||||
if (
|
||||
binaryRequirement &&
|
||||
(embeddedKeys.length === 0 || embeddedKeys.includes(binaryRequirement.propertyName))
|
||||
) {
|
||||
// Requirement wins for its key, except embedded meta beats the generic fallback.
|
||||
const embeddedMeta = embedded[binaryRequirement.propertyName];
|
||||
const isGenericFallback =
|
||||
@@ -982,6 +987,7 @@ export class EvalExecutionService {
|
||||
binary[binaryRequirement.propertyName] = synthesizeBinaryEntry(
|
||||
(isGenericFallback && embeddedMeta?.mimeType) || binaryRequirement.contentType,
|
||||
(isGenericFallback && embeddedMeta?.fileName) || binaryRequirement.filename,
|
||||
embeddedMeta?.text,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -990,6 +996,7 @@ export class EvalExecutionService {
|
||||
binary[key] = synthesizeBinaryEntry(
|
||||
meta.mimeType ?? 'application/octet-stream',
|
||||
meta.fileName ?? 'input.bin',
|
||||
meta.text,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1538,8 +1545,8 @@ function withInterceptionGaps(
|
||||
}
|
||||
|
||||
/** Synthesize a structurally valid binary entry (real bytes, base64-inlined). */
|
||||
function synthesizeBinaryEntry(contentType: string, filename: string): IBinaryData {
|
||||
const bytes = synthesizeBinaryFixture(contentType, filename);
|
||||
function synthesizeBinaryEntry(contentType: string, filename: string, text?: string): IBinaryData {
|
||||
const bytes = synthesizeBinaryFixture(contentType, filename, { text });
|
||||
const extension = filename.includes('.') ? filename.slice(filename.lastIndexOf('.') + 1) : 'bin';
|
||||
return {
|
||||
mimeType: contentType,
|
||||
@@ -1553,6 +1560,8 @@ function synthesizeBinaryEntry(contentType: string, filename: string): IBinaryDa
|
||||
interface EmbeddedBinaryMeta {
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
/** The document's text, written into the synthesized file. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
@@ -1587,6 +1596,7 @@ function readEmbeddedBinaryMeta(
|
||||
mimeType,
|
||||
// `name` is a fileName fallback, not qualification evidence.
|
||||
fileName: strictFileName ?? nonEmptyString(v.name),
|
||||
text: nonEmptyString(v.text),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Match THIS request only (URL + method): a node may make multiple sequential call
|
||||
|
||||
For APIs that return empty responses on success (204/202), call submit_response with type="json" and body={}.
|
||||
|
||||
**Binary / file responses.** Pick \`type: "binary"\` when the request URL or node parameters indicate a file download — Telegram \`getFile\` / \`/file/bot...\`, Google Drive \`alt=media\`, Dropbox \`/files/download\`, OneDrive \`/items/{id}/content\`, S3 \`GetObject\`, OpenAI \`audio/transcriptions\` source file, or any path containing \`/download\`, \`/file\`, \`/attachment\`, \`/media\`, \`/image\`, \`/voice\`, \`/audio\`, \`/export\`. Always set \`contentType\` (real MIME like \`application/pdf\`, \`audio/ogg\`, \`image/png\`) and \`filename\` (with the correct extension). Use \`sizeHint\` only when the scenario hints mention file size constraints (e.g. "rejects files > 100KB"). Do NOT pick \`binary\` for JSON metadata endpoints like Slack \`files.upload\`, \`files.info\`, or Telegram \`getFile\` (which returns a JSON envelope describing the file — the binary comes from the follow-up \`/file/bot.../path\` request). Exception: when the document at a download/export path is itself textual (CSV, XML, HTML, plain text) and the scenario or node hints specify its contents, use \`type: "text"\` with the exact document — reserve \`binary\` for opaque formats (PDF, images, audio, video, archives).
|
||||
**Binary / file responses.** Pick \`type: "binary"\` when the request URL or node parameters indicate a file download — Telegram \`getFile\` / \`/file/bot...\`, Google Drive \`alt=media\`, Dropbox \`/files/download\`, OneDrive \`/items/{id}/content\`, S3 \`GetObject\`, OpenAI \`audio/transcriptions\` source file, or any path containing \`/download\`, \`/file\`, \`/attachment\`, \`/media\`, \`/image\`, \`/voice\`, \`/audio\`, \`/export\`. Always set \`contentType\` (real MIME like \`application/pdf\`, \`audio/ogg\`, \`image/png\`) and \`filename\` (with the correct extension). For a PDF whose content the scenario or node hints state, also put that content in \`textBody\` as plain text — the harness writes it into the PDF; without it the PDF has no text. Use \`sizeHint\` only when the scenario hints mention file size constraints (e.g. "rejects files > 100KB"). Do NOT pick \`binary\` for JSON metadata endpoints like Slack \`files.upload\`, \`files.info\`, or Telegram \`getFile\` (which returns a JSON envelope describing the file — the binary comes from the follow-up \`/file/bot.../path\` request). Exception: when the document at a download/export path is itself textual (CSV, XML, HTML, plain text) and the scenario or node hints specify its contents, use \`type: "text"\` with the exact document — reserve \`binary\` for opaque formats (PDF, images, audio, video, archives).
|
||||
|
||||
**Raw text / non-JSON responses.** When the real endpoint returns a non-JSON text document — XML/SOAP, CSV, HTML, RSS/Atom, plain text — use \`type: "text"\` and put the EXACT raw document in \`textBody\`, with \`contentType\` set to the real MIME (\`text/xml\`, \`application/xml\`, \`text/csv\`, \`text/html\`, \`text/plain\`). NEVER wrap such a document inside a JSON object like \`{"data": "<?xml..."}\` — the node consumes the raw text, and a JSON wrapper corrupts it. SOAP endpoints return the full SOAP envelope as \`textBody\`; SOAP faults use \`type: "error"\` with the fault XML in \`textBody\`.`;
|
||||
|
||||
@@ -630,7 +630,7 @@ const submitResponseSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The raw text document exactly as the service would send it (e.g. "<?xml version=\\"1.0\\"?>..."). Required for type="text"; allowed with type="error" when the service returns a text/XML error document. Omit otherwise.',
|
||||
'The raw text document exactly as the service would send it (e.g. "<?xml version=\\"1.0\\"?>..."). Required for type="text"; allowed with type="error" when the service returns a text/XML error document; with type="binary" and a PDF, the document\'s text as plain text. Omit otherwise.',
|
||||
),
|
||||
statusCode: z
|
||||
.number()
|
||||
@@ -1021,7 +1021,10 @@ function materializeSpec(spec: MockResponseSpec): EvalMockHttpResponse {
|
||||
case 'binary': {
|
||||
const filename = spec.filename ?? 'mock-file.dat';
|
||||
const contentType = spec.contentType ?? 'application/octet-stream';
|
||||
const body = synthesizeBinaryFixture(contentType, filename, { sizeHint: spec.sizeHint });
|
||||
const body = synthesizeBinaryFixture(contentType, filename, {
|
||||
sizeHint: spec.sizeHint,
|
||||
text: spec.textBody,
|
||||
});
|
||||
return {
|
||||
body,
|
||||
headers: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { extractJsonCandidate } from '@n8n/ai-utilities/llm-output';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import { createEvalAgent, extractText } from '@n8n/instance-ai';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
// AI root node types (single source in @n8n/workflow-sdk mock-data) — lets
|
||||
// the typo guard accept a no-sub-node Agent.
|
||||
import { isAiRootNodeType } from '@n8n/workflow-sdk';
|
||||
@@ -613,7 +614,7 @@ RULES:
|
||||
- CRITICAL: apart from a Manual Trigger, triggerContent must NEVER be an empty object ({}). Even for scenarios that test empty payloads ("empty submission", "no data", "missing fields"), emit the trigger envelope with empty *nested* fields — an empty webhook is { headers: {}, query: {}, body: {} }, a schedule with no context is { timestamp: "..." }. The workflow cannot execute without trigger output. The one exception is a polling or event trigger that the scenario says has NOTHING to emit ("no new emails", "no new rows", "no results"): then set "triggerEmitsNoItems": true and omit triggerContent — the harness pins the trigger to zero items so downstream nodes do not run.
|
||||
- CRITICAL: check what downstream nodes reference (e.g., $json.body.email, $json.subject, $json.text) and ensure those paths exist in triggerContent
|
||||
- CRITICAL: when the workflow has MULTIPLE trigger nodes, pick the ONE the Test Scenario targets (the trigger whose firing the scenario describes, e.g. "The weekly Schedule Trigger fires") and return its exact node name in a "startNodeName" field. triggerContent must be THAT trigger's output.
|
||||
- CRITICAL: triggerContent must NEVER contain binary file CONTENT — no base64 blobs, no fake file-bytes placeholders. When the trigger carries a file (form upload, email attachment, incoming media), declare it with a METADATA-ONLY binary map instead: "binary": { "<propertyKey>": { "mimeType": "<real MIME>", "fileName": "<name.ext>" } } — the harness synthesizes real file bytes from that metadata and attaches them at the item level. The MIME type and file name MUST match the scenario: an image/png upload scenario needs mimeType "image/png" and a .png fileName, never a generic application/octet-stream. Use "data" as the propertyKey unless downstream nodes reference a different binary property name.
|
||||
- CRITICAL: triggerContent must NEVER contain binary file CONTENT — no base64 blobs, no fake file-bytes placeholders. When the trigger carries a file (form upload, email attachment, incoming media), declare it with a METADATA-ONLY binary map instead: "binary": { "<propertyKey>": { "mimeType": "<real MIME>", "fileName": "<name.ext>" } } — the harness synthesizes real file bytes from that metadata and attaches them at the item level. For a PDF or text file whose words the scenario states, also add "text": "<those words as plain text>" to its entry — the harness writes them into the file. The MIME type and file name MUST match the scenario: an image/png upload scenario needs mimeType "image/png" and a .png fileName, never a generic application/octet-stream. Use "data" as the propertyKey unless downstream nodes reference a different binary property name.
|
||||
3. Create a "nodeHints" object with one entry per node that calls an external service. Each hint states the DATA the service holds for that node: the records, field values, counts, IDs, dates and statuses, using entities from the global context. One node can send several requests (for example it reads a sheet's metadata and rows, then appends a row). Write each hint in two parts: "Before the node runs:" with what the service already holds, then "The node writes:" with what the node adds, changes or sends (or "nothing"). A read that happens before the write sees only the "Before the node runs" data.
|
||||
4. A hint states data, never a response layout. Do not write JSON, braces, field paths, wrapper keys, status envelopes or "return {...}". Do not describe what the n8n node outputs after its own processing (simplified records, rows keyed by column header, parsed JSON, fields such as json.data or output[0].content[0].text). The mock server writes each response in the real API's wire format and only needs the facts. Write facts as plain sentences, e.g. "Sheet 'Leads' has columns Name, Email, Status and 2 rows: Ann Lee, [email protected], new; Bo Chan, [email protected], won."
|
||||
5. Ensure data flows logically through the workflow. If node A fetches items that node B processes, A's hint lists the items B needs. Expressions such as $json.field read the node's OUTPUT, not the API response: use them to know which facts matter, not how to lay them out.
|
||||
@@ -706,6 +707,14 @@ function isManualTriggerNode(workflow: IWorkflowBase, nodeName?: string): boolea
|
||||
return node?.type === MANUAL_TRIGGER_NODE_TYPE && !node.disabled;
|
||||
}
|
||||
|
||||
/** The model sometimes answers with an n8n item (`{ json, binary }`) instead of the item's json. */
|
||||
function unwrapItemShape(content: object): object {
|
||||
if (!isRecord(content)) return content;
|
||||
const { json, ...rest } = content;
|
||||
if (!isRecord(json) || Object.keys(rest).some((key) => key !== 'binary')) return content;
|
||||
return rest.binary === undefined ? json : { ...json, binary: rest.binary };
|
||||
}
|
||||
|
||||
/** One LLM call → globalContext + triggerContent + per-node hints. Retried once on structural issues. */
|
||||
export async function generateMockHints(options: GenerateMockHintsOptions): Promise<MockHints> {
|
||||
const { workflow, nodeNames, scenarioHints } = options;
|
||||
@@ -761,12 +770,13 @@ export async function generateMockHints(options: GenerateMockHintsOptions): Prom
|
||||
) {
|
||||
reason = `invalid nodeHints structure (raw: ${text.slice(0, 200)})`;
|
||||
} else {
|
||||
const triggerContent =
|
||||
const triggerContent = unwrapItemShape(
|
||||
typeof parsed.triggerContent === 'object' &&
|
||||
parsed.triggerContent !== null &&
|
||||
!Array.isArray(parsed.triggerContent)
|
||||
parsed.triggerContent !== null &&
|
||||
!Array.isArray(parsed.triggerContent)
|
||||
? parsed.triggerContent
|
||||
: {};
|
||||
: {},
|
||||
);
|
||||
// The model answers a bool as a word often enough to read both spellings.
|
||||
const triggerEmitsNoItems =
|
||||
parsed.triggerEmitsNoItems === true || parsed.triggerEmitsNoItems === 'true';
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { Controller, MultipartUploadLimits } from '@n8n/decorators';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import express from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import request from 'supertest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { z } from 'zod';
|
||||
@@ -724,4 +725,223 @@ describe('PublicApiControllerRegistry', () => {
|
||||
expect(response.body).toEqual({ message: 'Forbidden' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary response bodies', () => {
|
||||
// supertest buffers only text and JSON bodies by default
|
||||
const readBinaryBody = (
|
||||
res: request.Response,
|
||||
callback: (error: Error | null, body: Buffer) => void,
|
||||
) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
||||
};
|
||||
|
||||
it('sends the body the method returns, with the declared status, media type and headers', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(202, {
|
||||
mediaType: 'application/gzip',
|
||||
headers: { 'X-Example': { description: 'Example header.' } },
|
||||
})
|
||||
async method() {
|
||||
return { body: Buffer.from([1, 2, 3]), headers: { 'X-Example': '1' } };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate())
|
||||
.get('/api/v1/widgets')
|
||||
.buffer(true)
|
||||
.parse(readBinaryBody)
|
||||
.expect(202);
|
||||
|
||||
expect(response.headers['content-type']).toBe('application/gzip');
|
||||
expect(response.headers['x-example']).toBe('1');
|
||||
expect(response.body).toEqual(Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('streams a returned stream', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
async method() {
|
||||
return { body: Readable.from([Buffer.from('ab'), Buffer.from('cd')]) };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate())
|
||||
.get('/api/v1/widgets')
|
||||
.buffer(true)
|
||||
.parse(readBinaryBody)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.toString()).toBe('abcd');
|
||||
});
|
||||
|
||||
it('matches a declared header name case-insensitively', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {
|
||||
mediaType: 'application/gzip',
|
||||
headers: { 'X-Example': { description: 'Example header.' } },
|
||||
})
|
||||
async method() {
|
||||
return { body: Buffer.from('x'), headers: { 'x-example': '1' } };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate())
|
||||
.get('/api/v1/widgets')
|
||||
.buffer(true)
|
||||
.parse(readBinaryBody)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['x-example']).toBe('1');
|
||||
});
|
||||
|
||||
it('keeps the declared media type when a result header tries to replace it', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
async method() {
|
||||
return { body: Buffer.from('x'), headers: { 'Content-Type': 'text/plain' } };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate())
|
||||
.get('/api/v1/widgets')
|
||||
.buffer(true)
|
||||
.parse(readBinaryBody)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toBe('application/gzip');
|
||||
});
|
||||
|
||||
it('fails with 500 when the method returns no binary body', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
async method() {}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(500);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(response.body.message).toBe('Internal server error');
|
||||
});
|
||||
|
||||
it('fails with 500 when a declared header is missing from the result', async () => {
|
||||
@Service()
|
||||
class WidgetsBinaryHeaderPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {
|
||||
mediaType: 'application/gzip',
|
||||
headers: { 'X-Required': { description: 'Must be set.' } },
|
||||
})
|
||||
async method() {
|
||||
return { body: Buffer.from('x') };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsBinaryHeaderPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(500);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(response.body.message).toBe('Internal server error');
|
||||
});
|
||||
|
||||
it('sends a clean JSON error when the method throws before the body starts', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
async method() {
|
||||
throw new NotFoundError('missing');
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(404);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(response.body.message).toBe('missing');
|
||||
});
|
||||
|
||||
it('sends a clean JSON error when a stream fails before its first chunk', async () => {
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {
|
||||
mediaType: 'application/gzip',
|
||||
headers: { 'X-Example': { description: 'Example header.' } },
|
||||
})
|
||||
async method() {
|
||||
const failing = new Readable({
|
||||
read() {
|
||||
this.destroy(new NotFoundError('missing'));
|
||||
},
|
||||
});
|
||||
return { body: failing, headers: { 'X-Example': '1' } };
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(404);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(response.headers['x-example']).toBeUndefined();
|
||||
expect(response.body.message).toBe('missing');
|
||||
});
|
||||
|
||||
it('keeps headers set before the method on an error response', async () => {
|
||||
const since = new Date('2026-07-23T00:00:00Z');
|
||||
|
||||
@Service()
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
@Deprecated({ since })
|
||||
async method() {
|
||||
throw new NotFoundError('missing');
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(404);
|
||||
|
||||
expect(response.headers.deprecation).toBe(`@${Math.floor(since.getTime() / 1000)}`);
|
||||
});
|
||||
|
||||
it('keeps a Content-Type set by earlier middleware on an error response', async () => {
|
||||
@Service()
|
||||
class WidgetsBinaryMiddlewarePublicController {
|
||||
@Middleware()
|
||||
label(_req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
next();
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
async method() {
|
||||
throw new NotFoundError('missing');
|
||||
}
|
||||
}
|
||||
markPublicApiController(WidgetsBinaryMiddlewarePublicController as Controller, '/widgets');
|
||||
|
||||
const response = await request(activate()).get('/api/v1/widgets').expect(404);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/text\/plain/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { BinaryResponse } from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { sendBinaryResponse } from '../binary-response';
|
||||
|
||||
/** A live response mock whose `status` returns the response, as Express does. */
|
||||
function mockResponse() {
|
||||
const res = mock<Response>();
|
||||
res.status.mockReturnValue(res);
|
||||
res.destroyed = false;
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('sendBinaryResponse', () => {
|
||||
const binaryResponse: BinaryResponse = {
|
||||
mediaType: 'application/gzip',
|
||||
headers: { 'X-Required': { description: 'Must be set.' } },
|
||||
};
|
||||
|
||||
it('rejects a result that is missing a declared header, before the response is touched', async () => {
|
||||
const res = mockResponse();
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res, binaryResponse, 200, 'Widgets.method', { body: Buffer.from('x') }),
|
||||
).rejects.toThrow('Widgets.method did not set the declared response header(s): X-Required');
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches a declared header name case-insensitively', async () => {
|
||||
const res = mockResponse();
|
||||
|
||||
await sendBinaryResponse(res, binaryResponse, 200, 'Widgets.method', {
|
||||
body: Buffer.from('x'),
|
||||
headers: { 'x-required': '1' },
|
||||
});
|
||||
|
||||
expect(res.end).toHaveBeenCalledWith(Buffer.from('x'));
|
||||
});
|
||||
|
||||
it('rejects a result without a binary body', async () => {
|
||||
const res = mockResponse();
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res, binaryResponse, 200, 'Widgets.method', {
|
||||
body: 'not binary',
|
||||
headers: { 'X-Required': '1' },
|
||||
}),
|
||||
).rejects.toThrow('Widgets.method declares a binary @ApiResponse but returned no body');
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends an empty stream with a 200 and no body', async () => {
|
||||
const sent: Buffer[] = [];
|
||||
const client = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
sent.push(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const res = Object.assign(client, {
|
||||
status: vi.fn(() => client),
|
||||
setHeader: vi.fn(() => client),
|
||||
});
|
||||
|
||||
await sendBinaryResponse(res as unknown as Response, binaryResponse, 200, 'Widgets.method', {
|
||||
body: Readable.from([]),
|
||||
headers: { 'X-Required': '1' },
|
||||
});
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(client.writableFinished).toBe(true);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves when the client disconnects after the first chunk', async () => {
|
||||
const sent: Buffer[] = [];
|
||||
const client = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
sent.push(chunk);
|
||||
// Drop the connection on the second chunk, before its write completes.
|
||||
if (sent.length === 2) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const res = Object.assign(client, { status: () => client, setHeader: () => client });
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res as unknown as Response, binaryResponse, 200, 'Widgets.method', {
|
||||
body: Readable.from([Buffer.from('ab'), Buffer.from('cd'), Buffer.from('ef')]),
|
||||
headers: { 'X-Required': '1' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('destroys the stream when the client has already left', async () => {
|
||||
const client = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
client.destroy();
|
||||
const res = Object.assign(client, { status: () => client, setHeader: () => client });
|
||||
const body = Readable.from([Buffer.from('ab')]);
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res as unknown as Response, binaryResponse, 200, 'Widgets.method', {
|
||||
body,
|
||||
headers: { 'X-Required': '1' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('destroys a returned stream when a declared header is missing', async () => {
|
||||
const body = Readable.from([Buffer.from('x')]);
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(mockResponse(), binaryResponse, 200, 'Widgets.method', { body }),
|
||||
).rejects.toThrow('X-Required');
|
||||
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('destroys the stream when the client leaves while the first chunk is pending', async () => {
|
||||
const client = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const res = Object.assign(client, { status: () => client, setHeader: () => client });
|
||||
// A source that never produces a chunk.
|
||||
const body = new Readable({ read() {} });
|
||||
|
||||
const sending = sendBinaryResponse(
|
||||
res as unknown as Response,
|
||||
binaryResponse,
|
||||
200,
|
||||
'Widgets.method',
|
||||
{
|
||||
body,
|
||||
headers: { 'X-Required': '1' },
|
||||
},
|
||||
);
|
||||
client.destroy();
|
||||
|
||||
await expect(sending).resolves.toBeUndefined();
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('destroys a stalled stream when the client left before the response started', async () => {
|
||||
const client = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
client.destroy();
|
||||
// Let the close event fire before the response starts, so a listener added later never sees it.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const res = Object.assign(client, { status: () => client, setHeader: () => client });
|
||||
// A source that never produces a chunk.
|
||||
const body = new Readable({ read() {} });
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res as unknown as Response, binaryResponse, 200, 'Widgets.method', {
|
||||
body,
|
||||
headers: { 'X-Required': '1' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('destroys the stream when setting a header throws', async () => {
|
||||
const res = mockResponse();
|
||||
res.setHeader.mockImplementation(() => {
|
||||
throw new Error('Invalid character in header content');
|
||||
});
|
||||
const body = Readable.from([Buffer.from('x')]);
|
||||
|
||||
await expect(
|
||||
sendBinaryResponse(res, binaryResponse, 200, 'Widgets.method', {
|
||||
body,
|
||||
headers: { 'X-Required': '1' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid character in header content');
|
||||
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { BinaryResponse, BinaryResult, SuccessStatus } from '@n8n/decorators';
|
||||
import { UnexpectedError } from '@n8n/errors';
|
||||
import type { Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
function isBinaryResult(result: unknown): result is BinaryResult {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'body' in result &&
|
||||
(Buffer.isBuffer(result.body) || result.body instanceof Readable)
|
||||
);
|
||||
}
|
||||
|
||||
function isClientGone(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error.code === 'ERR_STREAM_PREMATURE_CLOSE' || error.code === 'ERR_STREAM_UNABLE_TO_PIPE')
|
||||
);
|
||||
}
|
||||
|
||||
function setResponseHeaders(
|
||||
res: Response,
|
||||
successStatus: SuccessStatus,
|
||||
mediaType: BinaryResponse['mediaType'],
|
||||
headers: NonNullable<BinaryResult['headers']>,
|
||||
) {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
// Set last, so a result header cannot replace the declared media type.
|
||||
res.status(successStatus).setHeader('Content-Type', mediaType);
|
||||
}
|
||||
|
||||
async function sendStream(res: Response, body: Readable, setHeaders: () => void) {
|
||||
const release = () => body.destroy();
|
||||
res.once('close', release);
|
||||
|
||||
// The client may have left before the listener was attached.
|
||||
if (res.destroyed) {
|
||||
release();
|
||||
}
|
||||
|
||||
try {
|
||||
// Peek at the first chunk before any header is set. If the stream fails here, the error
|
||||
// still gets a JSON response.
|
||||
const iterator = body[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
|
||||
setHeaders();
|
||||
|
||||
const rest = { [Symbol.asyncIterator]: () => iterator };
|
||||
async function* withFirstChunk() {
|
||||
if (!first.done) {
|
||||
yield first.value;
|
||||
}
|
||||
yield* rest;
|
||||
}
|
||||
|
||||
await pipeline(Readable.from(withFirstChunk()), res);
|
||||
} catch (error) {
|
||||
// The client disconnected. There is no one left to send to.
|
||||
if (!isClientGone(error)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
res.off('close', release);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the result of a route whose success body is binary.
|
||||
*/
|
||||
export async function sendBinaryResponse(
|
||||
res: Response,
|
||||
binaryResponse: BinaryResponse,
|
||||
successStatus: SuccessStatus,
|
||||
routeName: string,
|
||||
result: unknown,
|
||||
): Promise<void> {
|
||||
if (!isBinaryResult(result)) {
|
||||
throw new UnexpectedError(`${routeName} declares a binary @ApiResponse but returned no body`);
|
||||
}
|
||||
|
||||
const { body } = result;
|
||||
const declaredHeaders = binaryResponse.headers ?? {};
|
||||
const resultHeaders = result.headers ?? {};
|
||||
|
||||
try {
|
||||
const setHeaderNames = new Set(Object.keys(resultHeaders).map((name) => name.toLowerCase()));
|
||||
const missing = Object.keys(declaredHeaders).filter(
|
||||
(name) => !setHeaderNames.has(name.toLowerCase()),
|
||||
);
|
||||
if (missing.length) {
|
||||
throw new UnexpectedError(
|
||||
`${routeName} did not set the declared response header(s): ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const setHeaders = () =>
|
||||
setResponseHeaders(res, successStatus, binaryResponse.mediaType, resultHeaders);
|
||||
|
||||
// A buffer is already in memory, so send it in one write.
|
||||
if (Buffer.isBuffer(body)) {
|
||||
setHeaders();
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendStream(res, body, setHeaders);
|
||||
} finally {
|
||||
// However this function exits, the stream must not stay open.
|
||||
if (body instanceof Readable) {
|
||||
body.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { BadRequestError, UnsupportedMediaTypeError } from '@n8n/errors';
|
||||
import { License } from '@/license';
|
||||
import { assertContentType } from '@/public-api/media-types/content-type';
|
||||
import { sendBinaryResponse } from '@/public-api/media-types/binary-response';
|
||||
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';
|
||||
@@ -120,6 +121,18 @@ export class PublicApiControllerRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
if (route.binaryResponse) {
|
||||
const result = await controller[handlerName](...args);
|
||||
await sendBinaryResponse(
|
||||
res,
|
||||
route.binaryResponse,
|
||||
successStatus,
|
||||
`${controllerClass.name}.${handlerName}`,
|
||||
result,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await controller[handlerName](...args);
|
||||
|
||||
if (res.headersSent) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ZodClass } from '@n8n/api-types';
|
||||
import type {
|
||||
ApiKeyScopeRequirement,
|
||||
Arg,
|
||||
BinaryResponse,
|
||||
Controller,
|
||||
DeprecationInfo,
|
||||
ErrorResponse,
|
||||
@@ -83,6 +84,8 @@ export interface ResolvedPublicApiRoute {
|
||||
requestBodyHandler?: RequestBodyHandler;
|
||||
requestQueryDto?: ZodClass;
|
||||
responseDto?: ResponseDtoClass;
|
||||
/** Mutually exclusive with `responseDto`. */
|
||||
binaryResponse?: BinaryResponse;
|
||||
/** Success status declared via `@ApiResponse` - always present, see `resolveSuccessStatus`. */
|
||||
successStatus: SuccessStatus;
|
||||
apiKeyScope?: ApiKeyScopeRequirement;
|
||||
@@ -296,6 +299,7 @@ export function resolvePublicApiRoutes(): ResolvedPublicApiRoute[] {
|
||||
requestBodyHandler,
|
||||
requestQueryDto,
|
||||
responseDto: route.responseDto,
|
||||
binaryResponse: route.binaryResponse,
|
||||
successStatus: resolveSuccessStatus(controllerClass.name, handlerName, route.successStatus),
|
||||
apiKeyScope: route.apiKeyScope,
|
||||
summary: route.summary,
|
||||
|
||||
@@ -373,4 +373,68 @@ describe('getDecoratorGeneratedOperations', () => {
|
||||
|
||||
expect(getSharedResponseSchemas().has(WidgetResponseDto)).toBe(false);
|
||||
});
|
||||
|
||||
it('documents a binary success response', () => {
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {
|
||||
mediaType: 'application/gzip',
|
||||
description: 'An archive.',
|
||||
headers: {
|
||||
'X-Example': { description: 'A header.' },
|
||||
},
|
||||
})
|
||||
method() {}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const [operation] = getDecoratorGeneratedOperations();
|
||||
|
||||
expect(operation.config.responses[200]).toEqual({
|
||||
description: 'An archive.',
|
||||
headers: {
|
||||
'X-Example': {
|
||||
description: 'A header.',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
},
|
||||
content: {
|
||||
'application/gzip': {
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the binary response description to "Operation successful." and omits headers when none are declared', () => {
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
method() {}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
const [operation] = getDecoratorGeneratedOperations();
|
||||
|
||||
expect(operation.config.responses[200]).toEqual({
|
||||
description: 'Operation successful.',
|
||||
content: {
|
||||
'application/gzip': {
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include a binary route in getSharedResponseSchemas', () => {
|
||||
class WidgetsPublicController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'application/gzip' })
|
||||
method() {}
|
||||
}
|
||||
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
|
||||
|
||||
expect(getSharedResponseSchemas().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
registerSharedSchemas,
|
||||
type OpenApiDocument,
|
||||
} from '../generate';
|
||||
import { buildBinarySuccessResponse } from '../decorator-routes';
|
||||
|
||||
function makeNamedResponseDto(className: string, shape: Parameters<typeof Z.class>[0]) {
|
||||
return { [className]: class extends Z.class(shape) {} }[className];
|
||||
@@ -268,4 +269,32 @@ describe('shared schema registry', () => {
|
||||
expect(registered.get(dtoA)).toBeDefined();
|
||||
expect(registered.get(dtoB)).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits a binary response body with format: binary and its header descriptions', () => {
|
||||
const registry = new OpenAPIRegistry();
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/exports',
|
||||
responses: {
|
||||
200: buildBinarySuccessResponse({
|
||||
mediaType: 'application/gzip',
|
||||
description: 'An archive.',
|
||||
headers: { 'X-Archive-Size': { description: 'Size of the archive' } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const [artifact] = buildArtifactsFromRegistry(registry, [
|
||||
{
|
||||
outputPath: 'handlers/exports/spec/paths/export.generated.yml',
|
||||
pathKey: '/exports',
|
||||
method: 'get',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(artifact.content).toContain('application/gzip:');
|
||||
expect(artifact.content).toContain('format: binary');
|
||||
expect(artifact.content).toContain('X-Archive-Size:');
|
||||
expect(artifact.content).toContain('Size of the archive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 type { BinaryResponse, ResponseDtoClass } from '@n8n/decorators';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
@@ -199,30 +199,65 @@ export function buildRequestBodyJsonSchema(
|
||||
return isRecord(schema) ? schema : undefined;
|
||||
}
|
||||
|
||||
/** Documents a success body the controller method writes itself, with its declared headers. */
|
||||
export function buildBinarySuccessResponse({ mediaType, description, headers }: BinaryResponse) {
|
||||
const responseHeaders: Record<
|
||||
string,
|
||||
{ description: string; required: true; schema: { type: 'string' } }
|
||||
> = {};
|
||||
|
||||
for (const [name, header] of Object.entries(headers ?? {})) {
|
||||
responseHeaders[name] = {
|
||||
description: header.description,
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: description ?? 'Operation successful.',
|
||||
headers: Object.keys(responseHeaders).length ? responseHeaders : undefined,
|
||||
content: {
|
||||
[mediaType]: { schema: { type: 'string' as const, format: 'binary' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Documents a JSON success body from the route's response DTO, or a bare success when it has none. */
|
||||
export function buildJsonSuccessResponse(
|
||||
responseDto: ResponseDtoClass | undefined,
|
||||
resolveSchema: SchemaResolver,
|
||||
) {
|
||||
const hasResponseContent = responseDto && hasNamedSchema(responseDto);
|
||||
|
||||
return {
|
||||
description: 'Operation successful.',
|
||||
content: hasResponseContent
|
||||
? {
|
||||
'application/json': {
|
||||
schema: resolveSchema(responseDto, responseDto.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
|
||||
* sends), auth always 401s, `@ApiKeyScope` always 403s on mismatch, and a body/query DTO always
|
||||
* 400s on failed `.safeParse()`. Anything else - like a 404 from a business-rule lookup that isn't
|
||||
* visible in decorator metadata - has to be declared explicitly via `@ApiErrorResponse`.
|
||||
* A binary `@ApiResponse` documents its media type, description and headers instead of a JSON DTO.
|
||||
*/
|
||||
function buildResponses(
|
||||
route: ResolvedPublicApiRoute,
|
||||
resolveSchema: SchemaResolver,
|
||||
): RouteConfig['responses'] {
|
||||
const responses: RouteConfig['responses'] = {
|
||||
[route.successStatus]: {
|
||||
description: 'Operation successful.',
|
||||
...(route.responseDto && hasNamedSchema(route.responseDto)
|
||||
? {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolveSchema(route.responseDto, route.responseDto.schema),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
[route.successStatus]: route.binaryResponse
|
||||
? buildBinarySuccessResponse(route.binaryResponse)
|
||||
: buildJsonSuccessResponse(route.responseDto, resolveSchema),
|
||||
};
|
||||
|
||||
// If the route has a request body or query, we add an HTTP 400 as a possible response
|
||||
|
||||
@@ -77,6 +77,29 @@ describe('eval-mock-fixtures', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('text option', () => {
|
||||
it('should write the text into a PDF', () => {
|
||||
const buf = synthesizeBinaryFixture('application/pdf', 'invoice.pdf', {
|
||||
text: 'Invoice Number: INV-7\nTotal Due: 12.50 EUR',
|
||||
});
|
||||
const pdf = buf.toString('latin1');
|
||||
expect(pdf.startsWith('%PDF')).toBe(true);
|
||||
expect(pdf).toContain('(Invoice Number: INV-7) Tj');
|
||||
expect(pdf).toContain('(Total Due: 12.50 EUR) Tj');
|
||||
});
|
||||
|
||||
it('should use the text as the body of a text MIME', () => {
|
||||
const buf = synthesizeBinaryFixture('text/csv', 'prices.csv', {
|
||||
text: 'sku,price\nA-1,9.99\n',
|
||||
});
|
||||
expect(buf.toString('utf8')).toBe('sku,price\nA-1,9.99\n');
|
||||
});
|
||||
|
||||
it('should keep an explicitly empty text body for a text MIME', () => {
|
||||
expect(synthesizeBinaryFixture('text/plain', 'empty.txt', { text: '' }).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('octet-stream fallback', () => {
|
||||
it('should produce deterministic bytes for application/octet-stream', () => {
|
||||
const a = synthesizeBinaryFixture('application/octet-stream', 'blob.dat');
|
||||
@@ -251,9 +274,11 @@ describe('eval-mock-fixtures', () => {
|
||||
expect(pdf.slice(startxref, startxref + 4)).toBe('xref');
|
||||
});
|
||||
|
||||
it('replaces non-ASCII characters and skips blank lines', () => {
|
||||
const pdf = buildPdfWithText('café £100\n\n \nnext').toString('latin1');
|
||||
expect(pdf).toContain('(caf? ?100) Tj');
|
||||
it('keeps WinAnsi characters, replaces others and skips blank lines', () => {
|
||||
const pdf = buildPdfWithText('café £100 10 €\n✓ ok\n\n \nnext').toString('latin1');
|
||||
expect(pdf).toContain('/Encoding/WinAnsiEncoding');
|
||||
expect(pdf).toContain('(café £100 10 \x80) Tj');
|
||||
expect(pdf).toContain('(? ok) Tj');
|
||||
expect(pdf).toContain('(next) Tj');
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface SynthesizeBinaryFixtureOptions {
|
||||
sizeHint?: FixtureSizeHint;
|
||||
/** Scenario-pinned override — when present, returned untouched (Step 4 precedence). */
|
||||
override?: Buffer;
|
||||
/** The document's text: a PDF carries it as its text layer, a text MIME uses it as the body. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,8 +269,8 @@ export function evalCanvasPng(): Buffer {
|
||||
const PDF_TEXT_MAX_LINES = 40;
|
||||
|
||||
/**
|
||||
* Build a valid one-page PDF whose extractable text is `text` (ASCII-printable
|
||||
* subset; other characters become `?`). The xref offsets are computed, so
|
||||
* Build a valid one-page PDF whose extractable text is `text` (WinAnsi subset:
|
||||
* Latin-1 plus `€`; other characters become `?`). The xref offsets are computed, so
|
||||
* strict parsers (pdfjs-dist behind Extract From File) accept it — LLM-authored
|
||||
* "PDF bytes" never parse, so mock layers substitute these and keep the
|
||||
* scenario's document CONTENT model-authored as plaintext.
|
||||
@@ -276,7 +278,12 @@ const PDF_TEXT_MAX_LINES = 40;
|
||||
export function buildPdfWithText(text: string): Buffer {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/[^\x20-\x7e]/g, '?').trimEnd())
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/€/g, '\x80')
|
||||
.replace(/[^\x20-\x7e\x80\xa0-\xff]/g, '?')
|
||||
.trimEnd(),
|
||||
)
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(0, PDF_TEXT_MAX_LINES);
|
||||
if (lines.length === 0) lines.push('Mock PDF document');
|
||||
@@ -295,7 +302,7 @@ export function buildPdfWithText(text: string): Buffer {
|
||||
'<</Type/Catalog/Pages 2 0 R>>',
|
||||
'<</Type/Pages/Kids[3 0 R]/Count 1>>',
|
||||
'<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>',
|
||||
'<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>',
|
||||
'<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>',
|
||||
`<</Length ${String(Buffer.byteLength(contentStream, 'latin1'))}>>\nstream\n${contentStream}\nendstream`,
|
||||
];
|
||||
|
||||
@@ -484,9 +491,10 @@ function applySizeHint(base: Buffer, hint: FixtureSizeHint, mime: string): Buffe
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `options.override` — scenario-pinned bytes returned untouched.
|
||||
* 2. MIME-keyed minimal fixture (PDF, PNG, OGG, …).
|
||||
* 3. Text MIME plaintext stub seeded with `filename`.
|
||||
* 4. Deterministic-random 256-byte payload (octet-stream fallback).
|
||||
* 2. `options.text` — a PDF with that text, or the text itself for a text MIME.
|
||||
* 3. MIME-keyed minimal fixture (PDF, PNG, OGG, …).
|
||||
* 4. Text MIME plaintext stub seeded with `filename`.
|
||||
* 5. Deterministic-random 256-byte payload (octet-stream fallback).
|
||||
*
|
||||
* `options.sizeHint` only pads the tail of MIMEs whose decoder tolerates
|
||||
* trailing bytes (everything except ZIP-based formats and MP4).
|
||||
@@ -501,6 +509,13 @@ export function synthesizeBinaryFixture(
|
||||
const mime = (contentType || 'application/octet-stream').toLowerCase().split(';')[0].trim();
|
||||
const hint: FixtureSizeHint = options.sizeHint ?? 'small';
|
||||
|
||||
if (options.text?.trim() && mime === 'application/pdf') {
|
||||
return applySizeHint(buildPdfWithText(options.text), hint, mime);
|
||||
}
|
||||
if (options.text !== undefined && isTextMime(mime)) {
|
||||
return applySizeHint(Buffer.from(options.text, 'utf8'), hint, mime);
|
||||
}
|
||||
|
||||
const binary = pickBinaryFixture(mime, filename);
|
||||
if (binary) return applySizeHint(binary, hint, mime);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user