chore: Migrate POST /api/v1/folders to @PublicApiController + Zod (#38619)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-23 13:06:46 +00:00 committed by GitHub
parent a423a6b203
commit 098d1d365e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 246 additions and 150 deletions

View File

@ -1,4 +1,5 @@
import {
CreateFolderPublicDto,
FolderDetailsPublicDto,
FolderListPublicDto,
folderPublicSchema,
@ -201,3 +202,34 @@ describe('UpdatedFolderPublicDto', () => {
expect(result.error?.errors[0].code).toBe('unrecognized_keys');
});
});
describe('CreateFolderPublicDto', () => {
test('accepts a name on its own', () => {
expect(CreateFolderPublicDto.safeParse({ name: 'My Folder' }).success).toBe(true);
});
test('accepts a name and a parentFolderId', () => {
const result = CreateFolderPublicDto.safeParse({ name: 'Child', parentFolderId: 'abc123' });
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: 'Child', parentFolderId: 'abc123' });
});
test.each([
['a missing name', {}, ['name']],
['an empty name', { name: '' }, ['name']],
['a name with an illegal character', { name: 'a/b' }, ['name']],
['a name over 128 characters', { name: 'a'.repeat(129) }, ['name']],
[
'a parentFolderId over 36 characters',
{ name: 'a', parentFolderId: 'b'.repeat(37) },
['parentFolderId'],
],
['an unknown key', { name: 'My Folder', tags: [] }, []],
])('rejects %s', (_label, payload, path) => {
const result = CreateFolderPublicDto.safeParse(payload);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(path);
});
});

View File

@ -10,6 +10,7 @@ import {
folderListQueryFieldDocs,
folderProjectFieldDocs,
folderProjectIconOpenApi,
folderProjectIdParamDocs,
updateFolderFieldDocs,
} from './folder-public.openapi';
import {
@ -19,11 +20,18 @@ import {
takeValidator,
VALID_SORT_OPTIONS,
} from './list-folder-query.dto';
import { folderIdSchema, folderNameSchema } from '../../schemas/folder.schema';
import {
FOLDER_NAME_MAX_LENGTH,
folderIdSchema,
folderNameSchema,
} from '../../schemas/folder.schema';
import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
import { projectTypeSchema, type ProjectIcon } from '../../schemas/project.schema';
import { Z } from '../../zod-class';
/** `personal` is accepted next to a real id, so this cannot reuse `projectIdParamSchema`. */
export const folderProjectIdParamSchema = z.string().openapi(folderProjectIdParamDocs);
const folderProjectPublicSchema = z.object({
id: z.string().openapi(folderProjectFieldDocs.id),
name: z.string().openapi(folderProjectFieldDocs.name),
@ -101,7 +109,10 @@ export class FolderDetailsPublicDto extends Z.class({
const updateFolderPublicSchema = z
.object({
name: folderNameSchema.optional().openapi(updateFolderFieldDocs.name),
name: folderNameSchema.optional().openapi({
...updateFolderFieldDocs.name,
maxLength: FOLDER_NAME_MAX_LENGTH,
}),
parentFolderId: folderIdSchema.optional().openapi(updateFolderFieldDocs.parentFolderId),
})
.strict()
@ -141,3 +152,17 @@ export class DeleteFolderQueryPublicDto extends Z.class(
},
{ strict: true },
) {}
export class CreateFolderPublicDto extends Z.class(
{
name: folderNameSchema.openapi({
...folderFieldDocs.name,
maxLength: FOLDER_NAME_MAX_LENGTH,
}),
parentFolderId: folderIdSchema.optional().openapi(folderFieldDocs.parentFolderId),
},
{ strict: true },
) {}
// Own class so create does not share UpdatedFolderPublicDto's OpenAPI component.
export class CreatedFolderPublicDto extends Z.class(folderCorePublicShape, { strict: true }) {}

View File

@ -77,3 +77,11 @@ export const deleteFolderQueryFieldDocs = {
'Optional target folder ID to move workflows and sub-folders into before deleting.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const folderProjectIdParamDocs = {
param: {
description:
"The ID of the project, or `personal` to create the folder in the calling user's own " +
'personal project.',
},
} as const satisfies ZodOpenAPIMetadata;

View File

@ -274,14 +274,17 @@ export { DeleteFolderDto } from './folders/delete-folder.dto';
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
export { TransferFolderBodyDto } from './folders/transfer-folder.dto';
export {
CreateFolderPublicDto,
CreatedFolderPublicDto,
DeleteFolderQueryPublicDto,
FolderDetailsPublicDto,
FolderListPublicDto,
FolderPublicDto,
FolderDetailsPublicDto,
ListFoldersQueryPublicDto,
folderPublicSchema,
UpdateFolderPublicDto,
UpdatedFolderPublicDto,
folderProjectIdParamSchema,
folderPublicSchema,
} from './folders/folder-public.dto';
export type { FolderPublic } from './folders/folder-public.dto';

View File

@ -2,7 +2,7 @@ import { z } from 'zod';
const illegalCharacterRegex = /[[\]^\\/:*?"<>|]/;
const dotsOnlyRegex = /^\.+$/;
const FOLDER_NAME_MAX_LENGTH = 128;
export const FOLDER_NAME_MAX_LENGTH = 128;
export const folderNameSchema = z
.string()

View File

@ -156,7 +156,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/folders/folders.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',

View File

@ -161,7 +161,6 @@ export default defineConfig({
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/discover/discover.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/folders/folders.handler.ts',
'./src/public-api/v1/handlers/insights/insights.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',

View File

@ -1,4 +1,6 @@
import {
CreateFolderPublicDto,
CreatedFolderPublicDto,
DeleteFolderQueryPublicDto,
FolderDetailsPublicDto,
FolderListPublicDto,
@ -7,6 +9,7 @@ import {
UpdateFolderPublicDto,
UpdatedFolderPublicDto,
folderIdParamSchema,
folderProjectIdParamSchema,
projectIdParamSchema,
} from '@n8n/api-types';
import { LICENSE_FEATURES } from '@n8n/constants';
@ -28,6 +31,7 @@ import {
Licensed,
Param,
Patch,
Post,
PublicApiController,
Query,
} from '@n8n/decorators';
@ -39,9 +43,12 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { assertProjectScope } from '@/public-api/v1/shared/services/utils.service';
import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
const tags = ['Folders'];
const PERSONAL_PROJECT_ALIAS = 'personal';
/**
* The `select` query parameter decides which columns the query loads, so a field the caller did not
* ask for is left `undefined` and drops out of the JSON body.
@ -91,6 +98,16 @@ const toUpdatedFolderPublicDto = (folder: Folder): UpdatedFolderPublicDto => ({
updatedAt: folder.updatedAt.toISOString(),
});
type CreatedFolder = Omit<Folder, 'homeProject'>;
const toCreatedFolderPublicDto = (folder: CreatedFolder): CreatedFolderPublicDto => ({
id: folder.id,
name: folder.name,
parentFolderId: folder.parentFolderId ?? folder.parentFolder?.id ?? null,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
});
const handleError = (error: unknown): never => {
if (error instanceof FolderNotFoundError) {
throw new NotFoundError(error.message);
@ -104,7 +121,10 @@ const handleError = (error: unknown): never => {
@PublicApiController('/projects/:projectId/folders')
export class FoldersPublicController {
constructor(private readonly folderService: FolderService) {}
constructor(
private readonly folderService: FolderService,
private readonly projectService: ProjectService,
) {}
@Get('/')
@Licensed(LICENSE_FEATURES.FOLDERS)
@ -130,6 +150,34 @@ export class FoldersPublicController {
return { count, data: data.map(toFolderPublicDto) };
}
@Post('/')
@Licensed(LICENSE_FEATURES.FOLDERS)
@ApiKeyScope('folder:create')
@ApiSummary('Create a folder')
@ApiDescription('Create a folder within a project.')
@ApiTags(tags)
@ApiResponse(201, CreatedFolderPublicDto)
@ApiErrorResponse(404)
async createFolder(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', folderProjectIdParamSchema) projectId: string,
@Body body: CreateFolderPublicDto,
): Promise<CreatedFolderPublicDto> {
const resolvedProjectId = await this.resolveProjectId(req, projectId);
// A 404 for an unknown project, so `@ProjectScope` (which answers 403) cannot be used here.
await assertProjectScope(req.user, resolvedProjectId, ['folder:create']);
try {
return toCreatedFolderPublicDto(
await this.folderService.createFolder(body, resolvedProjectId),
);
} catch (error) {
return handleError(error);
}
}
@Get('/:folderId')
@ApiKeyScope('folder:read')
@Licensed(LICENSE_FEATURES.FOLDERS)
@ -211,4 +259,15 @@ export class FoldersPublicController {
return handleError(error);
}
}
private async resolveProjectId(req: AuthenticatedRequest, projectId: string): Promise<string> {
if (projectId !== PERSONAL_PROJECT_ALIAS) return projectId;
const personalProject = await this.projectService.getPersonalProject(req.user);
if (!personalProject) {
throw new NotFoundError('Could not find a personal project for this user');
}
return personalProject.id;
}
}

View File

@ -1,67 +0,0 @@
import { CreateFolderDto } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
} from '../../shared/middlewares/global.middleware';
import { assertProjectScope } from '../../shared/services/utils.service';
const PERSONAL_PROJECT_ALIAS = 'personal';
const handleError = (error: unknown) => {
if (error instanceof FolderNotFoundError) {
throw new NotFoundError(error.message);
}
if (error instanceof UserError) {
throw new BadRequestError(error.message);
}
throw error;
};
type FolderHandlers = {
createFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
};
const folderHandlers: FolderHandlers = {
createFolder: [
isLicensed('feat:folders'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:create' }),
async (req, res) => {
let { projectId } = req.params;
if (projectId === PERSONAL_PROJECT_ALIAS) {
const personalProject = await Container.get(ProjectService).getPersonalProject(req.user);
if (!personalProject) {
throw new NotFoundError('Could not find a personal project for this user');
}
projectId = personalProject.id;
}
await assertProjectScope(req.user, projectId, ['folder:create']);
const payload = CreateFolderDto.safeParse(req.body);
if (payload.error) {
throw new BadRequestError(payload.error.errors[0].message);
}
try {
const folder = await Container.get(FolderService).createFolder(payload.data, projectId);
return res.status(201).json(folder);
} catch (error) {
return handleError(error);
}
},
],
};
export = folderHandlers;

View File

@ -1,39 +0,0 @@
post:
x-eov-operation-id: createFolder
x-required-scope: folder:create
x-eov-operation-handler: v1/handlers/folders/folders.handler
tags:
- Folders
summary: Create a folder
description: Create a folder within a project.
parameters:
- name: projectId
in: path
description: >-
The ID of the project, or `personal` to create the folder in the
calling user's own personal project.
required: true
schema:
type: string
requestBody:
description: Payload for folder to create.
content:
application/json:
schema:
$ref: '../schemas/folder.create.yml'
required: true
responses:
'201':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/folder.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,11 +0,0 @@
type: object
additionalProperties: false
required:
- name
properties:
name:
type: string
example: My Folder
parentFolderId:
type: string
example: abc123

View File

@ -1,20 +0,0 @@
type: object
additionalProperties: false
properties:
id:
type: string
readOnly: true
name:
type: string
example: My Folder
parentFolderId:
type: string
nullable: true
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true

View File

@ -0,0 +1,78 @@
operationId: createFolder
tags:
- Folders
summary: Create a folder
description: Create a folder within a project.
x-required-scope: folder:create
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the project, or `personal` to create the folder in the calling user's own personal project.
name: projectId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
maxLength: 128
example: My Folder
parentFolderId:
type: string
maxLength: 36
example: kRZ0ewrAEAhCkTBd
required:
- name
additionalProperties: false
responses:
'201':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
id:
type: string
readOnly: true
example: zLXWgYyZrZ4Gn1Vk
name:
type: string
example: My Folder
parentFolderId:
type: string
nullable: true
example: kRZ0ewrAEAhCkTBd
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
required:
- id
- name
- parentFolderId
- createdAt
- updatedAt
additionalProperties: false
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -30,6 +30,7 @@ requestBody:
name:
type: string
example: Renamed Folder
maxLength: 128
parentFolderId:
type: string
description: ID of the parent folder to move this folder into. Set to "0" to move the folder to the project root.

View File

@ -57,6 +57,8 @@ paths:
/projects/{projectId}/folders:
get:
$ref: ./handlers/projects/spec/paths/listFolders.generated.yml
post:
$ref: ./handlers/projects/spec/paths/createFolder.generated.yml
/projects/{projectId}/folders/{folderId}:
get:
$ref: ./handlers/projects/spec/paths/getFolder.generated.yml

View File

@ -123,8 +123,6 @@ paths:
$ref: './handlers/community-packages/spec/paths/community-packages.yml'
/community-packages/{name}:
$ref: './handlers/community-packages/spec/paths/community-packages.packageName.yml'
/projects/{projectId}/folders:
$ref: './handlers/folders/spec/paths/folders.yml'
/n8n-packages/export:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
/n8n-packages/import:

View File

@ -132,6 +132,30 @@ describe('POST /projects/:projectId/folders', () => {
.send({});
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
message: "request/body must have required property 'name'",
});
});
test('should return 400 when folder name is empty', async () => {
testServer.license.enable('feat:folders');
const response = await authOwnerAgent
.post(`/projects/${ownerPersonalProject.id}/folders`)
.send({ name: '' });
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('Folder name cannot be empty');
});
test('should return 400 when the body carries an unknown key', async () => {
testServer.license.enable('feat:folders');
const response = await authOwnerAgent
.post(`/projects/${ownerPersonalProject.id}/folders`)
.send({ name: 'Folder', unknownKey: 'nope' });
expect(response.statusCode).toBe(400);
});
test('should return 404 when parentFolderId is invalid', async () => {
@ -162,8 +186,13 @@ describe('POST /projects/:projectId/folders', () => {
.send({ name: 'My Folder' });
expect(response.statusCode).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name', 'My Folder');
expect(response.body).toStrictEqual({
id: expect.any(String),
name: 'My Folder',
parentFolderId: null,
createdAt: expect.stringMatching(ISO_DATE_TIME),
updatedAt: expect.stringMatching(ISO_DATE_TIME),
});
});
test('should create a folder with parentFolderId', async () => {
@ -177,6 +206,7 @@ describe('POST /projects/:projectId/folders', () => {
expect(response.statusCode).toBe(201);
expect(response.body).toHaveProperty('name', 'Child');
expect(response.body).toHaveProperty('parentFolderId', parentFolder.id);
});
test('should return 500 when createFolder throws an unexpected error', async () => {

View File

@ -240,7 +240,6 @@
"src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts",
"src/public-api/v1/handlers/discover/discover.handler.ts",
"src/public-api/v1/handlers/evaluations/evaluations.handler.ts",
"src/public-api/v1/handlers/folders/folders.handler.ts",
"src/public-api/v1/handlers/insights/insights.handler.ts",
"src/public-api/v1/handlers/ldap/ldap.handler.ts",
"src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts",