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 index 925f6425aa9..855d8c0d6c1 100644 --- 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 @@ -62,3 +62,9 @@ export class CreateDataTablePublicDto extends Z.class({ export class UpdateDataTablePublicDto extends Z.class({ name: dataTableNameSchema.openapi(updateDataTableFieldDocs.name), }) {} + +const dataTableListColumnPublicSchema = dataTableColumnPublicSchema.extend({ + dataTableId: z.string().openapi(dataTableColumnFieldDocs.dataTableId), +}); + +export class DataTableColumnListPublicDto extends Z.array(dataTableListColumnPublicSchema) {} 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 index a8a8c0a7b90..abd5a754345 100644 --- 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 @@ -24,6 +24,7 @@ export const dataTableColumnFieldDocs = { index: { type: 'integer', description: 'Column position' }, createdAt: { description: 'Timestamp when the column was created' }, updatedAt: { description: 'Timestamp when the column was last updated' }, + dataTableId: { description: 'ID of the data table this column belongs to' }, } as const satisfies Record; export const dataTableListFieldDocs = { diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index a0764aa5e84..dbba80e811d 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -422,6 +422,7 @@ export { CreateDataTableDto } from './data-table/create-data-table.dto'; export { UpdateDataTableDto } from './data-table/update-data-table.dto'; export { CreateDataTablePublicDto, + DataTableColumnListPublicDto, DataTableListPublicDto, DataTablePublicDto, UpdateDataTablePublicDto, diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 4a909ba8718..a1855c74a8c 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -159,8 +159,6 @@ export declare namespace DataTableRequest { } >; - type ListColumns = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; - type CreateColumn = AuthenticatedRequest<{ dataTableId: string }, {}, AddDataTableColumnDto, {}>; type DeleteColumn = AuthenticatedRequest<{ dataTableId: string; columnId: 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 index 69408246472..c20ea36424a 100644 --- 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 @@ -1,5 +1,6 @@ import { CreateDataTablePublicDto, + DataTableColumnListPublicDto, DataTableListPublicDto, DataTablePublicDto, PublicApiListDataTableQueryDto, @@ -31,6 +32,7 @@ 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 { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; 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'; @@ -83,6 +85,18 @@ const toDataTablePublicDto = (dataTable: DataTable, sizeBytes: number): DataTabl sizeBytes, }); +const toDataTableColumnPublicDto = ( + column: DataTableColumn, +): DataTableColumnListPublicDto[number] => ({ + id: column.id, + name: column.name, + dataTableId: column.dataTableId, + type: column.type, + index: column.index, + createdAt: column.createdAt.toISOString(), + updatedAt: column.updatedAt.toISOString(), +}); + @PublicApiController('/data-tables') export class DataTablesPublicController { constructor( @@ -232,6 +246,25 @@ export class DataTablesPublicController { } } + @Get('/:dataTableId/columns') + @ApiKeyScope('dataTableColumn:read') + @ProjectScope('dataTable:readColumn') + @ApiSummary('List columns of a data table') + @ApiDescription('Retrieve all columns for a specific data table.') + @ApiTags(tags) + @ApiResponse(200, DataTableColumnListPublicDto) + @ApiErrorResponse(404) + async listDataTableColumns( + _req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + ): Promise { + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + const columns = await this.dataTableService.getColumns(dataTableId, projectId); + + return columns.map(toDataTableColumnPublicDto); + } + private async withSize(dataTable: DataTable): Promise { const sizes = await this.dataTableService.getCachedSizeBytesByIds([dataTable.id]); diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts index 96b6084405a..20122d4b9ce 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts @@ -23,23 +23,12 @@ const handleError = (error: unknown) => { }; type DataTableColumnsHandlers = { - listDataTableColumns: PublicAPIEndpoint; createDataTableColumn: PublicAPIEndpoint; deleteDataTableColumn: PublicAPIEndpoint; updateDataTableColumn: PublicAPIEndpoint; }; const dataTableColumnsHandlers: DataTableColumnsHandlers = { - listDataTableColumns: [ - publicApiScope('dataTableColumn:read'), - projectScope('dataTable:readColumn', 'dataTable'), - async (req, res) => { - const { dataTableId } = req.params; - const projectId = await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - return res.json(await Container.get(DataTableService).getColumns(dataTableId, projectId)); - }, - ], - createDataTableColumn: [ publicApiScope('dataTableColumn:create'), projectScope('dataTable:writeColumn', 'dataTable'), diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml index bd1db36682d..9667ed9e9ba 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml @@ -1,30 +1,3 @@ -get: - x-eov-operation-id: listDataTableColumns - x-required-scope: dataTableColumn:read - x-eov-operation-handler: v1/handlers/data-tables/data-tables.columns.handler - tags: - - DataTable - summary: List columns of a data table - description: Retrieve all columns for a specific data table. - operationId: list-data-table-columns - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - responses: - '200': - description: Successfully retrieved columns - content: - application/json: - schema: - type: array - items: - $ref: '../schemas/dataTableColumn.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] - post: x-eov-operation-id: createDataTableColumn x-required-scope: dataTableColumn:create diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/listDataTableColumns.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/listDataTableColumns.generated.yml new file mode 100644 index 00000000000..1d914eab6a7 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/listDataTableColumns.generated.yml @@ -0,0 +1,70 @@ +operationId: listDataTableColumns +tags: + - DataTable +summary: List columns of a data table +description: Retrieve all columns for a specific data table. +x-required-scope: dataTableColumn: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: + 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 + dataTableId: + type: string + description: ID of the data table this column belongs to + required: + - id + - name + - type + - index + - createdAt + - updatedAt + - dataTableId + '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 9ffb54dd994..76882456bec 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 @@ -74,6 +74,9 @@ paths: $ref: ./handlers/data-tables/spec/paths/updateDataTable.generated.yml delete: $ref: ./handlers/data-tables/spec/paths/deleteDataTable.generated.yml + /data-tables/{dataTableId}/columns: + get: + $ref: ./handlers/data-tables/spec/paths/listDataTableColumns.generated.yml /discover: get: $ref: ./handlers/discover/spec/paths/getDiscover.generated.yml 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 8b74f12d49a..58da5d4a45e 100644 --- a/packages/cli/test/integration/public-api/data-tables.test.ts +++ b/packages/cli/test/integration/public-api/data-tables.test.ts @@ -2079,6 +2079,25 @@ describe('GET /data-tables/:dataTableId/columns', () => { testWithAPIKey('get', '/data-tables/123/columns', 'abcXYZ'), ); + test('should reject listing without dataTableColumn:read', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'scope-columns-table', + columns: [{ name: 'col1', type: 'string' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent.get(`/data-tables/${dataTable.id}/columns`); + + expect(response.statusCode).toBe(403); + }); + + test('should reject a malformed data table id', async () => { + const response = await authOwnerAgent.get('/data-tables/not-a-nanoid/columns'); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + test('should list columns for a data table', async () => { const dataTable = await createDataTable(ownerPersonalProject, { name: 'columns-test', @@ -2086,19 +2105,54 @@ describe('GET /data-tables/:dataTableId/columns', () => { { name: 'name', type: 'string' }, { name: 'age', type: 'number' }, { name: 'active', type: 'boolean' }, + { name: 'signedUpAt', type: 'date' }, ], }); const response = await authOwnerAgent.get(`/data-tables/${dataTable.id}/columns`); expect(response.statusCode).toBe(200); - expect(Array.isArray(response.body)).toBe(true); - expect(response.body).toHaveLength(3); - expect(response.body[0]).toHaveProperty('id'); - expect(response.body[0]).toHaveProperty('name'); - expect(response.body[0]).toHaveProperty('type'); - expect(response.body[0]).toHaveProperty('index'); - expect(response.body[0]).toHaveProperty('dataTableId', dataTable.id); + + // Columns aren't guaranteed to come back in index order, so sort before comparing. + const columns = [...response.body].sort((a, b) => a.index - b.index); + expect(columns).toStrictEqual([ + { + id: expect.any(String), + name: 'name', + type: 'string', + index: 0, + dataTableId: dataTable.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + { + id: expect.any(String), + name: 'age', + type: 'number', + index: 1, + dataTableId: dataTable.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + { + id: expect.any(String), + name: 'active', + type: 'boolean', + index: 2, + dataTableId: dataTable.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + { + id: expect.any(String), + name: 'signedUpAt', + type: 'date', + index: 3, + dataTableId: dataTable.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + ]); }); test('should return 404 for non-existing data table', async () => {