diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts new file mode 100644 index 00000000000..ed59f372690 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts @@ -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'); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts index fd08f68c150..273d8faa6d1 100644 --- a/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts @@ -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); } }); }); diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts new file mode 100644 index 00000000000..a4b981f3c9c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts @@ -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().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; + +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 }, +) {} diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts new file mode 100644 index 00000000000..1836ba1f64d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts @@ -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; + +export const folderProjectFieldDocs = { + id: { readOnly: true, example: 'VmwOO9HeTEj20kxM' }, + name: { example: 'Marketing' }, + type: { readOnly: true, example: 'team' }, +} as const satisfies Record; + +/** + * `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; + +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; diff --git a/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts index 90c524ddbda..716308d0ee3 100644 --- a/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts @@ -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; } diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 6a604f38ad2..24897fb850c 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 4f6459d04eb..106e74f187c 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -339,7 +339,7 @@ export function isAuthProviderType(value: string): value is AuthProviderType { } export type FolderWithWorkflowAndSubFolderCount = Folder & { - workflowCount?: boolean; + workflowCount?: number; subFolderCount?: number; }; diff --git a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts new file mode 100644 index 00000000000..62c60295782 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts @@ -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 { + // 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) }; + } +} diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index 4f9fbaa6cfc..3d18c630407 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts b/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts index 8b3454816de..0939d02bfe0 100644 --- a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts @@ -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>; - getFolders: PublicAPIEndpoint>; deleteFolder: PublicAPIEndpoint>; getFolder: PublicAPIEndpoint>; updateFolder: PublicAPIEndpoint>; @@ -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' }), diff --git a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml b/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml index 2663cb0febc..a0b4a4f5e12 100644 --- a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml +++ b/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml @@ -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' diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/listFolders.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/listFolders.generated.yml new file mode 100644 index 00000000000..b762f985b62 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/listFolders.generated.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 diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index b180fd2532e..0439a238865 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.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 diff --git a/packages/cli/test/integration/public-api/folders.test.ts b/packages/cli/test/integration/public-api/folders.test.ts index 70ee12ae3e8..525082d924d 100644 --- a/packages/cli/test/integration/public-api/folders.test.ts +++ b/packages/cli/test/integration/public-api/folders.test.ts @@ -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(