mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
chore: Migrate GET /api/v1/folders to @PublicApiController + Zod (#38620)
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:
parent
94c12a6e7f
commit
7867844fbb
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
FolderListPublicDto,
|
||||
folderPublicSchema,
|
||||
ListFoldersQueryPublicDto,
|
||||
} from '../folder-public.dto';
|
||||
|
||||
const folder = {
|
||||
id: 'zLXWgYyZrZ4Gn1Vk',
|
||||
name: 'Campaigns',
|
||||
parentFolderId: 'aBXWgYyZrZ4Gn1Vk',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
homeProject: {
|
||||
id: 'VmwOO9HeTEj20kxM',
|
||||
name: 'Marketing',
|
||||
type: 'team',
|
||||
icon: { type: 'icon', value: 'layers' },
|
||||
},
|
||||
parentFolder: { id: 'aBXWgYyZrZ4Gn1Vk', name: 'Marketing', parentFolderId: null },
|
||||
tags: [{ id: 'tag-1', name: 'campaign' }],
|
||||
workflowCount: 3,
|
||||
subFolderCount: 1,
|
||||
path: ['Marketing', 'Campaigns'],
|
||||
};
|
||||
|
||||
describe('folderPublicSchema', () => {
|
||||
test('accepts every field the list query can load', () => {
|
||||
const result = folderPublicSchema.safeParse(folder);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(folder);
|
||||
});
|
||||
|
||||
test('accepts a folder reduced by the select query option', () => {
|
||||
const result = folderPublicSchema.safeParse({ id: folder.id, name: folder.name });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ id: folder.id, name: folder.name });
|
||||
});
|
||||
|
||||
test('accepts a root folder', () => {
|
||||
const result = folderPublicSchema.safeParse({
|
||||
...folder,
|
||||
parentFolderId: null,
|
||||
parentFolder: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts a project type outside the current enum', () => {
|
||||
const result = folderPublicSchema.safeParse({
|
||||
...folder,
|
||||
homeProject: { ...folder.homeProject, type: 'legacy' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('strips fields the Public API does not publish', () => {
|
||||
const result = folderPublicSchema.parse({ ...folder, projectId: 'VmwOO9HeTEj20kxM' });
|
||||
|
||||
expect(result).not.toHaveProperty('projectId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderListPublicDto', () => {
|
||||
test('accepts a count and a list of folders', () => {
|
||||
expect(FolderListPublicDto.safeParse({ count: 1, data: [folder] }).success).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a bare array', () => {
|
||||
expect(FolderListPublicDto.safeParse([folder]).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListFoldersQueryPublicDto', () => {
|
||||
test('defaults skip and take when neither is sent', () => {
|
||||
const result = ListFoldersQueryPublicDto.safeParse({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ skip: 0, take: 10 });
|
||||
});
|
||||
|
||||
test('decodes the filter and select parameters', () => {
|
||||
const result = ListFoldersQueryPublicDto.safeParse({
|
||||
filter: '{"name":"Campaigns"}',
|
||||
select: '["id","name"]',
|
||||
sortBy: 'name:asc',
|
||||
skip: '5',
|
||||
take: '25',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({
|
||||
filter: { name: 'Campaigns' },
|
||||
select: { id: true, name: true },
|
||||
sortBy: 'name:asc',
|
||||
skip: 5,
|
||||
take: 25,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the legacy wording for an invalid sortBy', () => {
|
||||
const result = ListFoldersQueryPublicDto.safeParse({ sortBy: 'size:asc' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]).toMatchObject({
|
||||
path: ['sortBy'],
|
||||
message:
|
||||
'must be equal to one of the allowed values: name:asc, name:desc, createdAt:asc, createdAt:desc, updatedAt:asc, updatedAt:desc',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an undocumented query parameter', () => {
|
||||
const result = ListFoldersQueryPublicDto.safeParse({ offset: '10' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0].code).toBe('unrecognized_keys');
|
||||
});
|
||||
});
|
||||
|
|
@ -198,12 +198,8 @@ describe('ListFolderQueryDto', () => {
|
|||
const result = ListFolderQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath && !result.success) {
|
||||
if (Array.isArray(expectedErrorPath)) {
|
||||
const errorPaths = result.error.issues[0].path;
|
||||
expect(errorPaths).toContain(expectedErrorPath[0]);
|
||||
}
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
87
packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts
Normal file
87
packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import '../../openapi-extend';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
folderFieldDocs,
|
||||
folderListFieldDocs,
|
||||
folderListQueryFieldDocs,
|
||||
folderProjectFieldDocs,
|
||||
folderProjectIconOpenApi,
|
||||
} from './folder-public.openapi';
|
||||
import {
|
||||
filterValidator,
|
||||
selectValidator,
|
||||
skipValidator,
|
||||
takeValidator,
|
||||
VALID_SORT_OPTIONS,
|
||||
} from './list-folder-query.dto';
|
||||
import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
|
||||
import { projectTypeSchema, type ProjectIcon } from '../../schemas/project.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** The project a folder lives in, reduced to the columns the list query loads. */
|
||||
const folderProjectPublicSchema = z.object({
|
||||
id: z.string().openapi(folderProjectFieldDocs.id),
|
||||
name: z.string().openapi(folderProjectFieldDocs.name),
|
||||
// Legacy rows may hold a type the current enum no longer lists, and a response mismatch is a 500.
|
||||
type: z
|
||||
.string()
|
||||
.openapi({ ...folderProjectFieldDocs.type, enum: [...projectTypeSchema.options] }),
|
||||
icon: nullableObjectGuardSchema<ProjectIcon>().openapi(folderProjectIconOpenApi),
|
||||
});
|
||||
|
||||
const folderParentPublicSchema = z.object({
|
||||
id: z.string().openapi(folderFieldDocs.id),
|
||||
name: z.string().openapi(folderFieldDocs.name),
|
||||
parentFolderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const folderTagPublicSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every field is optional because the `select` query parameter decides which columns the route
|
||||
* loads: an unselected field is absent from the response, not null.
|
||||
*/
|
||||
export const folderPublicSchema = z.object({
|
||||
id: z.string().openapi(folderFieldDocs.id),
|
||||
name: z.string().optional().openapi(folderFieldDocs.name),
|
||||
parentFolderId: z.string().nullable().optional(),
|
||||
createdAt: z.string().datetime().optional().openapi(folderFieldDocs.createdAt),
|
||||
updatedAt: z.string().datetime().optional().openapi(folderFieldDocs.updatedAt),
|
||||
homeProject: folderProjectPublicSchema.optional(),
|
||||
parentFolder: folderParentPublicSchema.nullable().optional(),
|
||||
tags: z.array(folderTagPublicSchema).optional(),
|
||||
workflowCount: z.number().optional().openapi(folderFieldDocs.workflowCount),
|
||||
subFolderCount: z.number().optional().openapi(folderFieldDocs.subFolderCount),
|
||||
path: z.array(z.string()).optional().openapi(folderFieldDocs.path),
|
||||
});
|
||||
|
||||
export type FolderPublic = z.infer<typeof folderPublicSchema>;
|
||||
|
||||
export class FolderPublicDto extends Z.class(folderPublicSchema.shape) {}
|
||||
|
||||
export class FolderListPublicDto extends Z.class({
|
||||
count: z.number().int().openapi(folderListFieldDocs.count),
|
||||
data: z.array(folderPublicSchema),
|
||||
}) {}
|
||||
|
||||
export class ListFoldersQueryPublicDto extends Z.class(
|
||||
{
|
||||
filter: filterValidator.openapi(folderListQueryFieldDocs.filter),
|
||||
select: selectValidator.openapi(folderListQueryFieldDocs.select),
|
||||
sortBy: z
|
||||
.enum(VALID_SORT_OPTIONS, {
|
||||
// Keep the wording the legacy validator produced, so the 400 body does not change.
|
||||
message: `must be equal to one of the allowed values: ${VALID_SORT_OPTIONS.join(', ')}`,
|
||||
})
|
||||
.optional()
|
||||
.openapi(folderListQueryFieldDocs.sortBy),
|
||||
skip: skipValidator.openapi(folderListQueryFieldDocs.skip),
|
||||
take: takeValidator.openapi(folderListQueryFieldDocs.take),
|
||||
},
|
||||
{ strict: true },
|
||||
) {}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
|
||||
|
||||
import { alsoNullable } from '../openapi-nullable';
|
||||
|
||||
export const folderFieldDocs = {
|
||||
id: { readOnly: true, example: 'zLXWgYyZrZ4Gn1Vk' },
|
||||
name: { example: 'My Folder' },
|
||||
createdAt: { readOnly: true },
|
||||
updatedAt: { readOnly: true },
|
||||
workflowCount: { description: 'Number of workflows the folder holds.', readOnly: true },
|
||||
subFolderCount: { description: 'Number of folders the folder holds.', readOnly: true },
|
||||
path: {
|
||||
description: 'Names of every folder from the project root down to this folder.',
|
||||
readOnly: true,
|
||||
example: ['Marketing', 'Campaigns'],
|
||||
},
|
||||
} satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const folderProjectFieldDocs = {
|
||||
id: { readOnly: true, example: 'VmwOO9HeTEj20kxM' },
|
||||
name: { example: 'Marketing' },
|
||||
type: { readOnly: true, example: 'team' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
/**
|
||||
* `icon` is a JSON column, so the schema only guards the basic type and this metadata carries the
|
||||
* shape. Same reasoning as `projectIconOpenApi`, but the folder route returns a reduced project.
|
||||
*/
|
||||
export const folderProjectIconOpenApi: ZodOpenAPIMetadata = alsoNullable({
|
||||
type: 'object',
|
||||
description: 'Icon of the project, or null when the project has none',
|
||||
properties: {
|
||||
type: { type: 'string', enum: ['emoji', 'icon'] },
|
||||
value: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
},
|
||||
required: ['type', 'value'],
|
||||
});
|
||||
|
||||
export const folderListFieldDocs = {
|
||||
count: { description: 'Total number of folders matching the query.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const folderListQueryFieldDocs = {
|
||||
filter: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.',
|
||||
example: '{"parentFolderId":"abc123","name":"My Folder"}',
|
||||
},
|
||||
select: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.',
|
||||
example: '["id","name","tags","workflowCount"]',
|
||||
},
|
||||
sortBy: { description: 'Sort order for results.' },
|
||||
skip: { type: 'string', description: 'Number of items to skip for pagination. Defaults to 0.' },
|
||||
take: { type: 'string', description: 'Number of items to return. Defaults to 10.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const VALID_SELECT_FIELDS = [
|
||||
export const VALID_SELECT_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'createdAt',
|
||||
|
|
@ -16,7 +16,7 @@ const VALID_SELECT_FIELDS = [
|
|||
'path',
|
||||
] as const;
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
export const VALID_SORT_OPTIONS = [
|
||||
'name:asc',
|
||||
'name:desc',
|
||||
'createdAt:asc',
|
||||
|
|
@ -40,7 +40,7 @@ export const filterSchema = z
|
|||
// ---------------------
|
||||
|
||||
// Filter parameter validation
|
||||
const filterValidator = z
|
||||
export const filterValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
|
|
@ -53,7 +53,6 @@ const filterValidator = z
|
|||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
|
@ -61,14 +60,13 @@ const filterValidator = z
|
|||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
// Skip parameter validation
|
||||
const skipValidator = z
|
||||
export const skipValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 0))
|
||||
|
|
@ -77,7 +75,7 @@ const skipValidator = z
|
|||
});
|
||||
|
||||
// Take parameter validation
|
||||
const takeValidator = z
|
||||
export const takeValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 10))
|
||||
|
|
@ -87,7 +85,7 @@ const takeValidator = z
|
|||
|
||||
// Select parameter validation
|
||||
const selectFieldsValidator = z.array(z.enum(VALID_SELECT_FIELDS));
|
||||
const selectValidator = z
|
||||
export const selectValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
|
|
@ -106,7 +104,6 @@ const selectValidator = z
|
|||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid select fields. Valid fields are: ${VALID_SELECT_FIELDS.join(', ')}`,
|
||||
path: ['select'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
|
@ -114,7 +111,6 @@ const selectValidator = z
|
|||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid select format',
|
||||
path: ['select'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,6 +252,13 @@ export { UpdateFolderDto } from './folders/update-folder.dto';
|
|||
export { DeleteFolderDto } from './folders/delete-folder.dto';
|
||||
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
|
||||
export { TransferFolderBodyDto } from './folders/transfer-folder.dto';
|
||||
export {
|
||||
FolderListPublicDto,
|
||||
FolderPublicDto,
|
||||
ListFoldersQueryPublicDto,
|
||||
folderPublicSchema,
|
||||
} from './folders/folder-public.dto';
|
||||
export type { FolderPublic } from './folders/folder-public.dto';
|
||||
|
||||
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';
|
||||
export { InsightsDateFilterDto } from './insights/date-filter.dto';
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ export function isAuthProviderType(value: string): value is AuthProviderType {
|
|||
}
|
||||
|
||||
export type FolderWithWorkflowAndSubFolderCount = Folder & {
|
||||
workflowCount?: boolean;
|
||||
workflowCount?: number;
|
||||
subFolderCount?: number;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
FolderListPublicDto,
|
||||
type FolderPublic,
|
||||
ListFoldersQueryPublicDto,
|
||||
projectIdParamSchema,
|
||||
} from '@n8n/api-types';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { AuthenticatedRequest, FolderWithWorkflowAndSubFolderCountAndPath } from '@n8n/db';
|
||||
import {
|
||||
ApiDescription,
|
||||
ApiErrorResponse,
|
||||
ApiKeyScope,
|
||||
ApiResponse,
|
||||
ApiSummary,
|
||||
ApiTags,
|
||||
Get,
|
||||
Licensed,
|
||||
Param,
|
||||
PublicApiController,
|
||||
Query,
|
||||
} from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { assertProjectScope } from '@/public-api/v1/shared/services/utils.service';
|
||||
import { FolderService } from '@/services/folder.service';
|
||||
|
||||
const tags = ['Folders'];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const toFolderPublicDto = (folder: FolderWithWorkflowAndSubFolderCountAndPath): FolderPublic => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentFolderId: folder.parentFolderId,
|
||||
createdAt: folder.createdAt?.toISOString(),
|
||||
updatedAt: folder.updatedAt?.toISOString(),
|
||||
homeProject: folder.homeProject && {
|
||||
id: folder.homeProject.id,
|
||||
name: folder.homeProject.name,
|
||||
type: folder.homeProject.type,
|
||||
icon: folder.homeProject.icon,
|
||||
},
|
||||
parentFolder: folder.parentFolder && {
|
||||
id: folder.parentFolder.id,
|
||||
name: folder.parentFolder.name,
|
||||
parentFolderId: folder.parentFolder.parentFolderId,
|
||||
},
|
||||
tags: folder.tags?.map((tag) => ({ id: tag.id, name: tag.name })),
|
||||
workflowCount: folder.workflowCount,
|
||||
subFolderCount: folder.subFolderCount,
|
||||
path: folder.path,
|
||||
});
|
||||
|
||||
@PublicApiController('/projects/:projectId/folders')
|
||||
export class FoldersPublicController {
|
||||
constructor(private readonly folderService: FolderService) {}
|
||||
|
||||
@Get('/')
|
||||
@Licensed(LICENSE_FEATURES.FOLDERS)
|
||||
@ApiKeyScope('folder:list')
|
||||
@ApiSummary('Retrieve folders')
|
||||
@ApiDescription(
|
||||
'Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.',
|
||||
)
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, FolderListPublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async listFolders(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('projectId', projectIdParamSchema) projectId: string,
|
||||
@Query query: ListFoldersQueryPublicDto,
|
||||
): Promise<FolderListPublicDto> {
|
||||
// Kept in the handler: it answers 404 for an unknown project, which @ProjectScope cannot do.
|
||||
await assertProjectScope(req.user, projectId, ['folder:list']);
|
||||
|
||||
const [data, count] = await this.folderService.getManyAndCount(projectId, query);
|
||||
|
||||
return { count, data: data.map(toFolderPublicDto) };
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import './credentials.public.controller';
|
||||
import './executions.public.controller';
|
||||
import './folders.public.controller';
|
||||
import './node-type-policies.public.controller';
|
||||
import './projects.public.controller';
|
||||
import './promotions.public.controller';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
CreateFolderDto,
|
||||
DeleteFolderDto,
|
||||
ListFolderQueryDto,
|
||||
UpdateFolderDto,
|
||||
} from '@n8n/api-types';
|
||||
import { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
|
@ -36,7 +31,6 @@ const handleError = (error: unknown) => {
|
|||
|
||||
type FolderHandlers = {
|
||||
createFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
|
||||
getFolders: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
|
||||
deleteFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
|
||||
getFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
|
||||
updateFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
|
||||
|
|
@ -71,25 +65,6 @@ const folderHandlers: FolderHandlers = {
|
|||
}
|
||||
},
|
||||
],
|
||||
getFolders: [
|
||||
isLicensed('feat:folders'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:list' }),
|
||||
async (req, res) => {
|
||||
const { projectId } = req.params;
|
||||
await assertProjectScope(req.user, projectId, ['folder:list']);
|
||||
|
||||
const query = ListFolderQueryDto.safeParse(req.query);
|
||||
if (query.error) {
|
||||
throw new BadRequestError(query.error.errors[0].message);
|
||||
}
|
||||
|
||||
const [data, count] = await Container.get(FolderService).getManyAndCount(
|
||||
projectId,
|
||||
query.data,
|
||||
);
|
||||
return res.json({ count, data });
|
||||
},
|
||||
],
|
||||
deleteFolder: [
|
||||
isLicensed('feat:folders'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:delete' }),
|
||||
|
|
|
|||
|
|
@ -37,80 +37,3 @@ post:
|
|||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
get:
|
||||
x-eov-operation-id: getFolders
|
||||
x-required-scope: folder:list
|
||||
x-eov-operation-handler: v1/handlers/folders/folders.handler
|
||||
tags:
|
||||
- Folders
|
||||
summary: Retrieve folders
|
||||
description: Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: filter
|
||||
in: query
|
||||
required: false
|
||||
description: JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.
|
||||
schema:
|
||||
type: string
|
||||
example: '{"parentFolderId":"abc123","name":"My Folder"}'
|
||||
- name: select
|
||||
in: query
|
||||
required: false
|
||||
description: JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.
|
||||
schema:
|
||||
type: string
|
||||
example: '["id","name","tags","workflowCount"]'
|
||||
- name: sortBy
|
||||
in: query
|
||||
required: false
|
||||
description: Sort order for results.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- name:asc
|
||||
- name:desc
|
||||
- createdAt:asc
|
||||
- createdAt:desc
|
||||
- updatedAt:asc
|
||||
- updatedAt:desc
|
||||
- name: skip
|
||||
in: query
|
||||
required: false
|
||||
description: Number of items to skip for pagination. Defaults to 0.
|
||||
schema:
|
||||
type: string
|
||||
- name: take
|
||||
in: query
|
||||
required: false
|
||||
description: Number of items to return. Defaults to 10.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total number of folders matching the query.
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$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'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
operationId: listFolders
|
||||
tags:
|
||||
- Folders
|
||||
summary: Retrieve folders
|
||||
description: Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.
|
||||
x-required-scope: folder:list
|
||||
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.
|
||||
name: projectId
|
||||
in: path
|
||||
- schema:
|
||||
type: string
|
||||
description: JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.
|
||||
example: '{"parentFolderId":"abc123","name":"My Folder"}'
|
||||
required: false
|
||||
name: filter
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
description: JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.
|
||||
example: '["id","name","tags","workflowCount"]'
|
||||
required: false
|
||||
name: select
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
enum:
|
||||
- name:asc
|
||||
- name:desc
|
||||
- createdAt:asc
|
||||
- createdAt:desc
|
||||
- updatedAt:asc
|
||||
- updatedAt:desc
|
||||
description: Sort order for results.
|
||||
required: false
|
||||
name: sortBy
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
description: Number of items to skip for pagination. Defaults to 0.
|
||||
required: false
|
||||
name: skip
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
description: Number of items to return. Defaults to 10.
|
||||
required: false
|
||||
name: take
|
||||
in: query
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total number of folders matching the query.
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
readOnly: true
|
||||
example: zLXWgYyZrZ4Gn1Vk
|
||||
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
|
||||
homeProject:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
readOnly: true
|
||||
example: VmwOO9HeTEj20kxM
|
||||
name:
|
||||
type: string
|
||||
example: Marketing
|
||||
type:
|
||||
type: string
|
||||
readOnly: true
|
||||
example: team
|
||||
enum:
|
||||
- personal
|
||||
- team
|
||||
icon:
|
||||
type: object
|
||||
description: Icon of the project, or null when the project has none
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- emoji
|
||||
- icon
|
||||
value:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- type
|
||||
- icon
|
||||
parentFolder:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
readOnly: true
|
||||
example: zLXWgYyZrZ4Gn1Vk
|
||||
name:
|
||||
type: string
|
||||
example: My Folder
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- parentFolderId
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
workflowCount:
|
||||
type: number
|
||||
description: Number of workflows the folder holds.
|
||||
readOnly: true
|
||||
subFolderCount:
|
||||
type: number
|
||||
description: Number of folders the folder holds.
|
||||
readOnly: true
|
||||
path:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Names of every folder from the project root down to this folder.
|
||||
readOnly: true
|
||||
example:
|
||||
- Marketing
|
||||
- Campaigns
|
||||
required:
|
||||
- id
|
||||
required:
|
||||
- count
|
||||
- data
|
||||
'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
|
||||
|
|
@ -40,6 +40,9 @@ paths:
|
|||
/executions/{executionId}/retry:
|
||||
post:
|
||||
$ref: ./handlers/executions/spec/paths/retryExecution.generated.yml
|
||||
/projects/{projectId}/folders:
|
||||
get:
|
||||
$ref: ./handlers/projects/spec/paths/listFolders.generated.yml
|
||||
/node-type-policies/instance:
|
||||
get:
|
||||
$ref: ./handlers/node-type-policies/spec/paths/getInstancePolicy.generated.yml
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { FolderService } from '@/services/folder.service';
|
|||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { createFolder } from '../shared/db/folders';
|
||||
import { createTag } from '../shared/db/tags';
|
||||
import { createOwnerWithApiKey, createMemberWithApiKey } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
|
@ -355,6 +356,107 @@ describe('GET /projects/:projectId/folders', () => {
|
|||
expect(response.body.data[0].name).toBe('Child B');
|
||||
});
|
||||
|
||||
test('should return every public folder field and nothing else', async () => {
|
||||
testServer.license.enable('feat:folders');
|
||||
|
||||
const tag = await createTag({ name: 'Marketing' });
|
||||
const parentFolder = await createFolder(ownerPersonalProject, { name: 'Parent' });
|
||||
const folder = await createFolder(ownerPersonalProject, {
|
||||
name: 'Child',
|
||||
parentFolder,
|
||||
tags: [tag],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/projects/${ownerPersonalProject.id}/folders`)
|
||||
.query({ filter: JSON.stringify({ name: 'Child' }) });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.count).toBe(1);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: folder.id,
|
||||
name: 'Child',
|
||||
parentFolderId: parentFolder.id,
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
updatedAt: folder.updatedAt.toISOString(),
|
||||
homeProject: {
|
||||
id: ownerPersonalProject.id,
|
||||
name: ownerPersonalProject.name,
|
||||
type: 'personal',
|
||||
icon: null,
|
||||
},
|
||||
parentFolder: { id: parentFolder.id, name: 'Parent', parentFolderId: null },
|
||||
tags: [{ id: tag.id, name: 'Marketing' }],
|
||||
workflowCount: 0,
|
||||
subFolderCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return only the fields the select query option names', async () => {
|
||||
testServer.license.enable('feat:folders');
|
||||
|
||||
const parentFolder = await createFolder(ownerPersonalProject, { name: 'Parent' });
|
||||
const folder = await createFolder(ownerPersonalProject, { name: 'Child', parentFolder });
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/projects/${ownerPersonalProject.id}/folders`)
|
||||
.query({
|
||||
filter: JSON.stringify({ name: 'Child' }),
|
||||
select: JSON.stringify(['id', 'name', 'path']),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: folder.id,
|
||||
name: 'Child',
|
||||
path: ['Parent', 'Child'],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'an invalid sortBy value',
|
||||
query: { sortBy: 'bogus' },
|
||||
message:
|
||||
'request/query/sortBy must be equal to one of the allowed values: name:asc, name:desc, createdAt:asc, createdAt:desc, updatedAt:asc, updatedAt:desc',
|
||||
},
|
||||
{
|
||||
name: 'a filter that is not JSON',
|
||||
query: { filter: 'not-json' },
|
||||
message: 'request/query/filter Invalid filter format',
|
||||
},
|
||||
{
|
||||
name: 'an unknown filter field',
|
||||
query: { filter: JSON.stringify({ unknownField: 'x' }) },
|
||||
message: 'request/query/filter Invalid filter fields',
|
||||
},
|
||||
{
|
||||
name: 'an unknown select field',
|
||||
query: { select: JSON.stringify(['unknownField']) },
|
||||
message:
|
||||
'request/query/select Invalid select fields. Valid fields are: id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path',
|
||||
},
|
||||
{
|
||||
name: 'a take that is not a number',
|
||||
query: { take: 'abc' },
|
||||
message: 'request/query/take Take must be a valid number',
|
||||
},
|
||||
{
|
||||
name: 'an unknown query parameter',
|
||||
query: { unknownParameter: '1' },
|
||||
message: "request/query Unrecognized key(s) in object: 'unknownParameter'",
|
||||
},
|
||||
])('should return 400 for $name', async ({ query, message }) => {
|
||||
testServer.license.enable('feat:folders');
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/projects/${ownerPersonalProject.id}/folders`)
|
||||
.query(query);
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ message });
|
||||
});
|
||||
|
||||
test('should return 500 when getManyAndCount throws an unexpected error', async () => {
|
||||
testServer.license.enable('feat:folders');
|
||||
vi.spyOn(Container.get(FolderService), 'getManyAndCount').mockRejectedValueOnce(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user