diff --git a/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts b/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts new file mode 100644 index 00000000000..925f6425aa9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts @@ -0,0 +1,64 @@ +import '../../openapi-extend'; + +import { z } from 'zod'; + +import { + createDataTableColumnFieldDocs, + createDataTableFieldDocs, + dataTableColumnFieldDocs, + dataTableFieldDocs, + dataTableListFieldDocs, + updateDataTableFieldDocs, +} from './data-table-public.openapi'; +import { + dataTableColumnNameSchema, + dataTableColumnTypeSchema, + dataTableNameSchema, +} from '../../schemas/data-table.schema'; +import { Z } from '../../zod-class'; + +const dataTableColumnPublicSchema = z.object({ + id: z.string().openapi(dataTableColumnFieldDocs.id), + name: z.string().openapi(dataTableColumnFieldDocs.name), + type: z.string().openapi(dataTableColumnFieldDocs.type), + index: z.number().openapi(dataTableColumnFieldDocs.index), + createdAt: z.string().datetime().openapi(dataTableColumnFieldDocs.createdAt), + updatedAt: z.string().datetime().openapi(dataTableColumnFieldDocs.updatedAt), +}); + +export const dataTablePublicSchema = z.object({ + id: z.string().openapi(dataTableFieldDocs.id), + name: z.string().openapi(dataTableFieldDocs.name), + columns: z.array(dataTableColumnPublicSchema).openapi(dataTableFieldDocs.columns), + projectId: z.string().openapi(dataTableFieldDocs.projectId), + createdAt: z.string().datetime().openapi(dataTableFieldDocs.createdAt), + updatedAt: z.string().datetime().openapi(dataTableFieldDocs.updatedAt), + sizeBytes: z.number().openapi(dataTableFieldDocs.sizeBytes), +}); + +export type DataTablePublic = z.infer; + +export class DataTablePublicDto extends Z.class(dataTablePublicSchema.shape) {} + +export class DataTableListPublicDto extends Z.class({ + data: z.array(dataTablePublicSchema), + nextCursor: z.string().nullable().openapi(dataTableListFieldDocs.nextCursor), +}) {} + +const createDataTableColumnPublicSchema = z.object({ + name: dataTableColumnNameSchema.openapi(dataTableColumnFieldDocs.name), + type: dataTableColumnTypeSchema.openapi(dataTableColumnFieldDocs.type), + csvColumnName: z.string().optional().openapi(createDataTableColumnFieldDocs.csvColumnName), +}); + +export class CreateDataTablePublicDto extends Z.class({ + name: dataTableNameSchema.openapi(createDataTableFieldDocs.name), + columns: z.array(createDataTableColumnPublicSchema).openapi(createDataTableFieldDocs.columns), + projectId: z.string().optional().openapi(createDataTableFieldDocs.projectId), + fileId: z.string().optional().openapi(createDataTableFieldDocs.fileId), + hasHeaders: z.boolean().optional().openapi(createDataTableFieldDocs.hasHeaders), +}) {} + +export class UpdateDataTablePublicDto extends Z.class({ + name: dataTableNameSchema.openapi(updateDataTableFieldDocs.name), +}) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts b/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts new file mode 100644 index 00000000000..a8a8c0a7b90 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts @@ -0,0 +1,87 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +import { dataTableColumnTypeSchema } from '../../schemas/data-table.schema'; + +export const dataTableFieldDocs = { + id: { description: 'Unique identifier for the data table' }, + name: { description: 'Name of the data table' }, + columns: { description: 'Column definitions' }, + projectId: { description: 'ID of the project this table belongs to' }, + createdAt: { description: 'Timestamp when the table was created' }, + updatedAt: { description: 'Timestamp when the table was last updated' }, + sizeBytes: { + type: 'integer', + description: + 'Physical storage in bytes, including indexes and internal overhead. Not reduced ' + + 'immediately by row deletion, and may be a few seconds stale.', + }, +} as const satisfies Record; + +export const dataTableColumnFieldDocs = { + id: { description: 'Column ID' }, + name: { description: 'Column name' }, + type: { enum: [...dataTableColumnTypeSchema.options], description: 'Column data type' }, + index: { type: 'integer', description: 'Column position' }, + createdAt: { description: 'Timestamp when the column was created' }, + updatedAt: { description: 'Timestamp when the column was last updated' }, +} as const satisfies Record; + +export const dataTableListFieldDocs = { + nextCursor: { + description: + 'Paginate through data tables by setting the cursor parameter to a nextCursor attribute ' + + 'returned by a previous request. Default value fetches the first "page" of the collection.', + example: 'MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA', + }, +} as const satisfies Record; + +export const createDataTableColumnFieldDocs = { + csvColumnName: { + description: + 'Name of the CSV column to read the values from. If you do not set it, n8n maps the ' + + 'CSV columns by position.', + example: 'Email Address', + }, +} as const satisfies Record; + +export const createDataTableFieldDocs = { + name: { description: 'Name of the data table', example: 'customers' }, + columns: { description: 'Column definitions for the table' }, + projectId: { + description: + "ID of the project to create the table in. When omitted, the table is created in the user's personal project.", + example: 'a1b2c3d4', + }, + fileId: { + description: + 'ID of a CSV file that you uploaded in the n8n editor. If you set it, n8n fills the new ' + + 'table with the rows from that file. Only a session-authenticated caller can upload a ' + + 'file, so an API-key caller cannot use this field.', + }, + hasHeaders: { + description: + 'Set to true if the first row of the CSV file holds the column names. Applies only when ' + + 'you set `fileId`. The default is true.', + }, +} as const satisfies Record; + +export const updateDataTableFieldDocs = { + name: { description: 'New name for the data table', example: 'updated-customers' }, +} as const satisfies Record; + +export const listDataTablesQueryDocs = { + filter: { + type: 'string', + format: 'jsonString', + param: { + description: 'JSON string of filter conditions', + example: '{"name":"my-table"}', + }, + }, + sortBy: { + param: { + description: 'Sort format: field:asc or field:desc', + example: 'name:asc', + }, + }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts index 96b4bf05774..f1b6f7a70b8 100644 --- a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts @@ -1,6 +1,9 @@ +import '../../openapi-extend'; + import { jsonParse } from 'n8n-workflow'; import { z } from 'zod'; +import { listDataTablesQueryDocs } from './data-table-public.openapi'; import { Z } from '../../zod-class'; import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto'; @@ -70,7 +73,8 @@ export class ListDataTableQueryDto extends Z.class({ }) {} export class PublicApiListDataTableQueryDto extends Z.class({ - ...publicApiPaginationSchema, - filter: publicApiFilterValidator, - sortBy: sortByValidator, + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), + filter: publicApiFilterValidator.openapi(listDataTablesQueryDocs.filter), + sortBy: sortByValidator.openapi(listDataTablesQueryDocs.sortBy), }) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts b/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts deleted file mode 100644 index f65cc8a4230..00000000000 --- a/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -import { CreateDataTableColumnDto } from './create-data-table-column.dto'; -import { dataTableNameSchema } from '../../schemas/data-table.schema'; -import { Z } from '../../zod-class'; - -export class PublicApiCreateDataTableDto extends Z.class({ - name: dataTableNameSchema, - columns: z.array(CreateDataTableColumnDto.schema), - fileId: z.string().optional(), - hasHeaders: z.boolean().optional(), - projectId: z.string().optional(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 9b87c532851..3f6350a2856 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -404,7 +404,14 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto' export { CreateDataTableDto } from './data-table/create-data-table.dto'; export { UpdateDataTableDto } from './data-table/update-data-table.dto'; -export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto'; +export { + CreateDataTablePublicDto, + DataTableListPublicDto, + DataTablePublicDto, + UpdateDataTablePublicDto, + dataTablePublicSchema, + type DataTablePublic, +} from './data-table/data-table-public.dto'; export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto'; export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto'; export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto'; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index e0e1e7bdd00..2cf4f900d50 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -156,6 +156,7 @@ export { credentialTypeNameParamSchema, credentialTypePolicyIdParamSchema, credentialTypePolicyScopeIdParamSchema, + dataTableIdParamSchema, executionIdParamSchema, nodeTypePolicyIdParamSchema, nodeTypePolicyScopeIdParamSchema, diff --git a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts index 0c2e756ad16..5ca26545e9b 100644 --- a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts +++ b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts @@ -52,6 +52,11 @@ export const credentialIdParamSchema = stringIdParamSchema('The ID of the creden export const credentialTypeNameParamSchema = stringIdParamSchema( 'The credential type name that you want to get the schema for', ); + +export const dataTableIdParamSchema = z + .string() + .regex(/^[A-Za-z0-9]{16}$/, 'must match format "nanoid"') + .openapi({ format: 'nanoid', param: { description: 'The ID of the data table' } }); export const variableIdParamSchema = stringIdParamSchema('The ID of the variable.'); export const nodeTypePolicyIdParamSchema = stringIdParamSchema( 'The ID of the node type policy document.', diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index c1198d8e5d1..7f342055a52 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -132,18 +132,6 @@ export default defineConfig( 'n8n-local-rules/require-public-api-controller': 'error', }, }, - { - // Ratchet allowlist: handlers/services still reaching a repository directly, pending - // migration to the `@PublicApiController` + service pattern (API-70). NEVER add to this - // list — a new violation must fail CI. Entries are removed as each file migrates. - files: [ - './src/public-api/v1/handlers/data-tables/data-tables.handler.ts', - './src/public-api/v1/handlers/data-tables/data-tables.service.ts', - ], - rules: { - 'n8n-local-rules/no-repository-in-public-api-handler': 'off', - }, - }, { // Ratchet allowlist: legacy `export =` handler tuples pending migration to // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler @@ -153,7 +141,6 @@ export default defineConfig( './src/public-api/v1/handlers/community-packages/community-packages.handler.ts', './src/public-api/v1/handlers/credentials/credentials.handler.ts', './src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts', - './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/ldap/ldap.handler.ts', diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 4a1d1d6f6d2..c6a313172a7 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -136,18 +136,6 @@ export default defineConfig({ 'n8n-local-rules/require-public-api-controller': 'error', }, }, - { - // Ratchet allowlist: handlers/services still reaching a repository directly, pending - // migration to the `@PublicApiController` + service pattern (API-70). NEVER add to this - // list — a new violation must fail CI. Entries are removed as each file migrates. - files: [ - './src/public-api/v1/handlers/data-tables/data-tables.handler.ts', - './src/public-api/v1/handlers/data-tables/data-tables.service.ts', - ], - rules: { - 'n8n-local-rules/no-repository-in-public-api-handler': 'off', - }, - }, { // Ratchet allowlist: legacy `export =` handler tuples pending migration to // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler @@ -157,7 +145,6 @@ export default defineConfig({ './src/public-api/v1/handlers/community-packages/community-packages.handler.ts', './src/public-api/v1/handlers/credentials/credentials.handler.ts', './src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts', - './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/discover/discover.handler.ts', './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 8bc93205976..01ca13cbb8d 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -1,8 +1,6 @@ import type { AddDataTableColumnDto, AddDataTableRowsDto, - PublicApiCreateDataTableDto, - UpdateDataTableDto, UpdateDataTableColumnDto, UpdateDataTableRowDto, UpsertDataTableRowDto, @@ -135,27 +133,6 @@ export interface IJsonSchema { // ---------------------------------- export declare namespace DataTableRequest { - type List = AuthenticatedRequest< - {}, - {}, - {}, - { - limit?: number; - cursor?: string; - offset?: number; - filter?: string; - sortBy?: string; - } - >; - - type Create = AuthenticatedRequest<{}, {}, PublicApiCreateDataTableDto, {}>; - - type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; - - type Update = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableDto, {}>; - - type Delete = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; - type GetRows = AuthenticatedRequest< { dataTableId: string }, {}, diff --git a/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts new file mode 100644 index 00000000000..69408246472 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts @@ -0,0 +1,240 @@ +import { + CreateDataTablePublicDto, + DataTableListPublicDto, + DataTablePublicDto, + PublicApiListDataTableQueryDto, + UpdateDataTablePublicDto, + dataTableIdParamSchema, +} from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiErrorResponse, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Body, + Delete, + Get, + Param, + Patch, + Post, + ProjectScope, + PublicApiController, + Query, +} from '@n8n/decorators'; +import type { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service'; +import type { DataTable } from '@/modules/data-table/data-table.entity'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableAccessDeniedError } from '@/modules/data-table/errors/data-table-access-denied.error'; +import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; +import { + encodeNextCursor, + resolveOffsetPagination, +} from '@/public-api/v1/shared/services/pagination.service'; +import { ProjectNotFoundError } from '@/services/project.service.ee'; + +const tags = ['DataTable']; + +/** The domain errors the data table service raises, mapped to the public statuses the eov handler sent. */ +function handleError(error: unknown): never { + if (error instanceof DataTableValidationError) { + throw new BadRequestError(error.message); + } + if (error instanceof ProjectNotFoundError) { + throw new BadRequestError(`Project with ID "${error.projectId}" not found`); + } + if (error instanceof DataTableNotFoundError) { + throw new NotFoundError(error.message); + } + if (error instanceof DataTableAccessDeniedError) { + throw new ForbiddenError(); + } + if (error instanceof DataTableNameConflictError) { + throw new ConflictError(error.message); + } + + throw error; +} + +const toDataTablePublicDto = (dataTable: DataTable, sizeBytes: number): DataTablePublicDto => ({ + id: dataTable.id, + name: dataTable.name, + columns: (dataTable.columns ?? []).map((column) => ({ + id: column.id, + name: column.name, + type: column.type, + index: column.index, + createdAt: column.createdAt.toISOString(), + updatedAt: column.updatedAt.toISOString(), + })), + projectId: dataTable.projectId, + createdAt: dataTable.createdAt.toISOString(), + updatedAt: dataTable.updatedAt.toISOString(), + sizeBytes, +}); + +@PublicApiController('/data-tables') +export class DataTablesPublicController { + constructor( + private readonly dataTableService: DataTableService, + private readonly dataTableAggregateService: DataTableAggregateService, + ) {} + + @Get('/') + @ApiKeyScope('dataTable:list') + @ApiSummary('List all data tables') + @ApiDescription( + 'Retrieve a list of all data tables with optional filtering, sorting, and pagination. ' + + 'Each table includes `sizeBytes`, the value the `size` sort option orders by.', + ) + @ApiTags(tags) + @ApiResponse(200, DataTableListPublicDto) + async listDataTables( + req: AuthenticatedRequest, + _res: Response, + @Query query: PublicApiListDataTableQueryDto, + ): Promise { + const { offset, limit } = resolveOffsetPagination(query); + + try { + const { data, count } = await this.dataTableAggregateService.getManyAndCount(req.user, { + skip: offset, + take: limit, + filter: query.filter, + sortBy: query.sortBy, + }); + + const sizes = await this.dataTableService.getCachedSizeBytesByIds( + data.map((dataTable) => dataTable.id), + ); + + return { + data: data.map((dataTable) => + toDataTablePublicDto(dataTable, sizes.get(dataTable.id) ?? 0), + ), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } catch (error) { + return handleError(error); + } + } + + @Post('/') + @ApiKeyScope('dataTable:create') + @ApiSummary('Create a new data table') + @ApiDescription( + 'Create a new data table in your personal project or a team project you have access to.', + ) + @ApiTags(tags) + @ApiResponse(201, DataTablePublicDto) + @ApiErrorResponse(409) + async createDataTable( + req: AuthenticatedRequest, + _res: Response, + @Body body: CreateDataTablePublicDto, + ): Promise { + try { + const owningProjectId = await this.dataTableService.resolveOwningProjectId( + req.user, + body.projectId, + ); + + const dataTable = await this.dataTableService.createDataTable(owningProjectId, { + name: body.name, + columns: body.columns, + fileId: body.fileId, + hasHeaders: body.hasHeaders, + }); + + return await this.withSize(dataTable); + } catch (error) { + return handleError(error); + } + } + + @Get('/:dataTableId') + @ApiKeyScope('dataTable:read') + @ProjectScope('dataTable:read') + @ApiSummary('Get a data table') + @ApiDescription('Retrieve a specific data table by ID.') + @ApiTags(tags) + @ApiResponse(200, DataTablePublicDto) + @ApiErrorResponse(404) + async getDataTable( + _req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + ): Promise { + try { + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + + return await this.withSize(await this.dataTableService.getOne(dataTableId, projectId)); + } catch (error) { + return handleError(error); + } + } + + @Patch('/:dataTableId') + @ApiKeyScope('dataTable:update') + @ProjectScope('dataTable:update') + @ApiSummary('Update a data table') + @ApiDescription("Update a data table's name.") + @ApiTags(tags) + @ApiResponse(200, DataTablePublicDto) + @ApiErrorResponse(404) + @ApiErrorResponse(409) + async updateDataTable( + _req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + @Body body: UpdateDataTablePublicDto, + ): Promise { + try { + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + + await this.dataTableService.updateDataTable(dataTableId, projectId, { name: body.name }); + + return await this.withSize(await this.dataTableService.getOne(dataTableId, projectId)); + } catch (error) { + return handleError(error); + } + } + + @Delete('/:dataTableId') + @ApiKeyScope('dataTable:delete') + @ProjectScope('dataTable:delete') + @ApiSummary('Delete a data table') + @ApiDescription('Delete a data table. This will also delete all rows in the table.') + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async deleteDataTable( + _req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + ): Promise { + try { + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + + await this.dataTableService.deleteDataTable(dataTableId, projectId); + } catch (error) { + handleError(error); + } + } + + private async withSize(dataTable: DataTable): Promise { + const sizes = await this.dataTableService.getCachedSizeBytesByIds([dataTable.id]); + + return toDataTablePublicDto(dataTable, sizes.get(dataTable.id) ?? 0); + } +} diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index d96c2239ad1..f5dcc5a9de8 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -6,6 +6,7 @@ import './community-packages.public.controller'; import './credential-type-policies.public.controller'; import './credentials.public.controller'; +import './data-tables.public.controller'; import './discover.public.controller'; import './evaluations.public.controller'; import './executions.public.controller'; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts index ec424d06b0b..ea84fbeae7f 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts @@ -11,7 +11,6 @@ import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; import type { DataTableRequest } from '@/public-api/types'; import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; -import { ProjectNotFoundError } from '@/services/project.service.ee'; // Mock middleware before requiring handler const mockMiddleware = vi.fn(async (_req, _res, next) => next()) as any; @@ -21,11 +20,9 @@ vi.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware); // Loaded after the middleware spies above are installed; typed loosely so the // suite can invoke individual route entries by index. -let mainHandler: Record unknown>>; let handler: Record unknown>>; beforeAll(async () => { - mainHandler = (await import('../data-tables.handler.js')) as unknown as typeof mainHandler; handler = (await import('../data-tables.rows.handler.js')) as unknown as typeof handler; }); @@ -71,188 +68,6 @@ describe('DataTable Handler', () => { vi.clearAllMocks(); }); - describe('createDataTable', () => { - it('should create in personal project when no projectId provided', async () => { - const req = { - body: { name: 'test-table', columns: [{ name: 'col1', type: 'string' }] }, - user: makeUser(), - } as unknown as DataTableRequest.Create; - mockDataTableService.resolveOwningProjectId.mockResolvedValue(projectId); - mockDataTableService.createDataTable.mockResolvedValue({ - id: dataTableId, - name: 'test-table', - columns: [], - project: { id: projectId }, - } as never); - - await mainHandler.createDataTable[1](req, mockResponse as Response); - - expect(mockDataTableService.resolveOwningProjectId).toHaveBeenCalledWith(req.user, undefined); - expect(mockDataTableService.createDataTable).toHaveBeenCalledWith(projectId, { - name: 'test-table', - columns: [{ name: 'col1', type: 'string' }], - fileId: undefined, - hasHeaders: undefined, - }); - expect(mockResponse.status).toHaveBeenCalledWith(201); - }); - - it('should map a missing project to BadRequestError', async () => { - const req = { - body: { name: 'test-table', columns: [], projectId }, - user: makeUser(), - } as unknown as DataTableRequest.Create; - mockDataTableService.resolveOwningProjectId.mockRejectedValue( - new ProjectNotFoundError(projectId), - ); - - const handlerFn = mainHandler.createDataTable[1]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - - expect(caught).toBeInstanceOf(BadRequestError); - expect(caught).toMatchObject({ - message: `Project with ID "${projectId}" not found`, - httpStatusCode: 400, - }); - }); - }); - - describe('listDataTables', () => { - it('should list via the aggregate service for a regular user', async () => { - const req = { query: {}, user: makeUser() } as unknown as DataTableRequest.List; - mockDataTableAggregateService.getManyAndCount.mockResolvedValue({ - data: [], - count: 0, - } as never); - - await mainHandler.listDataTables[2](req, mockResponse as Response); - - expect(mockDataTableAggregateService.getManyAndCount).toHaveBeenCalledWith( - req.user, - expect.objectContaining({ skip: 0, take: 100 }), - ); - }); - - it('should list via the aggregate service for a user with dataTable:listProject', async () => { - const req = { - query: {}, - user: makeUser(['dataTable:listProject']), - } as unknown as DataTableRequest.List; - mockDataTableAggregateService.getManyAndCount.mockResolvedValue({ - data: [], - count: 0, - } as never); - - await mainHandler.listDataTables[2](req, mockResponse as Response); - - expect(mockDataTableAggregateService.getManyAndCount).toHaveBeenCalledWith( - req.user, - expect.objectContaining({ skip: 0, take: 100, filter: undefined }), - ); - }); - }); - - describe('sizeBytes', () => { - const otherDataTableId = 'other-data-table-id'; - - const makeDataTable = (id: string) => ({ - id, - name: `table-${id}`, - columns: [], - project: { id: projectId }, - }); - - beforeEach(() => { - mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue( - new Map([ - [dataTableId, 4096], - [otherDataTableId, 8192], - ]), - ); - }); - - it('should attach sizeBytes to every item when listing', async () => { - const req = { - query: {}, - user: makeUser(['dataTable:listProject']), - } as unknown as DataTableRequest.List; - mockDataTableAggregateService.getManyAndCount.mockResolvedValue({ - data: [makeDataTable(dataTableId), makeDataTable(otherDataTableId)], - count: 2, - } as never); - - await mainHandler.listDataTables[2](req, mockResponse as Response); - - const { data } = (mockResponse.json as Mock).mock.calls[0][0]; - expect(data).toHaveLength(2); - expect(data[0]).toMatchObject({ id: dataTableId, sizeBytes: 4096 }); - expect(data[1]).toMatchObject({ id: otherDataTableId, sizeBytes: 8192 }); - }); - - it('should include sizeBytes on the create response', async () => { - const req = { - body: { name: 'test-table', columns: [] }, - user: makeUser(), - } as unknown as DataTableRequest.Create; - mockDataTableService.resolveOwningProjectId.mockResolvedValue(projectId); - mockDataTableService.createDataTable.mockResolvedValue(makeDataTable(dataTableId) as never); - - await mainHandler.createDataTable[1](req, mockResponse as Response); - - expect(mockResponse.status).toHaveBeenCalledWith(201); - expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({ sizeBytes: 4096 }); - }); - - it('should include sizeBytes when reading a single data table', async () => { - const req = { - params: { dataTableId }, - user: makeUser(), - } as unknown as DataTableRequest.Get; - mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never); - - await mainHandler.getDataTable[2](req, mockResponse as Response); - - expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({ - id: dataTableId, - sizeBytes: 4096, - }); - }); - - it('should include sizeBytes on the update response', async () => { - const req = { - params: { dataTableId }, - body: { name: 'renamed' }, - user: makeUser(), - } as unknown as DataTableRequest.Update; - mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never); - - await mainHandler.updateDataTable[2](req, mockResponse as Response); - - expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({ - id: dataTableId, - sizeBytes: 4096, - }); - }); - - it('should serialise a table missing from the size map as 0, not undefined', async () => { - mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(new Map()); - const req = { - params: { dataTableId }, - user: makeUser(), - } as unknown as DataTableRequest.Get; - mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never); - - await mainHandler.getDataTable[2](req, mockResponse as Response); - - expect((mockResponse.json as Mock).mock.calls[0][0].sizeBytes).toBe(0); - }); - }); - describe('getDataTableRows', () => { it('should retrieve rows successfully', async () => { // Arrange diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts deleted file mode 100644 index 41bb6d552f1..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - PublicApiListDataTableQueryDto, - PublicApiCreateDataTableDto, - UpdateDataTableDto, -} from '@n8n/api-types'; -import { Container } from '@n8n/di'; - -import type { DataTableRequest } from '../../../types'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { - publicApiScope, - projectScope, - validCursor, -} from '../../shared/middlewares/global.middleware'; -import { encodeNextCursor } from '../../shared/services/pagination.service'; -import { stringifyQuery } from './data-tables.utils'; - -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service'; -import { DataTableService } from '@/modules/data-table/data-table.service'; -import { DataTableAccessDeniedError } from '@/modules/data-table/errors/data-table-access-denied.error'; -import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error'; -import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; -import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; -import { ProjectNotFoundError } from '@/services/project.service.ee'; - -const handleError = (error: unknown) => { - if (error instanceof DataTableValidationError) { - throw new BadRequestError(error.message); - } - if (error instanceof ProjectNotFoundError) { - throw new BadRequestError(`Project with ID "${error.projectId}" not found`); - } - if (error instanceof DataTableNotFoundError) { - throw new NotFoundError(error.message); - } - if (error instanceof DataTableAccessDeniedError) { - throw new ForbiddenError(); - } - if (error instanceof DataTableNameConflictError) { - throw new ConflictError(error.message); - } - - throw error; -}; - -function toPublicDataTable(dataTable: T) { - const { project: _project, ...rest } = dataTable; - return rest; -} - -const attachSize = async (dataTable: T) => { - const sizes = await Container.get(DataTableService).getCachedSizeBytesByIds([dataTable.id]); - return { ...dataTable, sizeBytes: sizes.get(dataTable.id) ?? 0 }; -}; - -type DataTableHandlers = { - listDataTables: PublicAPIEndpoint; - createDataTable: PublicAPIEndpoint; - getDataTable: PublicAPIEndpoint; - updateDataTable: PublicAPIEndpoint; - deleteDataTable: PublicAPIEndpoint; -}; - -const dataTableHandlers: DataTableHandlers = { - listDataTables: [ - publicApiScope('dataTable:list'), - validCursor, - async (req, res) => { - try { - const payload = PublicApiListDataTableQueryDto.safeParse(stringifyQuery(req.query)); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid query parameters'); - } - - const { offset, limit, filter, sortBy } = payload.data; - - const result = await Container.get(DataTableAggregateService).getManyAndCount(req.user, { - skip: offset, - take: limit, - filter, - sortBy, - }); - - const sizes = await Container.get(DataTableService).getCachedSizeBytesByIds( - result.data.map((dataTable) => dataTable.id), - ); - - const data = result.data.map((dataTable) => ({ - ...toPublicDataTable(dataTable), - sizeBytes: sizes.get(dataTable.id) ?? 0, - })); - - return res.json({ - data, - nextCursor: encodeNextCursor({ - offset, - limit, - numberOfTotalRecords: result.count, - }), - }); - } catch (error) { - return handleError(error); - } - }, - ], - - createDataTable: [ - publicApiScope('dataTable:create'), - async (req, res) => { - const payload = PublicApiCreateDataTableDto.safeParse(req.body); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body'); - } - - const { projectId, name, columns, fileId, hasHeaders } = payload.data; - const dataTableService = Container.get(DataTableService); - - try { - const owningProjectId = await dataTableService.resolveOwningProjectId(req.user, projectId); - const result = await dataTableService.createDataTable(owningProjectId, { - name, - columns, - fileId, - hasHeaders, - }); - - return res.status(201).json(await attachSize(toPublicDataTable(result))); - } catch (error) { - return handleError(error); - } - }, - ], - - getDataTable: [ - publicApiScope('dataTable:read'), - projectScope('dataTable:read', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const projectId = - await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - - const result = await Container.get(DataTableService).getOne(dataTableId, projectId); - - return res.json(await attachSize(toPublicDataTable(result))); - } catch (error) { - return handleError(error); - } - }, - ], - - updateDataTable: [ - publicApiScope('dataTable:update'), - projectScope('dataTable:update', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const payload = UpdateDataTableDto.safeParse(req.body); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body'); - } - - const dataTableService = Container.get(DataTableService); - const projectId = await dataTableService.getProjectIdForDataTable(dataTableId); - - await dataTableService.updateDataTable(dataTableId, projectId, payload.data); - - const result = await dataTableService.getOne(dataTableId, projectId); - - return res.json(await attachSize(toPublicDataTable(result))); - } catch (error) { - return handleError(error); - } - }, - ], - - deleteDataTable: [ - publicApiScope('dataTable:delete'), - projectScope('dataTable:delete', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const projectId = - await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - - await Container.get(DataTableService).deleteDataTable(dataTableId, projectId); - - return res.status(204).send(); - } catch (error) { - return handleError(error); - } - }, - ], -}; - -export = dataTableHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/createDataTable.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/createDataTable.generated.yml new file mode 100644 index 00000000000..8899acfe035 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/createDataTable.generated.yml @@ -0,0 +1,79 @@ +operationId: createDataTable +tags: + - DataTable +summary: Create a new data table +description: Create a new data table in your personal project or a team project you have access to. +x-required-scope: dataTable:create +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 128 + description: Name of the data table + example: customers + columns: + type: array + items: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 63 + pattern: ^[a-zA-Z][a-zA-Z0-9_]*$ + description: Column name + type: + type: string + enum: + - string + - number + - boolean + - date + description: Column data type + csvColumnName: + type: string + description: Name of the CSV column to read the values from. If you do not set it, n8n maps the CSV columns by position. + example: Email Address + required: + - name + - type + description: Column definitions for the table + projectId: + type: string + description: ID of the project to create the table in. When omitted, the table is created in the user's personal project. + example: a1b2c3d4 + fileId: + type: string + description: ID of a CSV file that you uploaded in the n8n editor. If you set it, n8n fills the new table with the rows from that file. Only a session-authenticated caller can upload a file, so an API-key caller cannot use this field. + hasHeaders: + type: boolean + description: Set to true if the first row of the CSV file holds the column names. Applies only when you set `fileId`. The default is true. + required: + - name + - columns +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.yml + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '409': + $ref: ../../../../shared/spec/responses/conflict.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml deleted file mode 100644 index 0bd363ec2c6..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml +++ /dev/null @@ -1,82 +0,0 @@ -get: - x-eov-operation-id: getDataTable - x-required-scope: dataTable:read - x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler - tags: - - DataTable - summary: Get a data table - description: Retrieve a specific data table by ID. - operationId: get-data-table - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - responses: - '200': - description: Successfully retrieved data table - content: - application/json: - schema: - $ref: '../schemas/dataTable.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] - -patch: - x-eov-operation-id: updateDataTable - x-required-scope: dataTable:update - x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler - tags: - - DataTable - summary: Update a data table - description: Update a data table's name. - operationId: update-data-table - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - requestBody: - required: true - content: - application/json: - schema: - $ref: '../schemas/updateDataTableRequest.yml' - example: - name: 'updated-customers' - responses: - '200': - description: Data table updated successfully - content: - application/json: - schema: - $ref: '../schemas/dataTable.yml' - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - '409': - $ref: '../../../../shared/spec/responses/conflict.yml' - security: - - ApiKeyAuth: [] - -delete: - x-eov-operation-id: deleteDataTable - x-required-scope: dataTable:delete - x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler - tags: - - DataTable - summary: Delete a data table - description: Delete a data table. This will also delete all rows in the table. - operationId: delete-data-table - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - responses: - '204': - description: Data table deleted successfully - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml deleted file mode 100644 index 7ce18f865ea..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml +++ /dev/null @@ -1,93 +0,0 @@ -get: - x-eov-operation-id: listDataTables - x-required-scope: dataTable:list - x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler - tags: - - DataTable - summary: List all data tables - description: Retrieve a list of all data tables with optional filtering, sorting, and pagination. Each table includes `sizeBytes`, the value the `size` sort option orders by. - operationId: list-data-tables - parameters: - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - - name: filter - in: query - description: JSON string of filter conditions - schema: - type: string - format: jsonString - example: '{"name":"my-table"}' - - name: sortBy - in: query - description: 'Sort format: field:asc or field:desc' - schema: - type: string - example: 'name:asc' - responses: - '200': - description: Successfully retrieved data tables - content: - application/json: - schema: - $ref: '../schemas/dataTableList.yml' - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - security: - - ApiKeyAuth: [] - -post: - x-eov-operation-id: createDataTable - x-required-scope: dataTable:create - x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler - tags: - - DataTable - summary: Create a new data table - description: Create a new data table in your personal project or a team project you have access to. - operationId: create-data-table - requestBody: - required: true - content: - application/json: - schema: - $ref: '../schemas/createDataTableRequest.yml' - examples: - personalProject: - summary: Default (personal project) - value: - name: 'customers' - columns: - - name: 'email' - type: 'string' - - name: 'status' - type: 'string' - - name: 'age' - type: 'number' - scopedProject: - summary: Explicit project - value: - name: 'customers' - projectId: 'a1b2c3d4' - columns: - - name: 'email' - type: 'string' - - name: 'status' - type: 'string' - responses: - '201': - description: Data table created successfully - content: - application/json: - schema: - $ref: '../schemas/dataTable.yml' - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '409': - $ref: '../../../../shared/spec/responses/conflict.yml' - security: - - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTable.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTable.generated.yml new file mode 100644 index 00000000000..6f9c11d8046 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTable.generated.yml @@ -0,0 +1,27 @@ +operationId: deleteDataTable +tags: + - DataTable +summary: Delete a data table +description: Delete a data table. This will also delete all rows in the table. +x-required-scope: dataTable:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path +responses: + '204': + description: Operation successful. + '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/data-tables/spec/paths/getDataTable.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/getDataTable.generated.yml new file mode 100644 index 00000000000..86ebb79382c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/getDataTable.generated.yml @@ -0,0 +1,31 @@ +operationId: getDataTable +tags: + - DataTable +summary: Get a data table +description: Retrieve a specific data table by ID. +x-required-scope: dataTable:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.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/data-tables/spec/paths/listDataTables.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/listDataTables.generated.yml new file mode 100644 index 00000000000..3388d065698 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/listDataTables.generated.yml @@ -0,0 +1,129 @@ +operationId: listDataTables +tags: + - DataTable +summary: List all data tables +description: Retrieve a list of all data tables with optional filtering, sorting, and pagination. Each table includes `sizeBytes`, the value the `size` sort option orders by. +x-required-scope: dataTable:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + format: jsonString + required: false + description: JSON string of filter conditions + example: '{"name":"my-table"}' + name: filter + in: query + - schema: + type: string + enum: + - name:asc + - name:desc + - createdAt:asc + - createdAt:desc + - updatedAt:asc + - updatedAt:desc + - size:asc + - size:desc + required: false + description: 'Sort format: field:asc or field:desc' + example: name:asc + name: sortBy + in: query +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: Unique identifier for the data table + name: + type: string + description: Name of the data table + columns: + type: array + items: + type: object + properties: + id: + type: string + description: Column ID + name: + type: string + description: Column name + type: + type: string + description: Column data type + enum: + - string + - number + - boolean + - date + index: + type: integer + description: Column position + createdAt: + type: string + format: date-time + description: Timestamp when the column was created + updatedAt: + type: string + format: date-time + description: Timestamp when the column was last updated + required: + - id + - name + - type + - index + - createdAt + - updatedAt + description: Column definitions + projectId: + type: string + description: ID of the project this table belongs to + createdAt: + type: string + format: date-time + description: Timestamp when the table was created + updatedAt: + type: string + format: date-time + description: Timestamp when the table was last updated + sizeBytes: + type: integer + description: Physical storage in bytes, including indexes and internal overhead. Not reduced immediately by row deletion, and may be a few seconds stale. + required: + - id + - name + - columns + - projectId + - createdAt + - updatedAt + - sizeBytes + nextCursor: + type: string + nullable: true + description: Paginate through data tables by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTable.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTable.generated.yml new file mode 100644 index 00000000000..05f540ca857 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTable.generated.yml @@ -0,0 +1,52 @@ +operationId: updateDataTable +tags: + - DataTable +summary: Update a data table +description: Update a data table's name. +x-required-scope: dataTable:update +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 128 + description: New name for the data table + example: updated-customers + required: + - name +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.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 + '409': + $ref: ../../../../shared/spec/responses/conflict.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml deleted file mode 100644 index 44ce2cd0324..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml +++ /dev/null @@ -1,31 +0,0 @@ -type: object -properties: - name: - type: string - description: Name of the data table - minLength: 1 - maxLength: 128 - columns: - type: array - description: Column definitions for the table - items: - type: object - properties: - name: - type: string - description: Column name - minLength: 1 - type: - type: string - enum: [string, number, boolean, date, json] - description: Column data type - required: - - name - - type - projectId: - type: string - description: | - ID of the project to create the table in. When omitted, the table is created in the user's personal project. -required: - - name - - columns diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml deleted file mode 100644 index fe95ade560a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './dataTable.yml' - nextCursor: - type: string - description: Paginate through data tables by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. - nullable: true - example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml deleted file mode 100644 index d4a5e0fc159..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml +++ /dev/null @@ -1,9 +0,0 @@ -type: object -properties: - name: - type: string - description: New name for the data table - minLength: 1 - maxLength: 128 -required: - - name 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 c748db31b9a..b9c422b1712 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 @@ -52,6 +52,18 @@ paths: /credentials/schema/{credentialTypeName}: get: $ref: ./handlers/credentials/spec/paths/getCredentialType.generated.yml + /data-tables: + get: + $ref: ./handlers/data-tables/spec/paths/listDataTables.generated.yml + post: + $ref: ./handlers/data-tables/spec/paths/createDataTable.generated.yml + /data-tables/{dataTableId}: + get: + $ref: ./handlers/data-tables/spec/paths/getDataTable.generated.yml + patch: + $ref: ./handlers/data-tables/spec/paths/updateDataTable.generated.yml + delete: + $ref: ./handlers/data-tables/spec/paths/deleteDataTable.generated.yml /discover: get: $ref: ./handlers/discover/spec/paths/getDiscover.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 062e0b6cbf0..ef45800af02 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -97,10 +97,6 @@ paths: $ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml' /workflows/{id}/test-runs/{runId}/cancel: $ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.runId.cancel.yml' - /data-tables: - $ref: './handlers/data-tables/spec/paths/data-tables.yml' - /data-tables/{dataTableId}: - $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.yml' /data-tables/{dataTableId}/rows: $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml' /data-tables/{dataTableId}/rows/update: diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/dataTablePublicDto.generated.yml similarity index 70% rename from packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml rename to packages/cli/src/public-api/v1/shared/spec/schemas/dataTablePublicDto.generated.yml index df88cd7a3de..37f0485f542 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/dataTablePublicDto.generated.yml @@ -8,7 +8,6 @@ properties: description: Name of the data table columns: type: array - description: Column definitions items: type: object properties: @@ -20,11 +19,31 @@ properties: description: Column name type: type: string - enum: [string, number, boolean, date] description: Column data type + enum: + - string + - number + - boolean + - date index: type: integer description: Column position + createdAt: + type: string + format: date-time + description: Timestamp when the column was created + updatedAt: + type: string + format: date-time + description: Timestamp when the column was last updated + required: + - id + - name + - type + - index + - createdAt + - updatedAt + description: Column definitions projectId: type: string description: ID of the project this table belongs to diff --git a/packages/cli/test/integration/public-api/data-tables.test.ts b/packages/cli/test/integration/public-api/data-tables.test.ts index 07a9ea93cde..02b3c427279 100644 --- a/packages/cli/test/integration/public-api/data-tables.test.ts +++ b/packages/cli/test/integration/public-api/data-tables.test.ts @@ -75,11 +75,39 @@ const testWithAPIKey = expect(response.statusCode).toBe(401); }; +// The API key scope check runs before RBAC, so a key scoped to an unrelated resource is rejected +// even for an instance owner. +const createUnscopedAgent = async () => { + const unscopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] }); + return testServer.publicApiAgentFor(unscopedOwner); +}; + describe('GET /data-tables', () => { test('should fail due to missing API Key', testWithAPIKey('get', '/data-tables', null)); test('should fail due to invalid API Key', testWithAPIKey('get', '/data-tables', 'abcXYZ')); + test('should reject listing without dataTable:list', async () => { + const unscopedAgent = await createUnscopedAgent(); + const response = await unscopedAgent.get('/data-tables'); + + expect(response.statusCode).toBe(403); + }); + + test('should reject an undecodable cursor', async () => { + const response = await authOwnerAgent.get('/data-tables').query({ cursor: 'not-a-cursor' }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message', 'An invalid cursor was provided'); + }); + + test('should reject a filter that is not valid JSON', async () => { + const response = await authOwnerAgent.get('/data-tables').query({ filter: '{not json' }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + test('should list data tables', async () => { await createDataTable(ownerPersonalProject, { name: 'table1', @@ -93,16 +121,48 @@ describe('GET /data-tables', () => { const response = await authOwnerAgent.get('/data-tables'); expect(response.statusCode).toBe(200); - expect(response.body).toHaveProperty('data'); - expect(response.body).toHaveProperty('nextCursor'); - expect(response.body.data).toHaveLength(2); - expect(response.body.nextCursor).toBeNull(); - expect(response.body.data[0]).toHaveProperty('id'); - expect(response.body.data[0]).toHaveProperty('name'); - expect(response.body.data[0]).toHaveProperty('columns'); - for (const dataTable of response.body.data) { - expect(typeof dataTable.sizeBytes).toBe('number'); - } + // Default order is updatedAt DESC, so the most recently created table comes first. + expect(response.body).toStrictEqual({ + data: [ + { + id: expect.any(String), + name: 'table2', + columns: [ + { + id: expect.any(String), + name: 'age', + type: 'number', + index: expect.any(Number), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ], + projectId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + sizeBytes: expect.any(Number), + }, + { + id: expect.any(String), + name: 'table1', + columns: [ + { + id: expect.any(String), + name: 'name', + type: 'string', + index: expect.any(Number), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ], + projectId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + sizeBytes: expect.any(Number), + }, + ], + nextCursor: null, + }); }); test('should paginate data tables', async () => { @@ -348,6 +408,24 @@ describe('POST /data-tables', () => { test('should fail due to invalid API Key', testWithAPIKey('post', '/data-tables', 'abcXYZ')); + test('should reject creating without dataTable:create', async () => { + const unscopedAgent = await createUnscopedAgent(); + const response = await unscopedAgent + .post('/data-tables') + .send({ name: 'blocked', columns: [{ name: 'col1', type: 'string' }] }); + + expect(response.statusCode).toBe(403); + }); + + test('should reject a create body with no name', async () => { + const response = await authOwnerAgent + .post('/data-tables') + .send({ columns: [{ name: 'col1', type: 'string' }] }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain("request/body must have required property 'name'"); + }); + test('should create a data table', async () => { const response = await authOwnerAgent.post('/data-tables').send({ name: 'my-table', @@ -358,12 +436,34 @@ describe('POST /data-tables', () => { }); expect(response.statusCode).toBe(201); - expect(response.body).toHaveProperty('id'); - expect(response.body).toHaveProperty('name', 'my-table'); - expect(response.body).toHaveProperty('columns'); - expect(response.body.columns).toHaveLength(2); - expect(response.body).toHaveProperty('projectId', ownerPersonalProject.id); - expect(typeof response.body.sizeBytes).toBe('number'); + // Columns aren't guaranteed to come back in index order, so sort before comparing. + const columns = [...response.body.columns].sort((a, b) => a.index - b.index); + expect({ ...response.body, columns }).toStrictEqual({ + id: expect.any(String), + name: 'my-table', + columns: [ + { + id: expect.any(String), + name: 'email', + type: 'string', + index: 0, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + { + id: expect.any(String), + name: 'age', + type: 'number', + index: 1, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ], + projectId: ownerPersonalProject.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + sizeBytes: expect.any(Number), + }); }); test('should fail with duplicate name', async () => { @@ -415,6 +515,40 @@ describe('POST /data-tables', () => { expect(response.statusCode).toBe(400); expect(response.body).toHaveProperty('message'); }); + + test('forwards fileId to the import', async () => { + const response = await authOwnerAgent.post('/data-tables').send({ + name: 'csv-table', + columns: [{ name: 'email', type: 'string', csvColumnName: 'Email Address' }], + fileId: 'no-such-file', + hasHeaders: true, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('Error uploading file'); + }); + + test('does not keep the table when the import fails', async () => { + await authOwnerAgent.post('/data-tables').send({ + name: 'csv-table', + columns: [{ name: 'email', type: 'string' }], + fileId: 'no-such-file', + }); + + const response = await authOwnerAgent.get('/data-tables'); + + expect(response.body.data).toHaveLength(0); + }); + + test('accepts csvColumnName on a create that has no fileId', async () => { + const response = await authOwnerAgent.post('/data-tables').send({ + name: 'plain-table', + columns: [{ name: 'email', type: 'string', csvColumnName: 'Email Address' }], + }); + + expect(response.statusCode).toBe(201); + expect(response.body.columns).toHaveLength(1); + }); }); describe('GET /data-tables/:dataTableId', () => { @@ -433,6 +567,25 @@ describe('GET /data-tables/:dataTableId', () => { ); }); + test('should reject a malformed data table id', async () => { + const response = await authOwnerAgent.get('/data-tables/not-a-nanoid'); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + + test('should reject reading without dataTable:read', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'scope-table', + columns: [{ name: 'col1', type: 'string' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent.get(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(403); + }); + test('should get a data table', async () => { const dataTable = await createDataTable(ownerPersonalProject, { name: 'test-table', @@ -445,11 +598,34 @@ describe('GET /data-tables/:dataTableId', () => { const response = await authOwnerAgent.get(`/data-tables/${dataTable.id}`); expect(response.statusCode).toBe(200); - expect(response.body).toHaveProperty('id', dataTable.id); - expect(response.body).toHaveProperty('name', 'test-table'); - expect(response.body).toHaveProperty('columns'); - expect(response.body.columns).toHaveLength(2); - expect(typeof response.body.sizeBytes).toBe('number'); + // Columns aren't guaranteed to come back in index order, so sort before comparing. + const columns = [...response.body.columns].sort((a, b) => a.index - b.index); + expect({ ...response.body, columns }).toStrictEqual({ + id: dataTable.id, + name: 'test-table', + columns: [ + { + id: expect.any(String), + name: 'name', + type: 'string', + index: 0, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + { + id: expect.any(String), + name: 'age', + type: 'number', + index: 1, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ], + projectId: ownerPersonalProject.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + sizeBytes: expect.any(Number), + }); }); test('should return 403 when user does not have access to the data table', async () => { @@ -496,6 +672,41 @@ describe('PATCH /data-tables/:dataTableId', () => { ); }); + test('should reject a malformed data table id', async () => { + const response = await authOwnerAgent + .patch('/data-tables/not-a-nanoid') + .send({ name: 'whatever' }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + + test('should reject an update body with an empty name', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'validation-table', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}`).send({ name: '' }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + + test('should reject updating without dataTable:update', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'scope-table', + columns: [{ name: 'col1', type: 'string' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent + .patch(`/data-tables/${dataTable.id}`) + .send({ name: 'blocked' }); + + expect(response.statusCode).toBe(403); + }); + test('should update a data table name', async () => { const dataTable = await createDataTable(ownerPersonalProject, { name: 'old-name', @@ -507,9 +718,24 @@ describe('PATCH /data-tables/:dataTableId', () => { }); expect(response.statusCode).toBe(200); - expect(response.body).toHaveProperty('id', dataTable.id); - expect(response.body).toHaveProperty('name', 'new-name'); - expect(typeof response.body.sizeBytes).toBe('number'); + expect(response.body).toStrictEqual({ + id: dataTable.id, + name: 'new-name', + columns: [ + { + id: expect.any(String), + name: 'col1', + type: 'string', + index: expect.any(Number), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ], + projectId: ownerPersonalProject.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + sizeBytes: expect.any(Number), + }); }); test('should fail with duplicate name', async () => { @@ -564,6 +790,25 @@ describe('DELETE /data-tables/:dataTableId', () => { ); }); + test('should reject a malformed data table id', async () => { + const response = await authOwnerAgent.delete('/data-tables/not-a-nanoid'); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + + test('should reject deleting without dataTable:delete', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'scope-table', + columns: [{ name: 'col1', type: 'string' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent.delete(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(403); + }); + test('should delete a data table', async () => { const dataTable = await createDataTable(ownerPersonalProject, { name: 'table-to-delete',