mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
chore: Migrate data-tables table-level endpoints to PublicApiController (#38639)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Sam Wooler <swooler592@gmail.com>
This commit is contained in:
parent
3f4d63ca8a
commit
88c3962423
|
|
@ -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<typeof dataTablePublicSchema>;
|
||||
|
||||
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),
|
||||
}) {}
|
||||
|
|
@ -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<string, ZodOpenAPIMetadata>;
|
||||
|
||||
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<string, ZodOpenAPIMetadata>;
|
||||
|
||||
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<string, ZodOpenAPIMetadata>;
|
||||
|
||||
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<string, ZodOpenAPIMetadata>;
|
||||
|
||||
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<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const updateDataTableFieldDocs = {
|
||||
name: { description: 'New name for the data table', example: 'updated-customers' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
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<string, ZodOpenAPIMetadata>;
|
||||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export {
|
|||
credentialTypeNameParamSchema,
|
||||
credentialTypePolicyIdParamSchema,
|
||||
credentialTypePolicyScopeIdParamSchema,
|
||||
dataTableIdParamSchema,
|
||||
executionIdParamSchema,
|
||||
nodeTypePolicyIdParamSchema,
|
||||
nodeTypePolicyScopeIdParamSchema,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -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<DataTableListPublicDto> {
|
||||
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<DataTablePublicDto> {
|
||||
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<DataTablePublicDto> {
|
||||
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<DataTablePublicDto> {
|
||||
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<void> {
|
||||
try {
|
||||
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
|
||||
|
||||
await this.dataTableService.deleteDataTable(dataTableId, projectId);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async withSize(dataTable: DataTable): Promise<DataTablePublicDto> {
|
||||
const sizes = await this.dataTableService.getCachedSizeBytesByIds([dataTable.id]);
|
||||
|
||||
return toDataTablePublicDto(dataTable, sizes.get(dataTable.id) ?? 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, Array<(...args: unknown[]) => unknown>>;
|
||||
let handler: Record<string, Array<(...args: unknown[]) => 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
|
||||
|
|
|
|||
|
|
@ -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<T extends { project?: unknown }>(dataTable: T) {
|
||||
const { project: _project, ...rest } = dataTable;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const attachSize = async <T extends { id: string }>(dataTable: T) => {
|
||||
const sizes = await Container.get(DataTableService).getCachedSizeBytesByIds([dataTable.id]);
|
||||
return { ...dataTable, sizeBytes: sizes.get(dataTable.id) ?? 0 };
|
||||
};
|
||||
|
||||
type DataTableHandlers = {
|
||||
listDataTables: PublicAPIEndpoint<DataTableRequest.List>;
|
||||
createDataTable: PublicAPIEndpoint<DataTableRequest.Create>;
|
||||
getDataTable: PublicAPIEndpoint<DataTableRequest.Get>;
|
||||
updateDataTable: PublicAPIEndpoint<DataTableRequest.Update>;
|
||||
deleteDataTable: PublicAPIEndpoint<DataTableRequest.Delete>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: New name for the data table
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
required:
|
||||
- name
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user