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:
Ricardo Espinoza
2026-01-26 15:18:39 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.5
parent 7129c88fd9
commit 6b474e4141
36 changed files with 3483 additions and 25 deletions
@@ -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,
}) {}
+8 -2
View File
@@ -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),
),
+1 -1
View File
@@ -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`.",
);
}
+21 -1
View File
@@ -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: {
+65
View File
@@ -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
// ----------------------------------
@@ -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);
}
},
],
};
@@ -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: []
@@ -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: []
@@ -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: []
@@ -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: []
@@ -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: []
@@ -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
@@ -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
@@ -0,0 +1,7 @@
name: dataTableId
in: path
description: The ID of the data table
required: true
schema:
type: string
format: nanoid
@@ -0,0 +1,9 @@
type: object
properties:
name:
type: string
description: New name for the data table
minLength: 1
maxLength: 128
required:
- name
@@ -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',
},
{
+1 -1
View File
@@ -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[]>;