refactor: Consolidate WorkflowService.getMany() (no-changelog) (#6892)
In scope: - Consolidate `WorkflowService.getMany()`. - Support non-entity field `ownedBy` for `select`. - Support `tags` for `filter`. - Move `addOwnerId` to `OwnershipService`. - Remove unneeded check for `filter.id`. - Simplify DTO validation for `filter` and `select`. - Expand tests for `GET /workflows`. Workflow list query DTOs: ``` filter → name, active, tags select → id, name, active, tags, createdAt, updatedAt, versionId, ownedBy ``` Out of scope: - Migrate `shared_workflow.roleId` and `shared_credential.roleId` to string IDs. - Refactor `WorkflowHelpers.getSharedWorkflowIds()`.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { isIntegerString } from '@/utils';
|
||||
|
||||
export class Pagination {
|
||||
static fromString(rawTake: string, rawSkip: string) {
|
||||
if (!isIntegerString(rawTake)) {
|
||||
throw new Error('Parameter take is not an integer string');
|
||||
}
|
||||
|
||||
if (!isIntegerString(rawSkip)) {
|
||||
throw new Error('Parameter skip is not an integer string');
|
||||
}
|
||||
|
||||
const [take, skip] = [rawTake, rawSkip].map((o) => parseInt(o, 10));
|
||||
|
||||
const MAX_ITEMS_PER_PAGE = 50;
|
||||
|
||||
return {
|
||||
take: Math.min(take, MAX_ITEMS_PER_PAGE),
|
||||
skip,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IsOptional, IsString, IsBoolean, IsArray, validate } from 'class-validator';
|
||||
import { Expose, instanceToPlain, plainToInstance } from 'class-transformer';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { isObjectLiteral } from '@/utils';
|
||||
|
||||
export class WorkflowFilter {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
active?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
tags?: string[];
|
||||
|
||||
static async fromString(rawFilter: string) {
|
||||
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' });
|
||||
|
||||
if (!isObjectLiteral(dto)) throw new Error('Filter must be an object literal');
|
||||
|
||||
const instance = plainToInstance(WorkflowFilter, dto, {
|
||||
excludeExtraneousValues: true, // remove fields not in class
|
||||
exposeUnsetFields: false, // remove in-class undefined fields
|
||||
});
|
||||
|
||||
await instance.validate();
|
||||
|
||||
return instanceToPlain(instance);
|
||||
}
|
||||
|
||||
private async validate() {
|
||||
const result = await validate(this);
|
||||
|
||||
if (result.length > 0) throw new Error('Parsed filter does not fit the schema');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isStringArray } from '@/utils';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
export class WorkflowSelect {
|
||||
fields: string[];
|
||||
|
||||
static get selectableFields() {
|
||||
return new Set([
|
||||
'id', // always included downstream
|
||||
'name',
|
||||
'active',
|
||||
'tags',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'versionId',
|
||||
'ownedBy', // non-entity field
|
||||
]);
|
||||
}
|
||||
|
||||
static fromString(rawFilter: string) {
|
||||
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' });
|
||||
|
||||
if (!isStringArray(dto)) throw new Error('Parsed select is not a string array');
|
||||
|
||||
return dto.reduce<Record<string, true>>((acc, field) => {
|
||||
if (!WorkflowSelect.selectableFields.has(field)) return acc;
|
||||
return (acc[field] = true), acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { BadRequestError } from '@/ResponseHelper';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import * as utils from '@/utils';
|
||||
|
||||
export function handleListQueryError(
|
||||
paramName: 'filter' | 'select',
|
||||
paramValue: string,
|
||||
maybeError: unknown,
|
||||
) {
|
||||
const error = utils.toError(maybeError);
|
||||
|
||||
LoggerProxy.error(`Invalid "${paramName}" query parameter`, {
|
||||
paramName,
|
||||
paramValue,
|
||||
error,
|
||||
});
|
||||
|
||||
throw new BadRequestError(
|
||||
`Invalid "${paramName}" query parameter: ${paramValue}. Error: ${error.message}`,
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,38 @@
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { handleListQueryError } from './error';
|
||||
import { WorkflowSchema } from './workflow.schema';
|
||||
import type { ListQueryRequest } from '@/requests';
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Schema } from './schema';
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
function toQueryFilter(rawFilter: string, schema: typeof Schema) {
|
||||
const parsedFilter = new schema(
|
||||
jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }),
|
||||
);
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
||||
import { WorkflowFilter } from './dtos/workflow.filter.dto';
|
||||
import { toError } from '@/utils';
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsedFilter)
|
||||
.filter(([_, value]) => value !== undefined)
|
||||
.map(([key, _]: [keyof Schema, unknown]) => [key, parsedFilter[key]]),
|
||||
);
|
||||
}
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import type { ListQuery } from '@/requests';
|
||||
|
||||
export const filterListQueryMiddleware: RequestHandler = (req: ListQueryRequest, res, next) => {
|
||||
export const filterListQueryMiddleware = async (
|
||||
req: ListQuery.Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const { filter: rawFilter } = req.query;
|
||||
|
||||
if (!rawFilter) return next();
|
||||
|
||||
let schema;
|
||||
let Filter;
|
||||
|
||||
if (req.baseUrl.endsWith('workflows')) {
|
||||
schema = WorkflowSchema;
|
||||
Filter = WorkflowFilter;
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = toQueryFilter(rawFilter, schema);
|
||||
const filter = await Filter.fromString(rawFilter);
|
||||
|
||||
if (Object.keys(filter).length === 0) return next();
|
||||
|
||||
req.listQueryOptions = { ...req.listQueryOptions, filter };
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
handleListQueryError('filter', rawFilter, error);
|
||||
} catch (maybeError) {
|
||||
ResponseHelper.sendErrorResponse(res, toError(maybeError));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import type { ListQueryRequest } from '@/requests';
|
||||
import { isIntegerString } from '@/utils';
|
||||
import { toError } from '@/utils';
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
||||
import { Pagination } from './dtos/pagination.dto';
|
||||
import type { ListQuery } from '@/requests';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
function toPaginationOptions(rawTake: string, rawSkip: string) {
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
if ([rawTake, rawSkip].some((i) => !isIntegerString(i))) {
|
||||
throw new Error('Parameter take or skip is not an integer string');
|
||||
}
|
||||
|
||||
const [take, skip] = [rawTake, rawSkip].map((o) => parseInt(o, 10));
|
||||
|
||||
return { skip, take: Math.min(take, MAX_ITEMS) };
|
||||
}
|
||||
|
||||
export const paginationListQueryMiddleware: RequestHandler = (req: ListQueryRequest, res, next) => {
|
||||
export const paginationListQueryMiddleware: RequestHandler = (
|
||||
req: ListQuery.Request,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
const { take: rawTake, skip: rawSkip = '0' } = req.query;
|
||||
|
||||
if (!rawTake) return next();
|
||||
|
||||
const { take, skip } = toPaginationOptions(rawTake, rawSkip);
|
||||
try {
|
||||
const { take, skip } = Pagination.fromString(rawTake, rawSkip);
|
||||
|
||||
req.listQueryOptions = { ...req.listQueryOptions, take, skip };
|
||||
req.listQueryOptions = { ...req.listQueryOptions, skip, take };
|
||||
|
||||
next();
|
||||
next();
|
||||
} catch (maybeError) {
|
||||
ResponseHelper.sendErrorResponse(res, toError(maybeError));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export class Schema {
|
||||
constructor(private data: unknown = {}) {}
|
||||
|
||||
static get fieldNames(): string[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,34 @@
|
||||
import { handleListQueryError } from './error';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { WorkflowSchema } from './workflow.schema';
|
||||
import * as utils from '@/utils';
|
||||
import type { ListQueryRequest } from '@/requests';
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
import { WorkflowSelect } from './dtos/workflow.select.dto';
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
||||
import { toError } from '@/utils';
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Schema } from '@/middlewares/listQuery/schema';
|
||||
import type { ListQuery } from '@/requests';
|
||||
|
||||
function toQuerySelect(rawSelect: string, schema: typeof Schema) {
|
||||
const asArr = jsonParse(rawSelect, { errorMessage: 'Failed to parse select JSON' });
|
||||
|
||||
if (!utils.isStringArray(asArr)) {
|
||||
throw new Error('Parsed select is not a string array');
|
||||
}
|
||||
|
||||
return asArr.reduce<Record<string, true>>((acc, field) => {
|
||||
if (!schema.fieldNames.includes(field)) return acc;
|
||||
return (acc[field] = true), acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export const selectListQueryMiddleware: RequestHandler = (req: ListQueryRequest, res, next) => {
|
||||
export const selectListQueryMiddleware: RequestHandler = (req: ListQuery.Request, res, next) => {
|
||||
const { select: rawSelect } = req.query;
|
||||
|
||||
if (!rawSelect) return next();
|
||||
|
||||
let schema;
|
||||
let Select;
|
||||
|
||||
if (req.baseUrl.endsWith('workflows')) {
|
||||
schema = WorkflowSchema;
|
||||
Select = WorkflowSelect;
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const select = toQuerySelect(rawSelect, schema);
|
||||
const select = Select.fromString(rawSelect);
|
||||
|
||||
if (Object.keys(select).length === 0) return next();
|
||||
|
||||
req.listQueryOptions = { ...req.listQueryOptions, select };
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
handleListQueryError('select', rawSelect, error);
|
||||
} catch (maybeError) {
|
||||
ResponseHelper.sendErrorResponse(res, toError(maybeError));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Schema } from '@/middlewares/listQuery/schema';
|
||||
import { validateSync, IsOptional, IsString, IsBoolean, IsDateString } from 'class-validator';
|
||||
|
||||
export class WorkflowSchema extends Schema {
|
||||
constructor(data: unknown = {}) {
|
||||
super();
|
||||
Object.assign(this, data);
|
||||
|
||||
// strip out unknown fields
|
||||
const result = validateSync(this, { whitelist: true });
|
||||
|
||||
if (result.length > 0) {
|
||||
throw new Error('Parsed filter does not fit the schema');
|
||||
}
|
||||
}
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string = undefined;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string = undefined;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean = undefined;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdAt?: Date = undefined;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
updatedAt?: Date = undefined;
|
||||
|
||||
static get fieldNames() {
|
||||
return Object.getOwnPropertyNames(new WorkflowSchema());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user