mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 01:45:48 +08:00
feat(core): Add data table resources to the public API (#23610)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
7129c88fd9
commit
6b474e4141
@@ -9,5 +9,5 @@ import {
|
||||
|
||||
export class AddDataTableRowsDto extends Z.class({
|
||||
data: z.array(z.record(dataTableColumnNameSchema, dataTableColumnValueSchema)),
|
||||
returnType: insertRowReturnType,
|
||||
returnType: insertRowReturnType.optional().default('count'),
|
||||
}) {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Z } from 'zod-class';
|
||||
|
||||
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
|
||||
import { dataTableColumnNameSchema } from '../../schemas/data-table.schema';
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const filterValidator = z
|
||||
.string()
|
||||
@@ -52,11 +52,13 @@ const sortByValidator = z
|
||||
|
||||
try {
|
||||
column = dataTableColumnNameSchema.parse(column);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
const errorMessage =
|
||||
e instanceof z.ZodError ? e.errors[0]?.message : 'Invalid sort columnName';
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid sort columnName',
|
||||
path: ['sort'],
|
||||
message: errorMessage,
|
||||
path: ['sortBy'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
@@ -81,3 +83,11 @@ export class ListDataTableContentQueryDto extends Z.class({
|
||||
sortBy: sortByValidator.optional(),
|
||||
search: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
export class PublicApiListDataTableContentQueryDto extends Z.class({
|
||||
limit: publicApiPaginationSchema.limit,
|
||||
offset: publicApiPaginationSchema.offset,
|
||||
filter: filterValidator.optional(),
|
||||
sortBy: sortByValidator.optional(),
|
||||
search: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
'name:asc',
|
||||
@@ -11,8 +11,6 @@ const VALID_SORT_OPTIONS = [
|
||||
'createdAt:desc',
|
||||
'updatedAt:asc',
|
||||
'updatedAt:desc',
|
||||
'sizeBytes:asc',
|
||||
'sizeBytes:desc',
|
||||
] as const;
|
||||
|
||||
export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
|
||||
@@ -68,3 +66,9 @@ export class ListDataTableQueryDto extends Z.class({
|
||||
filter: filterValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
||||
export class PublicApiListDataTableQueryDto extends Z.class({
|
||||
...publicApiPaginationSchema,
|
||||
filter: filterValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
||||
@@ -112,8 +112,14 @@ export { UpdateDataTableDto } from './data-table/update-data-table.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';
|
||||
export { ListDataTableQueryDto } from './data-table/list-data-table-query.dto';
|
||||
export { ListDataTableContentQueryDto } from './data-table/list-data-table-content-query.dto';
|
||||
export {
|
||||
ListDataTableQueryDto,
|
||||
PublicApiListDataTableQueryDto,
|
||||
} from './data-table/list-data-table-query.dto';
|
||||
export {
|
||||
ListDataTableContentQueryDto,
|
||||
PublicApiListDataTableContentQueryDto,
|
||||
} from './data-table/list-data-table-content-query.dto';
|
||||
export { CreateDataTableColumnDto } from './data-table/create-data-table-column.dto';
|
||||
export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto';
|
||||
export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export const MAX_ITEMS_PER_PAGE = 50;
|
||||
export const MAX_ITEMS_PER_PAGE = 250;
|
||||
|
||||
const skipValidator = z
|
||||
.string()
|
||||
@@ -39,3 +39,32 @@ export const paginationSchema = {
|
||||
};
|
||||
|
||||
export class PaginationDto extends Z.class(paginationSchema) {}
|
||||
|
||||
const offsetValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 0))
|
||||
.refine((val) => !isNaN(val) && Number.isInteger(val), {
|
||||
message: 'Param `offset` must be a valid integer',
|
||||
})
|
||||
.refine((val) => val >= 0, {
|
||||
message: 'Param `offset` must be a non-negative integer',
|
||||
});
|
||||
|
||||
const createLimitValidator = (maxItems: number) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 100))
|
||||
.refine((val) => !isNaN(val) && Number.isInteger(val), {
|
||||
message: 'Param `limit` must be a valid integer',
|
||||
})
|
||||
.refine((val) => val >= 0, {
|
||||
message: 'Param `limit` must be a non-negative integer',
|
||||
})
|
||||
.transform((val) => Math.min(val, maxItems));
|
||||
|
||||
export const publicApiPaginationSchema = {
|
||||
offset: offsetValidator,
|
||||
limit: createLimitValidator(MAX_ITEMS_PER_PAGE),
|
||||
};
|
||||
|
||||
@@ -67,6 +67,8 @@ export const API_KEY_RESOURCES = {
|
||||
credential: ['create', 'update', 'move', 'delete'] as const,
|
||||
sourceControl: ['pull'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
dataTable: ['create', 'read', 'update', 'delete', 'list'] as const,
|
||||
dataTableRow: ['create', 'read', 'update', 'delete', 'upsert'] as const,
|
||||
} as const;
|
||||
|
||||
export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner';
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { isApiKeyScope, type ApiKeyScope, type AuthPrincipal, type GlobalRole } from './types.ee';
|
||||
import {
|
||||
isApiKeyScope,
|
||||
type ApiKeyScope,
|
||||
type AuthPrincipal,
|
||||
type GlobalRole,
|
||||
type Scope,
|
||||
} from './types.ee';
|
||||
|
||||
export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'user:read',
|
||||
@@ -40,6 +46,16 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'credential:update',
|
||||
'credential:move',
|
||||
'credential:delete',
|
||||
'dataTable:create',
|
||||
'dataTable:read',
|
||||
'dataTable:update',
|
||||
'dataTable:delete',
|
||||
'dataTable:list',
|
||||
'dataTableRow:create',
|
||||
'dataTableRow:read',
|
||||
'dataTableRow:update',
|
||||
'dataTableRow:delete',
|
||||
'dataTableRow:upsert',
|
||||
];
|
||||
|
||||
export const ADMIN_API_KEY_SCOPES: ApiKeyScope[] = OWNER_API_KEY_SCOPES;
|
||||
@@ -67,6 +83,16 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'credential:update',
|
||||
'credential:move',
|
||||
'credential:delete',
|
||||
'dataTable:create',
|
||||
'dataTable:read',
|
||||
'dataTable:update',
|
||||
'dataTable:delete',
|
||||
'dataTable:list',
|
||||
'dataTableRow:create',
|
||||
'dataTableRow:read',
|
||||
'dataTableRow:update',
|
||||
'dataTableRow:delete',
|
||||
'dataTableRow:upsert',
|
||||
];
|
||||
|
||||
export const CHAT_USER_API_KEY_SCOPES: ApiKeyScope[] = [];
|
||||
@@ -96,6 +122,16 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
|
||||
'credential:update',
|
||||
'credential:move',
|
||||
'credential:delete',
|
||||
'dataTable:create',
|
||||
'dataTable:read',
|
||||
'dataTable:update',
|
||||
'dataTable:delete',
|
||||
'dataTable:list',
|
||||
'dataTableRow:create',
|
||||
'dataTableRow:read',
|
||||
'dataTableRow:update',
|
||||
'dataTableRow:delete',
|
||||
'dataTableRow:upsert',
|
||||
];
|
||||
|
||||
const MAP_ROLE_SCOPES: Record<GlobalRole, ApiKeyScope[]> = {
|
||||
@@ -112,8 +148,7 @@ export const getApiKeyScopesForRole = (user: AuthPrincipal) => {
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
user.role.scopes
|
||||
.map((scope) => scope.slug)
|
||||
(user.role.scopes.map((scope) => scope.slug) as Array<Scope | ApiKeyScope>)
|
||||
.concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT)
|
||||
.filter(isApiKeyScope),
|
||||
),
|
||||
|
||||
@@ -119,7 +119,7 @@ type AllApiKeyScopesObject = {
|
||||
|
||||
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
|
||||
|
||||
export function isApiKeyScope(scope: Scope): scope is ApiKeyScope {
|
||||
export function isApiKeyScope(scope: Scope | ApiKeyScope): scope is ApiKeyScope {
|
||||
// We are casting with as for runtime type checking
|
||||
return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { User } from '@n8n/db';
|
||||
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import { hasGlobalScope, type Scope } from '@n8n/permissions';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
@@ -24,7 +25,13 @@ export async function userHasScopes(
|
||||
credentialId,
|
||||
workflowId,
|
||||
projectId,
|
||||
}: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */,
|
||||
dataTableId,
|
||||
}: {
|
||||
credentialId?: string;
|
||||
workflowId?: string;
|
||||
projectId?: string;
|
||||
dataTableId?: string;
|
||||
} /* only one */,
|
||||
): Promise<boolean> {
|
||||
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true;
|
||||
|
||||
@@ -99,9 +106,29 @@ export async function userHasScopes(
|
||||
);
|
||||
}
|
||||
|
||||
if (dataTableId) {
|
||||
const moduleRegistry = Container.get(ModuleRegistry);
|
||||
if (!moduleRegistry.isActive('data-table')) {
|
||||
throw new NotFoundError(`Data table with ID "${dataTableId}" not found.`);
|
||||
}
|
||||
|
||||
const { DataTableRepository } = await import('@/modules/data-table/data-table.repository');
|
||||
const dataTable = await Container.get(DataTableRepository).findOne({
|
||||
where: { id: dataTableId },
|
||||
relations: ['project'],
|
||||
});
|
||||
|
||||
if (!dataTable) {
|
||||
throw new NotFoundError(`Data table with ID "${dataTableId}" not found.`);
|
||||
}
|
||||
|
||||
// Data tables don't have resource-level roles, only project-level access
|
||||
return userProjectIds.includes(dataTable.project.id);
|
||||
}
|
||||
|
||||
if (projectId) return userProjectIds.includes(projectId);
|
||||
|
||||
throw new UnexpectedError(
|
||||
"`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.",
|
||||
"`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Router } from 'express';
|
||||
import type { ErrorRequestHandler, Router } from 'express';
|
||||
import express from 'express';
|
||||
import type { HttpError } from 'express-openapi-validator/dist/framework/types';
|
||||
import fs from 'fs/promises';
|
||||
@@ -51,9 +51,22 @@ async function createApiRouter(
|
||||
});
|
||||
|
||||
const { middleware: openApiValidatorMiddleware } = await import('express-openapi-validator');
|
||||
|
||||
// Error handler specifically for JSON parsing - must come immediately after express.json()
|
||||
const jsonParseErrorHandler: ErrorRequestHandler = (error, _req, res, next) => {
|
||||
if (error instanceof SyntaxError && 'body' in error) {
|
||||
res.status(400).json({
|
||||
message: 'Invalid JSON in request body',
|
||||
});
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
};
|
||||
|
||||
apiController.use(
|
||||
`/${publicApiEndpoint}/${version}`,
|
||||
express.json(),
|
||||
jsonParseErrorHandler,
|
||||
openApiValidatorMiddleware({
|
||||
apiSpec: openApiSpecPath,
|
||||
operationHandlers: handlersDirectory,
|
||||
@@ -79,6 +92,13 @@ async function createApiRouter(
|
||||
}
|
||||
},
|
||||
},
|
||||
nanoid: {
|
||||
type: 'string',
|
||||
validate: (id: string) => {
|
||||
// Nanoids in n8n are 16 characters long and use alphanumeric characters
|
||||
return /^[A-Za-z0-9]{16}$/.test(id);
|
||||
},
|
||||
},
|
||||
},
|
||||
validateSecurity: {
|
||||
handlers: {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db';
|
||||
import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import type {
|
||||
AddDataTableRowsDto,
|
||||
CreateDataTableDto,
|
||||
UpdateDataTableDto,
|
||||
UpdateDataTableRowDto,
|
||||
UpsertDataTableRowDto,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import type { AuthlessRequest } from '@/requests';
|
||||
import type { Risk } from '@/security-audit/types';
|
||||
@@ -193,6 +200,64 @@ export interface IJsonSchema {
|
||||
required: string[];
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /data-tables
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace DataTableRequest {
|
||||
type List = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
offset?: number;
|
||||
filter?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type Create = AuthenticatedRequest<{}, {}, CreateDataTableDto, {}>;
|
||||
|
||||
type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
|
||||
|
||||
type Update = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableDto, {}>;
|
||||
|
||||
type Delete = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
|
||||
|
||||
type GetRows = AuthenticatedRequest<
|
||||
{ dataTableId: string },
|
||||
{},
|
||||
{},
|
||||
{
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
offset?: number;
|
||||
filter?: string;
|
||||
sortBy?: string;
|
||||
search?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type InsertRows = AuthenticatedRequest<{ dataTableId: string }, {}, AddDataTableRowsDto, {}>;
|
||||
|
||||
type UpdateRows = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableRowDto, {}>;
|
||||
|
||||
type UpsertRow = AuthenticatedRequest<{ dataTableId: string }, {}, UpsertDataTableRowDto, {}>;
|
||||
|
||||
type DeleteRows = AuthenticatedRequest<
|
||||
{ dataTableId: string },
|
||||
{},
|
||||
{},
|
||||
{
|
||||
filter?: string;
|
||||
returnData?: string | boolean;
|
||||
dryRun?: string | boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /audit
|
||||
// ----------------------------------
|
||||
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
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';
|
||||
|
||||
// Mock middleware before requiring handler
|
||||
const mockMiddleware = jest.fn(async (_req, _res, next) => next()) as any;
|
||||
jest.spyOn(middlewares, 'apiKeyHasScope').mockReturnValue(mockMiddleware);
|
||||
jest.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware);
|
||||
jest.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware);
|
||||
|
||||
const handler = require('../data-tables.rows.handler');
|
||||
|
||||
describe('DataTable Handler', () => {
|
||||
let mockDataTableService: jest.Mocked<DataTableService>;
|
||||
let mockDataTableRepository: jest.Mocked<DataTableRepository>;
|
||||
let mockProjectRepository: jest.Mocked<ProjectRepository>;
|
||||
let mockResponse: Partial<Response>;
|
||||
|
||||
const projectId = 'test-project-id';
|
||||
const dataTableId = 'test-data-table-id';
|
||||
const userId = 'test-user-id';
|
||||
|
||||
beforeEach(() => {
|
||||
mockDataTableService = mockInstance(DataTableService);
|
||||
mockDataTableRepository = mockInstance(DataTableRepository);
|
||||
mockProjectRepository = mockInstance(ProjectRepository);
|
||||
|
||||
jest.spyOn(Container, 'get').mockImplementation((serviceClass) => {
|
||||
if (serviceClass === DataTableService) {
|
||||
return mockDataTableService as any;
|
||||
}
|
||||
if (serviceClass === DataTableRepository) {
|
||||
return mockDataTableRepository as any;
|
||||
}
|
||||
if (serviceClass === ProjectRepository) {
|
||||
return mockProjectRepository as any;
|
||||
}
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
mockDataTableRepository.findOne.mockResolvedValue({
|
||||
id: dataTableId,
|
||||
project: { id: projectId },
|
||||
} as any);
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockResponse = {
|
||||
json: jest.fn().mockReturnThis(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getDataTableRows', () => {
|
||||
it('should retrieve rows successfully', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: { offset: '0', limit: '100' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
const mockRows = [
|
||||
{ id: 1, name: 'Test Row 1', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 2, name: 'Test Row 2', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
mockDataTableService.getManyRowsAndCount.mockResolvedValue({
|
||||
data: mockRows,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: dataTableId },
|
||||
relations: ['project'],
|
||||
});
|
||||
expect(mockDataTableService.getManyRowsAndCount).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{ skip: 0, take: 100, filter: undefined, sortBy: undefined, search: undefined },
|
||||
);
|
||||
const callArg = (mockResponse.json as jest.Mock).mock.calls[0][0];
|
||||
expect(callArg).toHaveProperty('data', mockRows);
|
||||
expect(callArg).toHaveProperty('nextCursor');
|
||||
});
|
||||
|
||||
it('should handle filter, sortBy, and search parameters', async () => {
|
||||
// Arrange
|
||||
const filterStr =
|
||||
'{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"active"}]}';
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
offset: '10',
|
||||
limit: '50',
|
||||
filter: filterStr,
|
||||
sortBy: 'createdAt:desc',
|
||||
search: 'test',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
mockDataTableService.getManyRowsAndCount.mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.getManyRowsAndCount).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
skip: 10,
|
||||
take: 50,
|
||||
filter: JSON.parse(filterStr),
|
||||
sortBy: ['createdAt', 'DESC'],
|
||||
search: 'test',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 when data table not found', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: { offset: '0', limit: '100' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
mockDataTableRepository.findOne.mockRejectedValue(new DataTableNotFoundError(dataTableId));
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(dataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for validation errors', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: { offset: '0', limit: '100', filter: 'invalid-json' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Invalid'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertDataTableRows', () => {
|
||||
it('should insert rows and return count', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
data: [{ name: 'New Row' }],
|
||||
returnType: 'count',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.InsertRows;
|
||||
|
||||
mockDataTableService.insertRows.mockResolvedValue({ count: 1 } as any);
|
||||
|
||||
// Act
|
||||
await handler.insertDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.insertRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
[{ name: 'New Row' }],
|
||||
'count',
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ count: 1 });
|
||||
});
|
||||
|
||||
it('should insert rows and return IDs', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
data: [{ name: 'Row 1' }, { name: 'Row 2' }],
|
||||
returnType: 'id',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.InsertRows;
|
||||
|
||||
mockDataTableService.insertRows.mockResolvedValue([1, 2] as any);
|
||||
|
||||
// Act
|
||||
await handler.insertDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.insertRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
[{ name: 'Row 1' }, { name: 'Row 2' }],
|
||||
'id',
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it('should insert rows and return full rows', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
data: [{ name: 'Test' }],
|
||||
returnType: 'all',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.InsertRows;
|
||||
|
||||
const mockRow = { id: 1, name: 'Test', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockDataTableService.insertRows.mockResolvedValue([mockRow]);
|
||||
|
||||
// Act
|
||||
await handler.insertDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
|
||||
});
|
||||
|
||||
it('should return 404 when data table not found', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: { data: [{ name: 'Test' }], returnType: 'count' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.InsertRows;
|
||||
|
||||
mockDataTableRepository.findOne.mockRejectedValue(new DataTableNotFoundError(dataTableId));
|
||||
|
||||
// Act
|
||||
await handler.insertDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDataTableRows', () => {
|
||||
it('should update rows and return true when returnData is false', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
mockDataTableService.updateRows.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should update rows and return updated rows when returnData is true', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
returnData: true,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
const mockRow = { id: 1, status: 'updated', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockDataTableService.updateRows.mockResolvedValue([mockRow] as any);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
|
||||
});
|
||||
|
||||
it('should support dry run mode', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
},
|
||||
data: { status: 'test' },
|
||||
returnData: true,
|
||||
dryRun: true,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
const mockDryRunResult = [
|
||||
{ id: 1, status: 'old', dryRunState: 'before' },
|
||||
{ id: 1, status: 'test', dryRunState: 'after' },
|
||||
];
|
||||
mockDataTableService.updateRows.mockResolvedValue(mockDryRunResult as any);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
expect.any(Object),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertDataTableRow', () => {
|
||||
it('should upsert row and return true when returnData is false', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }],
|
||||
},
|
||||
data: { email: 'test@example.com', name: 'Test User' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpsertRow;
|
||||
|
||||
mockDataTableService.upsertRow.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await handler.upsertDataTableRow[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.upsertRow).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
expect.objectContaining({
|
||||
filter: expect.any(Object),
|
||||
data: { email: 'test@example.com', name: 'Test User' },
|
||||
}),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should upsert row and return upserted row when returnData is true', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }],
|
||||
},
|
||||
data: { email: 'test@example.com', name: 'Test User' },
|
||||
returnData: true,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpsertRow;
|
||||
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDataTableService.upsertRow.mockResolvedValue(mockRow as any);
|
||||
|
||||
// Act
|
||||
await handler.upsertDataTableRow[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockRow);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTableRows', () => {
|
||||
it('should delete rows and return true when returnData is false', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'false',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
mockDataTableService.deleteRows.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
filter: JSON.parse(filterStr),
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should delete rows and return deleted rows when returnData is true', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'true',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
const mockRow = { id: 1, name: 'Deleted', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockDataTableService.deleteRows.mockResolvedValue([mockRow] as any);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
|
||||
});
|
||||
|
||||
it('should return 400 when filter is missing', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: 'Required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should support dry run mode', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'test' }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'true',
|
||||
dryRun: 'true',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
const mockRows = [{ id: 1, status: 'test', createdAt: new Date(), updatedAt: new Date() }];
|
||||
mockDataTableService.deleteRows.mockResolvedValue(mockRows as any);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
expect.objectContaining({ filter: expect.any(Object) }),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security - Cross-Project Access', () => {
|
||||
const otherUserDataTableId = 'other-user-data-table-id';
|
||||
|
||||
it('should return 404 when trying to get rows from another users data table', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
query: { offset: '0', limit: '100' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
// User's personal project is returned
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
// But the data table belongs to another project, so getProjectIdForDataTable throws
|
||||
mockDataTableRepository.findOne.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: otherUserDataTableId },
|
||||
relations: ['project'],
|
||||
});
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when trying to insert rows into another users data table', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
body: {
|
||||
data: [{ name: 'Malicious Row' }],
|
||||
returnType: 'count',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.InsertRows;
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockDataTableService.insertRows.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.insertDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when trying to update rows in another users data table', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'hacked' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockDataTableService.updateRows.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when trying to upsert row in another users data table', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
body: {
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'email', condition: 'eq', value: 'malicious@example.com' }],
|
||||
},
|
||||
data: { email: 'malicious@example.com', name: 'Hacker' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpsertRow;
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockDataTableService.upsertRow.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.upsertDataTableRow[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when trying to delete rows from another users data table', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'false',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockDataTableService.deleteRows.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not leak information about data table existence in error messages', async () => {
|
||||
// Arrange
|
||||
const nonExistentDataTableId = 'non-existent-table-id';
|
||||
const req = {
|
||||
params: { dataTableId: nonExistentDataTableId },
|
||||
query: { offset: '0', limit: '100' },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.GetRows;
|
||||
|
||||
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
|
||||
id: projectId,
|
||||
} as any);
|
||||
|
||||
mockDataTableRepository.findOne.mockRejectedValue(
|
||||
new DataTableNotFoundError(nonExistentDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
await handler.getDataTableRows[3](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
// The error message should be the same whether:
|
||||
// 1. The table doesn't exist at all
|
||||
// 2. The table exists but belongs to another user's project
|
||||
// This prevents information leakage
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining(nonExistentDataTableId),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
PublicApiListDataTableQueryDto,
|
||||
CreateDataTableDto,
|
||||
UpdateDataTableDto,
|
||||
} from '@n8n/api-types';
|
||||
import { ProjectRepository } from '@n8n/db';
|
||||
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
import { Container } from '@n8n/di';
|
||||
import type express from 'express';
|
||||
|
||||
import type { DataTableRequest } from '../../../types';
|
||||
import {
|
||||
apiKeyHasScope,
|
||||
projectScope,
|
||||
validCursor,
|
||||
} from '../../shared/middlewares/global.middleware';
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
import { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
|
||||
import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error';
|
||||
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
|
||||
|
||||
const handleError = (error: unknown, res: express.Response): express.Response => {
|
||||
if (error instanceof DataTableNotFoundError) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
if (error instanceof DataTableNameConflictError) {
|
||||
return res.status(409).json({ message: error.message });
|
||||
}
|
||||
if (error instanceof DataTableValidationError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert all query parameter values to strings for DTO validation.
|
||||
* Express/Supertest may parse some values as numbers/booleans.
|
||||
*/
|
||||
const stringifyQuery = (query: Record<string, unknown>): Record<string, string | undefined> => {
|
||||
const result: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the project ID for a data table.
|
||||
* Called AFTER projectScope middleware has validated access.
|
||||
*/
|
||||
const getProjectIdForDataTable = async (dataTableId: string): Promise<string> => {
|
||||
const dataTable = await Container.get(DataTableRepository).findOne({
|
||||
where: { id: dataTableId },
|
||||
relations: ['project'],
|
||||
});
|
||||
|
||||
if (!dataTable) {
|
||||
throw new DataTableNotFoundError(dataTableId);
|
||||
}
|
||||
|
||||
return dataTable.project.id;
|
||||
};
|
||||
|
||||
export = {
|
||||
listDataTables: [
|
||||
apiKeyHasScope('dataTable:list'),
|
||||
validCursor,
|
||||
async (req: DataTableRequest.List, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const payload = PublicApiListDataTableQueryDto.safeParse(stringifyQuery(req.query));
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid query parameters',
|
||||
});
|
||||
}
|
||||
|
||||
const { offset, limit, filter, sortBy } = payload.data;
|
||||
|
||||
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
const providedFilter = filter ?? {};
|
||||
const result = await Container.get(DataTableService).getManyAndCount({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
filter: { ...providedFilter, projectId: project.id },
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const data = result.data.map(({ project: _project, ...rest }) => rest);
|
||||
|
||||
return res.json({
|
||||
data,
|
||||
nextCursor: encodeNextCursor({
|
||||
offset,
|
||||
limit,
|
||||
numberOfTotalRecords: result.count,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
createDataTable: [
|
||||
apiKeyHasScope('dataTable:create'),
|
||||
async (req: DataTableRequest.Create, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const payload = CreateDataTableDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid request body',
|
||||
});
|
||||
}
|
||||
|
||||
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
const result = await Container.get(DataTableService).createDataTable(
|
||||
project.id,
|
||||
payload.data,
|
||||
);
|
||||
|
||||
const { project: _project, ...dataTable } = result;
|
||||
|
||||
return res.status(201).json(dataTable);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
getDataTable: [
|
||||
apiKeyHasScope('dataTable:read'),
|
||||
projectScope('dataTable:read', 'dataTable'),
|
||||
async (req: DataTableRequest.Get, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
|
||||
const result = await Container.get(DataTableRepository).findOne({
|
||||
where: { id: dataTableId, project: { id: projectId } },
|
||||
relations: ['project', 'columns'],
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new DataTableNotFoundError(dataTableId);
|
||||
}
|
||||
|
||||
const { project: _project, ...dataTable } = result;
|
||||
|
||||
return res.json(dataTable);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
updateDataTable: [
|
||||
apiKeyHasScope('dataTable:update'),
|
||||
projectScope('dataTable:update', 'dataTable'),
|
||||
async (req: DataTableRequest.Update, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = UpdateDataTableDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid request body',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
|
||||
await Container.get(DataTableService).updateDataTable(dataTableId, projectId, payload.data);
|
||||
|
||||
const result = await Container.get(DataTableRepository).findOne({
|
||||
where: { id: dataTableId, project: { id: projectId } },
|
||||
relations: ['project', 'columns'],
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new DataTableNotFoundError(dataTableId);
|
||||
}
|
||||
|
||||
const { project: _project, ...dataTable } = result;
|
||||
|
||||
return res.json(dataTable);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
deleteDataTable: [
|
||||
apiKeyHasScope('dataTable:delete'),
|
||||
projectScope('dataTable:delete', 'dataTable'),
|
||||
async (req: DataTableRequest.Delete, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
|
||||
await Container.get(DataTableService).deleteDataTable(dataTableId, projectId);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
PublicApiListDataTableContentQueryDto,
|
||||
AddDataTableRowsDto,
|
||||
UpdateDataTableRowDto,
|
||||
UpsertDataTableRowDto,
|
||||
DeleteDataTableRowsDto,
|
||||
} from '@n8n/api-types';
|
||||
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
import { Container } from '@n8n/di';
|
||||
import type express from 'express';
|
||||
|
||||
import type { DataTableRequest } from '../../../types';
|
||||
import {
|
||||
apiKeyHasScope,
|
||||
projectScope,
|
||||
validCursor,
|
||||
} from '../../shared/middlewares/global.middleware';
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
import { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
|
||||
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
|
||||
|
||||
const handleError = (error: unknown, res: express.Response): express.Response => {
|
||||
if (error instanceof DataTableNotFoundError) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
if (error instanceof DataTableValidationError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert all query parameter values to strings for DTO validation.
|
||||
* Express/Supertest may parse some values as numbers/booleans.
|
||||
*/
|
||||
const stringifyQuery = (query: Record<string, unknown>): Record<string, string | undefined> => {
|
||||
const result: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the project ID for a data table.
|
||||
* Called AFTER projectScope middleware has validated access.
|
||||
*/
|
||||
const getProjectIdForDataTable = async (dataTableId: string): Promise<string> => {
|
||||
const dataTable = await Container.get(DataTableRepository).findOne({
|
||||
where: { id: dataTableId },
|
||||
relations: ['project'],
|
||||
});
|
||||
|
||||
if (!dataTable) {
|
||||
throw new DataTableNotFoundError(dataTableId);
|
||||
}
|
||||
|
||||
return dataTable.project.id;
|
||||
};
|
||||
|
||||
export = {
|
||||
getDataTableRows: [
|
||||
apiKeyHasScope('dataTableRow:read'),
|
||||
projectScope('dataTable:readRow', 'dataTable'),
|
||||
validCursor,
|
||||
async (req: DataTableRequest.GetRows, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = PublicApiListDataTableContentQueryDto.safeParse(stringifyQuery(req.query));
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid query parameters',
|
||||
});
|
||||
}
|
||||
|
||||
const { offset, limit, filter, sortBy, search } = payload.data;
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
|
||||
const result = await Container.get(DataTableService).getManyRowsAndCount(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
skip: offset,
|
||||
take: limit,
|
||||
filter,
|
||||
sortBy,
|
||||
search,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
data: result.data,
|
||||
nextCursor: encodeNextCursor({
|
||||
offset,
|
||||
limit,
|
||||
numberOfTotalRecords: result.count,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
insertDataTableRows: [
|
||||
apiKeyHasScope('dataTableRow:create'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req: DataTableRequest.InsertRows, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = AddDataTableRowsDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid request body',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
|
||||
const result = await Container.get(DataTableService).insertRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
payload.data.data,
|
||||
payload.data.returnType,
|
||||
);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
updateDataTableRows: [
|
||||
apiKeyHasScope('dataTableRow:update'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req: DataTableRequest.UpdateRows, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = UpdateDataTableRowDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid request body',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
const service = Container.get(DataTableService);
|
||||
const { filter, data, returnData = false, dryRun = false } = payload.data;
|
||||
const params = { filter, data };
|
||||
|
||||
const result = dryRun
|
||||
? await service.updateRows(dataTableId, projectId, params, returnData, true)
|
||||
: returnData
|
||||
? await service.updateRows(dataTableId, projectId, params, true, false)
|
||||
: await service.updateRows(dataTableId, projectId, params, false, false);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
upsertDataTableRow: [
|
||||
apiKeyHasScope('dataTableRow:upsert'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req: DataTableRequest.UpsertRow, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = UpsertDataTableRowDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid request body',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
const service = Container.get(DataTableService);
|
||||
const { filter, data, returnData = false, dryRun = false } = payload.data;
|
||||
const params = { filter, data };
|
||||
|
||||
const result = dryRun
|
||||
? await service.upsertRow(dataTableId, projectId, params, returnData, true)
|
||||
: returnData
|
||||
? await service.upsertRow(dataTableId, projectId, params, true, false)
|
||||
: await service.upsertRow(dataTableId, projectId, params, false, false);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
deleteDataTableRows: [
|
||||
apiKeyHasScope('dataTableRow:delete'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req: DataTableRequest.DeleteRows, res: express.Response): Promise<express.Response> => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = DeleteDataTableRowsDto.safeParse(stringifyQuery(req.query));
|
||||
if (!payload.success) {
|
||||
return res.status(400).json({
|
||||
message: payload.error.errors[0]?.message || 'Invalid query parameters',
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdForDataTable(dataTableId);
|
||||
const service = Container.get(DataTableService);
|
||||
const { filter, returnData = false, dryRun = false } = payload.data;
|
||||
const params = { filter };
|
||||
|
||||
const result = dryRun
|
||||
? await service.deleteRows(dataTableId, projectId, params, returnData, true)
|
||||
: returnData
|
||||
? await service.deleteRows(dataTableId, projectId, params, true, false)
|
||||
: await service.deleteRows(dataTableId, projectId, params, false, false);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error, res);
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
delete:
|
||||
x-eov-operation-id: deleteDataTableRows
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Delete rows from a data table
|
||||
description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data.
|
||||
operationId: delete-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
- name: filter
|
||||
in: query
|
||||
required: true
|
||||
description: JSON string of filter conditions. Required to prevent accidental deletion of all data.
|
||||
schema:
|
||||
type: string
|
||||
format: jsonString
|
||||
example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}'
|
||||
- name: returnData
|
||||
in: query
|
||||
description: If true, return the deleted rows; if false, return true on success
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- name: dryRun
|
||||
in: query
|
||||
description: If true, preview which rows would be deleted without actually deleting them
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: Rows deleted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: boolean
|
||||
description: True when returnData is false
|
||||
- type: array
|
||||
items:
|
||||
$ref: '../schemas/dataTableRow.yml'
|
||||
description: Deleted rows when returnData is true
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
patch:
|
||||
x-eov-operation-id: updateDataTableRows
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Update rows in a data table
|
||||
description: Update rows matching filter conditions in a data table.
|
||||
operationId: update-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/updateRowsRequest.yml'
|
||||
example:
|
||||
filter:
|
||||
type: 'and'
|
||||
filters:
|
||||
- columnName: 'status'
|
||||
condition: 'eq'
|
||||
value: 'pending'
|
||||
data:
|
||||
status: 'completed'
|
||||
updatedBy: 'admin'
|
||||
returnData: false
|
||||
dryRun: false
|
||||
responses:
|
||||
'200':
|
||||
description: Rows updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: boolean
|
||||
description: True when returnData is false
|
||||
- type: array
|
||||
items:
|
||||
$ref: '../schemas/dataTableRow.yml'
|
||||
description: Updated rows when returnData is true
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
post:
|
||||
x-eov-operation-id: upsertDataTableRow
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Upsert a row in a data table
|
||||
description: Update an existing row or insert a new one if no row matches the filter conditions.
|
||||
operationId: upsert-data-table-row
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/upsertRowRequest.yml'
|
||||
example:
|
||||
filter:
|
||||
type: 'and'
|
||||
filters:
|
||||
- columnName: 'email'
|
||||
condition: 'eq'
|
||||
value: 'user@example.com'
|
||||
data:
|
||||
email: 'user@example.com'
|
||||
name: 'Updated Name'
|
||||
status: 'active'
|
||||
returnData: true
|
||||
dryRun: false
|
||||
responses:
|
||||
'200':
|
||||
description: Row upserted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: boolean
|
||||
description: True when returnData is false
|
||||
- allOf:
|
||||
- $ref: '../schemas/dataTableRow.yml'
|
||||
description: Upserted row when returnData is true
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
get:
|
||||
x-eov-operation-id: getDataTableRows
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Retrieve rows from a data table
|
||||
description: Query and retrieve rows from a data table with optional filtering, sorting, and pagination.
|
||||
operationId: get-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
- $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: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"active"}]}'
|
||||
- name: sortBy
|
||||
in: query
|
||||
description: 'Sort format: columnName:asc or columnName:desc'
|
||||
schema:
|
||||
type: string
|
||||
example: 'createdAt:desc'
|
||||
- name: search
|
||||
in: query
|
||||
description: Search text across all string columns
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/dataTableRowList.yml'
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
|
||||
post:
|
||||
x-eov-operation-id: insertDataTableRows
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Insert rows into a data table
|
||||
description: Insert one or more rows into a data table.
|
||||
operationId: insert-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/insertRowsRequest.yml'
|
||||
example:
|
||||
data:
|
||||
- name: 'John Doe'
|
||||
email: 'john@example.com'
|
||||
age: 30
|
||||
- name: 'Jane Smith'
|
||||
email: 'jane@example.com'
|
||||
age: 25
|
||||
returnType: 'all'
|
||||
responses:
|
||||
'200':
|
||||
description: Rows inserted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Number of rows inserted (when returnType is 'count')
|
||||
- type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Array of inserted row IDs (when returnType is 'id')
|
||||
- type: array
|
||||
items:
|
||||
$ref: '../schemas/dataTableRow.yml'
|
||||
description: Array of inserted rows (when returnType is 'all')
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
get:
|
||||
x-eov-operation-id: getDataTable
|
||||
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-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-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: []
|
||||
@@ -0,0 +1,76 @@
|
||||
get:
|
||||
x-eov-operation-id: listDataTables
|
||||
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.
|
||||
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-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 workspace.
|
||||
operationId: create-data-table
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/createDataTableRequest.yml'
|
||||
example:
|
||||
name: 'customers'
|
||||
columns:
|
||||
- name: 'email'
|
||||
type: 'string'
|
||||
- name: 'status'
|
||||
type: 'string'
|
||||
- name: 'age'
|
||||
type: 'number'
|
||||
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'
|
||||
'409':
|
||||
$ref: '../../../../shared/spec/responses/conflict.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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
|
||||
required:
|
||||
- name
|
||||
- columns
|
||||
@@ -0,0 +1,45 @@
|
||||
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
|
||||
description: Column definitions
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Column ID
|
||||
name:
|
||||
type: string
|
||||
description: Column name
|
||||
type:
|
||||
type: string
|
||||
enum: [string, number, boolean, date]
|
||||
description: Column data type
|
||||
index:
|
||||
type: integer
|
||||
description: Column position
|
||||
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
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- columns
|
||||
- projectId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,15 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
additionalProperties: true
|
||||
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
|
||||
@@ -0,0 +1,11 @@
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: './dataTableRow.yml'
|
||||
nextCursor:
|
||||
type: string
|
||||
description: Paginate through rows 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
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Array of rows to insert. Each row is an object with column names as keys.
|
||||
minItems: 1
|
||||
returnType:
|
||||
type: string
|
||||
enum: [count, id, all]
|
||||
default: count
|
||||
description: |
|
||||
- count: Return only the number of rows inserted
|
||||
- id: Return an array of inserted row IDs
|
||||
- all: Return the full row data for all inserted rows
|
||||
required:
|
||||
- data
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
name: dataTableId
|
||||
in: path
|
||||
description: The ID of the data table
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: nanoid
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: New name for the data table
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
required:
|
||||
- name
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
type: object
|
||||
properties:
|
||||
filter:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
default: and
|
||||
filters:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
columnName:
|
||||
type: string
|
||||
condition:
|
||||
type: string
|
||||
enum: [eq, neq, like, ilike, gt, gte, lt, lte]
|
||||
value: {}
|
||||
required:
|
||||
- columnName
|
||||
- condition
|
||||
- value
|
||||
required:
|
||||
- filters
|
||||
description: Filter conditions to match rows for update
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Column values to update
|
||||
returnData:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, return the updated rows; if false, return true on success
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, preview changes without persisting them
|
||||
required:
|
||||
- filter
|
||||
- data
|
||||
@@ -0,0 +1,43 @@
|
||||
type: object
|
||||
properties:
|
||||
filter:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
default: and
|
||||
filters:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
columnName:
|
||||
type: string
|
||||
condition:
|
||||
type: string
|
||||
enum: [eq, neq, like, ilike, gt, gte, lt, lte]
|
||||
value: {}
|
||||
required:
|
||||
- columnName
|
||||
- condition
|
||||
- value
|
||||
required:
|
||||
- filters
|
||||
description: Filter conditions to match existing row. If no row matches, a new row is inserted.
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Column values for the row
|
||||
returnData:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, return the upserted row; if false, return true on success
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, preview changes without persisting them
|
||||
required:
|
||||
- filter
|
||||
- data
|
||||
@@ -32,6 +32,8 @@ tags:
|
||||
description: Operations about source control
|
||||
- name: Variables
|
||||
description: Operations about variables
|
||||
- name: DataTable
|
||||
description: Operations about data tables and their rows
|
||||
- name: Projects
|
||||
description: Operations about projects
|
||||
|
||||
@@ -82,6 +84,18 @@ paths:
|
||||
$ref: './handlers/variables/spec/paths/variables.yml'
|
||||
/variables/{id}:
|
||||
$ref: './handlers/variables/spec/paths/variables.id.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:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml'
|
||||
/data-tables/{dataTableId}/rows/upsert:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.upsert.yml'
|
||||
/data-tables/{dataTableId}/rows/delete:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml'
|
||||
/projects:
|
||||
$ref: './handlers/projects/spec/paths/projects.yml'
|
||||
/projects/{projectId}:
|
||||
|
||||
@@ -17,7 +17,7 @@ import { decodeCursor } from '../services/pagination.service';
|
||||
|
||||
const UNLIMITED_USERS_QUOTA = -1;
|
||||
|
||||
export type ProjectScopeResource = 'workflow' | 'credential';
|
||||
export type ProjectScopeResource = 'workflow' | 'credential' | 'dataTable';
|
||||
|
||||
const buildScopeMiddleware = (
|
||||
scopes: Scope[],
|
||||
@@ -25,17 +25,19 @@ const buildScopeMiddleware = (
|
||||
{ globalOnly } = { globalOnly: false },
|
||||
) => {
|
||||
return async (
|
||||
req: AuthenticatedRequest<{ id?: string }>,
|
||||
req: AuthenticatedRequest<{ id?: string; dataTableId?: string }>,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): Promise<express.Response | void> => {
|
||||
const params: { credentialId?: string; workflowId?: string } = {};
|
||||
const params: { credentialId?: string; workflowId?: string; dataTableId?: string } = {};
|
||||
if (req.params.id) {
|
||||
if (resource === 'workflow') {
|
||||
params.workflowId = req.params.id;
|
||||
} else if (resource === 'credential') {
|
||||
params.credentialId = req.params.id;
|
||||
}
|
||||
} else if (req.params.dataTableId && resource === 'dataTable') {
|
||||
params.dataTableId = req.params.dataTableId;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,3 +16,5 @@ IncludeRole:
|
||||
$ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml'
|
||||
VariableId:
|
||||
$ref: '../../../handlers/variables/spec/schemas/parameters/variableId.yml'
|
||||
dataTableId:
|
||||
$ref: '../../../handlers/data-tables/spec/schemas/parameters/dataTableId.yml'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import type {
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
ListDataTableOptions,
|
||||
ListDataTableOptionsSortByKey,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ROWS_LIMIT_DEFAULT } from '../../common/constants';
|
||||
@@ -67,9 +66,8 @@ export const description: INodeProperties[] = [
|
||||
options: [
|
||||
{ name: 'Created', value: 'createdAt' },
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Size', value: 'sizeBytes' },
|
||||
{ name: 'Updated', value: 'updatedAt' },
|
||||
] satisfies Array<{ name: string; value: ListDataTableOptionsSortByKey }>,
|
||||
],
|
||||
description: 'Field to sort by',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ export type CreateDataTableOptions = Pick<DataTable, 'name'> & {
|
||||
|
||||
export type UpdateDataTableOptions = { name: string };
|
||||
|
||||
export type ListDataTableOptionsSortByKey = 'name' | 'createdAt' | 'updatedAt' | 'sizeBytes';
|
||||
export type ListDataTableOptionsSortByKey = 'name' | 'createdAt' | 'updatedAt';
|
||||
|
||||
export type ListDataTableOptions = {
|
||||
filter?: Record<string, string | string[]>;
|
||||
|
||||
Reference in New Issue
Block a user