mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
fix(API): Stop advertising server-managed fields as writable
The generated request spec listed `id`, `active`, `createdAt` and the other
server-managed fields as ordinary properties with empty schemas, because the
generator cannot express a "must be absent" check. Alongside
`additionalProperties: false`, the published contract positively invited callers
to send fields the server always rejects, and an SDK generated from it would
expose them as settable. The hand-written spec marked them `readOnly`.
They are now left out of the request shape, so the spec describes only what a
caller may send. The strict object rejects them as unrecognised keys, and an
error map keeps the specific message rather than letting a read-only field look
like a typo:
{ "id": "abc" } -> 'id' is a read-only field and cannot be set
{ "nam": "oops" } -> Unrecognized key(s) in object: 'nam'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a8b698a6e
commit
9cbd5cb97b
|
|
@ -228,5 +228,15 @@ describe('CreateWorkflowPublicDto', () => {
|
|||
expect(result.error.issues[0].message).toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not call an unrecognised field read-only', () => {
|
||||
const result = CreateWorkflowPublicDto.safeParse({ ...minimalWorkflow, nam: 'typo' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).not.toContain('read-only');
|
||||
expect(result.error.issues[0].message).toContain('nam');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,16 +9,43 @@ import { z } from 'zod';
|
|||
const GROUP_DESCRIPTION_PUBLIC_MAX_LENGTH = 155;
|
||||
|
||||
/**
|
||||
* Fields the public GET/POST/PUT response includes but that are server-managed. The previous
|
||||
* hand-written OpenAPI schema (`workflow.yml`/`workflowCreate.yml`) marked these `readOnly`,
|
||||
* which express-openapi-validator enforced by rejecting the request when one was present. This
|
||||
* reproduces that rejection instead of silently accepting or stripping the value.
|
||||
* Server-managed fields: present in the response, rejected in a request. The hand-written spec
|
||||
* marked them `readOnly`, which express-openapi-validator enforced.
|
||||
*
|
||||
* They are deliberately absent from the shape below rather than declared and rejected. A declared
|
||||
* field would appear in the generated request spec, and the generator cannot express `readOnly`
|
||||
* for it — so the published contract would invite callers to send fields the server always
|
||||
* rejects. Left out, the `strict` object rejects them as unrecognised keys, and the error map
|
||||
* below restores the specific message.
|
||||
*/
|
||||
function readOnlyPublicField(fieldName: string) {
|
||||
return z.custom<undefined>((value) => value === undefined, {
|
||||
message: `'${fieldName}' is a read-only field and cannot be set`,
|
||||
});
|
||||
}
|
||||
const READ_ONLY_PUBLIC_FIELDS = [
|
||||
'id',
|
||||
'active',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isArchived',
|
||||
'versionId',
|
||||
'triggerCount',
|
||||
'meta',
|
||||
'tags',
|
||||
'shared',
|
||||
'activeVersion',
|
||||
];
|
||||
|
||||
/** Tells a caller a rejected key is read-only, rather than leaving it to look like a typo. */
|
||||
export const readOnlyPublicFieldErrorMap: z.ZodErrorMap = (issue, ctx) => {
|
||||
if (issue.code === z.ZodIssueCode.unrecognized_keys) {
|
||||
const readOnly = issue.keys.filter((key) => READ_ONLY_PUBLIC_FIELDS.includes(key));
|
||||
|
||||
if (readOnly.length > 0) {
|
||||
const names = readOnly.map((key) => `'${key}'`).join(', ');
|
||||
const verb = readOnly.length > 1 ? 'are read-only fields' : 'is a read-only field';
|
||||
return { message: `${names} ${verb} and cannot be set` };
|
||||
}
|
||||
}
|
||||
|
||||
return { message: ctx.defaultError };
|
||||
};
|
||||
|
||||
// Nodes, connections, settings, static data, and pin data all carry arbitrary,
|
||||
// node-type-specific shapes that only the workflow engine can fully validate --
|
||||
|
|
@ -102,16 +129,4 @@ export const workflowWritePublicShape = {
|
|||
staticData: staticDataWritePublicSchema.optional(),
|
||||
pinData: pinDataWritePublicSchema.optional(),
|
||||
parentFolderId: z.string().nullable().optional(),
|
||||
|
||||
id: readOnlyPublicField('id'),
|
||||
active: readOnlyPublicField('active'),
|
||||
createdAt: readOnlyPublicField('createdAt'),
|
||||
updatedAt: readOnlyPublicField('updatedAt'),
|
||||
isArchived: readOnlyPublicField('isArchived'),
|
||||
versionId: readOnlyPublicField('versionId'),
|
||||
triggerCount: readOnlyPublicField('triggerCount'),
|
||||
meta: readOnlyPublicField('meta'),
|
||||
tags: readOnlyPublicField('tags'),
|
||||
shared: readOnlyPublicField('shared'),
|
||||
activeVersion: readOnlyPublicField('activeVersion'),
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { workflowWritePublicShape } from './base-workflow-public.dto';
|
||||
import { readOnlyPublicFieldErrorMap, workflowWritePublicShape } from './base-workflow-public.dto';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateWorkflowPublicDto extends Z.class(
|
||||
|
|
@ -8,5 +8,5 @@ export class CreateWorkflowPublicDto extends Z.class(
|
|||
...workflowWritePublicShape,
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
{ strict: true },
|
||||
{ strict: true, errorMap: readOnlyPublicFieldErrorMap },
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -39,14 +39,20 @@ export const Z = {
|
|||
class: <T extends z.ZodRawShape>(
|
||||
shape: T,
|
||||
/**
|
||||
* Pass `strict` when the endpoint must reject an unrecognised key rather than drop it.
|
||||
* Needed by Public API request bodies whose hand-written spec set
|
||||
* `additionalProperties: false`: Zod strips by default, so without this a client's typo
|
||||
* would be silently accepted where it used to return 400.
|
||||
* `strict` rejects an unrecognised key rather than dropping it. Needed by Public API
|
||||
* request bodies whose hand-written spec set `additionalProperties: false`: Zod strips by
|
||||
* default, so without this a client's typo would be silently accepted where it used to
|
||||
* return 400.
|
||||
*
|
||||
* `errorMap` lets a DTO explain those rejections in its own terms - for example, saying a
|
||||
* key is read-only rather than unrecognised.
|
||||
*/
|
||||
options: { strict?: boolean } = {},
|
||||
options: { strict?: boolean; errorMap?: z.ZodErrorMap } = {},
|
||||
): ZodClass<z.objectOutputType<T, z.ZodTypeAny>, T> => {
|
||||
const schema = options.strict ? z.object(shape).strict() : z.object(shape);
|
||||
const object = options.errorMap
|
||||
? z.object(shape, { errorMap: options.errorMap })
|
||||
: z.object(shape);
|
||||
const schema = options.strict ? object.strict() : object;
|
||||
type Output = z.objectOutputType<T, z.ZodTypeAny>;
|
||||
|
||||
const DtoClass = class {
|
||||
|
|
|
|||
|
|
@ -127,17 +127,6 @@ requestBody:
|
|||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
id: {}
|
||||
active: {}
|
||||
createdAt: {}
|
||||
updatedAt: {}
|
||||
isArchived: {}
|
||||
versionId: {}
|
||||
triggerCount: {}
|
||||
meta: {}
|
||||
tags: {}
|
||||
shared: {}
|
||||
activeVersion: {}
|
||||
projectId:
|
||||
type: string
|
||||
required:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user