refactor(core): Update dynamic node parameter endpoints to use DTOs (#12379)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-30 15:48:44 +01:00 committed by GitHub
parent f08db47077
commit 1674dd0f88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 875 additions and 171 deletions

View file

@ -0,0 +1,81 @@
import { ActionResultRequestDto } from '../action-result-request.dto';
describe('ActionResultRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
handler: 'testHandler',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with payload',
request: {
...baseValidRequest,
payload: { key: 'value' },
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = ActionResultRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
handler: 'testHandler',
},
expectedErrorPath: ['path'],
},
{
name: 'missing handler',
request: {
path: '/test/path',
currentNodeParameters: {},
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
},
expectedErrorPath: ['handler'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ActionResultRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,90 @@
import { OptionsRequestDto } from '../options-request.dto';
describe('OptionsRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with method name',
request: {
...baseValidRequest,
methodName: 'testMethod',
},
},
{
name: 'request with load options',
request: {
...baseValidRequest,
loadOptions: {
routing: {
operations: { someOperation: 'test' },
output: { someOutput: 'test' },
request: { someRequest: 'test' },
},
},
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = OptionsRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
},
expectedErrorPath: ['path'],
},
{
name: 'missing node type and version',
request: {
path: '/test/path',
},
expectedErrorPath: ['nodeTypeAndVersion'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = OptionsRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,95 @@
import { ResourceLocatorRequestDto } from '../resource-locator-request.dto';
describe('ResourceLocatorRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with filter',
request: {
...baseValidRequest,
filter: 'testFilter',
},
},
{
name: 'request with pagination token',
request: {
...baseValidRequest,
paginationToken: 'token123',
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
{
name: 'request with a semver node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 1.1 },
},
},
])('should validate $name', ({ request }) => {
const result = ResourceLocatorRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
},
expectedErrorPath: ['path'],
},
{
name: 'missing method name',
request: {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
},
expectedErrorPath: ['methodName'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResourceLocatorRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,74 @@
import { ResourceMapperFieldsRequestDto } from '../resource-mapper-fields-request.dto';
describe('ResourceMapperFieldsRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = ResourceMapperFieldsRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
},
expectedErrorPath: ['path'],
},
{
name: 'missing method name',
request: {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
},
expectedErrorPath: ['methodName'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResourceMapperFieldsRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,11 @@
import type { IDataObject } from 'n8n-workflow';
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ActionResultRequestDto extends BaseDynamicParametersRequestDto.extend({
handler: z.string(),
payload: z
.union([z.object({}).catchall(z.any()) satisfies z.ZodType<IDataObject>, z.string()])
.optional(),
}) {}

View file

@ -0,0 +1,18 @@
import type { INodeCredentials, INodeParameters, INodeTypeNameVersion } from 'n8n-workflow';
import { z } from 'zod';
import { Z } from 'zod-class';
import { nodeVersionSchema } from '../../schemas/nodeVersion.schema';
export class BaseDynamicParametersRequestDto extends Z.class({
path: z.string(),
nodeTypeAndVersion: z.object({
name: z.string(),
version: nodeVersionSchema,
}) satisfies z.ZodType<INodeTypeNameVersion>,
currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType<INodeParameters>,
methodName: z.string().optional(),
credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType<
INodeCredentials | undefined
>,
}) {}

View file

@ -0,0 +1,18 @@
import type { ILoadOptions } from 'n8n-workflow';
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class OptionsRequestDto extends BaseDynamicParametersRequestDto.extend({
loadOptions: z
.object({
routing: z
.object({
operations: z.any().optional(),
output: z.any().optional(),
request: z.any().optional(),
})
.optional(),
})
.optional() as z.ZodType<ILoadOptions | undefined>,
}) {}

View file

@ -0,0 +1,9 @@
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ResourceLocatorRequestDto extends BaseDynamicParametersRequestDto.extend({
methodName: z.string(),
filter: z.string().optional(),
paginationToken: z.string().optional(),
}) {}

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ResourceMapperFieldsRequestDto extends BaseDynamicParametersRequestDto.extend({
methodName: z.string(),
}) {}

View file

@ -6,6 +6,11 @@ export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto';
export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto';
export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto';
export { ActionResultRequestDto } from './dynamic-node-parameters/action-result-request.dto';
export { InviteUsersRequestDto } from './invitation/invite-users-request.dto';
export { AcceptInvitationRequestDto } from './invitation/accept-invitation-request.dto';

View file

@ -0,0 +1,28 @@
import { nodeVersionSchema } from '../nodeVersion.schema';
describe('nodeVersionSchema', () => {
describe('valid versions', () => {
test.each([
[1, 'single digit'],
[2, 'single digit'],
[1.0, 'major.minor with zero minor'],
[1.2, 'major.minor'],
[10.5, 'major.minor with double digits'],
])('should accept %s as a valid version (%s)', (version) => {
const validated = nodeVersionSchema.parse(version);
expect(validated).toBe(version);
});
});
describe('invalid versions', () => {
test.each([
['not-a-number', 'non-number input'],
['1.2.3', 'more than two parts'],
['1.a', 'non-numeric characters'],
['1.2.3', 'more than two parts as string'],
])('should reject %s as an invalid version (%s)', (version) => {
const check = () => nodeVersionSchema.parse(version);
expect(check).toThrowError();
});
});
});

View file

@ -0,0 +1,17 @@
import { z } from 'zod';
export const nodeVersionSchema = z
.number()
.min(1)
.refine(
(val) => {
const parts = String(val).split('.');
return (
(parts.length === 1 && !isNaN(Number(parts[0]))) ||
(parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])))
);
},
{
message: 'Invalid node version. Must be in format: major.minor',
},
);

View file

@ -1,34 +1,223 @@
import type {
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
ActionResultRequestDto,
} from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import type { ILoadOptions, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import type {
ILoadOptions,
IWorkflowExecuteAdditionalData,
INodePropertyOptions,
NodeParameterValueType,
} from 'n8n-workflow';
import { DynamicNodeParametersController } from '@/controllers/dynamic-node-parameters.controller';
import type { DynamicNodeParametersRequest } from '@/requests';
import type { AuthenticatedRequest } from '@/requests';
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import * as AdditionalData from '@/workflow-execute-additional-data';
describe('DynamicNodeParametersController', () => {
const service = mock<DynamicNodeParametersService>();
const controller = new DynamicNodeParametersController(service);
let service: jest.Mocked<DynamicNodeParametersService>;
let controller: DynamicNodeParametersController;
let mockUser: { id: string };
let baseAdditionalData: IWorkflowExecuteAdditionalData;
beforeEach(() => {
jest.clearAllMocks();
service = mock<DynamicNodeParametersService>();
controller = new DynamicNodeParametersController(service);
mockUser = { id: 'user123' };
baseAdditionalData = mock<IWorkflowExecuteAdditionalData>();
jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(baseAdditionalData);
});
describe('getOptions', () => {
it('should take `loadOptions` as object', async () => {
jest
.spyOn(AdditionalData, 'getBase')
.mockResolvedValue(mock<IWorkflowExecuteAdditionalData>());
const basePayload: OptionsRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
};
const req = mock<DynamicNodeParametersRequest.Options>();
const loadOptions: ILoadOptions = {};
req.body.loadOptions = loadOptions;
it('should call getOptionsViaMethodName when methodName is provided', async () => {
const payload: OptionsRequestDto = {
...basePayload,
methodName: 'testMethod',
};
const req = { user: mockUser } as AuthenticatedRequest;
await controller.getOptions(req);
const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }];
service.getOptionsViaMethodName.mockResolvedValue(expectedResult);
const zerothArg = service.getOptionsViaLoadOptions.mock.calls[0][0];
const result = await controller.getOptions(req, mock(), payload);
expect(zerothArg).toEqual(loadOptions);
expect(service.getOptionsViaMethodName).toHaveBeenCalledWith(
'testMethod',
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},
undefined,
);
expect(result).toEqual(expectedResult);
});
it('should call getOptionsViaLoadOptions when loadOptions is provided', async () => {
const loadOptions: ILoadOptions = {
routing: {
operations: {},
},
};
const payload: OptionsRequestDto = {
...basePayload,
loadOptions,
};
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }];
service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult);
const result = await controller.getOptions(req, mock(), payload);
expect(service.getOptionsViaLoadOptions).toHaveBeenCalledWith(
loadOptions,
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},
undefined,
);
expect(result).toEqual(expectedResult);
});
it('should return empty array when no method or load options are provided', async () => {
const req = { user: mockUser } as AuthenticatedRequest;
const result = await controller.getOptions(req, mock(), basePayload);
expect(result).toEqual([]);
});
});
describe('getResourceLocatorResults', () => {
const basePayload: ResourceLocatorRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
it('should call getResourceLocatorResults with correct parameters', async () => {
const payload: ResourceLocatorRequestDto = {
...basePayload,
filter: 'testFilter',
paginationToken: 'testToken',
};
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult = { results: [{ name: 'test', value: 'value' }] };
service.getResourceLocatorResults.mockResolvedValue(expectedResult);
const result = await controller.getResourceLocatorResults(req, mock(), payload);
expect(service.getResourceLocatorResults).toHaveBeenCalledWith(
'testMethod',
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},
undefined,
'testFilter',
'testToken',
);
expect(result).toEqual(expectedResult);
});
});
describe('getResourceMappingFields', () => {
const basePayload: ResourceMapperFieldsRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
it('should call getResourceMappingFields with correct parameters', async () => {
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult = { fields: [] };
service.getResourceMappingFields.mockResolvedValue(expectedResult);
const result = await controller.getResourceMappingFields(req, mock(), basePayload);
expect(service.getResourceMappingFields).toHaveBeenCalledWith(
'testMethod',
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},
undefined,
);
expect(result).toEqual(expectedResult);
});
});
describe('getLocalResourceMappingFields', () => {
const basePayload: ResourceMapperFieldsRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
it('should call getLocalResourceMappingFields with correct parameters', async () => {
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult = { fields: [] };
service.getLocalResourceMappingFields.mockResolvedValue(expectedResult);
const result = await controller.getLocalResourceMappingFields(req, mock(), basePayload);
expect(service.getLocalResourceMappingFields).toHaveBeenCalledWith(
'testMethod',
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
);
expect(result).toEqual(expectedResult);
});
});
describe('getActionResult', () => {
const basePayload: ActionResultRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
handler: 'testHandler',
currentNodeParameters: {},
};
it('should call getActionResult with correct parameters', async () => {
const payload: ActionResultRequestDto = {
...basePayload,
payload: { test: 'value' },
};
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult: NodeParameterValueType = 'test result';
service.getActionResult.mockResolvedValue(expectedResult);
const result = await controller.getActionResult(req, mock(), payload);
expect(service.getActionResult).toHaveBeenCalledWith(
'testHandler',
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},
{ test: 'value' },
undefined,
);
expect(result).toEqual(expectedResult);
});
});
});

View file

@ -1,8 +1,13 @@
import {
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
ActionResultRequestDto,
} from '@n8n/api-types';
import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow';
import { Post, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { DynamicNodeParametersRequest } from '@/requests';
import { Post, RestController, Body } from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { getBase } from '@/workflow-execute-additional-data';
@ -11,7 +16,11 @@ export class DynamicNodeParametersController {
constructor(private readonly service: DynamicNodeParametersService) {}
@Post('/options')
async getOptions(req: DynamicNodeParametersRequest.Options): Promise<INodePropertyOptions[]> {
async getOptions(
req: AuthenticatedRequest,
_res: Response,
@Body payload: OptionsRequestDto,
): Promise<INodePropertyOptions[]> {
const {
credentials,
currentNodeParameters,
@ -19,7 +28,7 @@ export class DynamicNodeParametersController {
path,
methodName,
loadOptions,
} = req.body;
} = payload;
const additionalData = await getBase(req.user.id, currentNodeParameters);
@ -48,7 +57,11 @@ export class DynamicNodeParametersController {
}
@Post('/resource-locator-results')
async getResourceLocatorResults(req: DynamicNodeParametersRequest.ResourceLocatorResults) {
async getResourceLocatorResults(
req: AuthenticatedRequest,
_res: Response,
@Body payload: ResourceLocatorRequestDto,
) {
const {
path,
methodName,
@ -57,9 +70,7 @@ export class DynamicNodeParametersController {
credentials,
currentNodeParameters,
nodeTypeAndVersion,
} = req.body;
if (!methodName) throw new BadRequestError('Missing `methodName` in request body');
} = payload;
const additionalData = await getBase(req.user.id, currentNodeParameters);
@ -76,10 +87,12 @@ export class DynamicNodeParametersController {
}
@Post('/resource-mapper-fields')
async getResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) {
const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = req.body;
if (!methodName) throw new BadRequestError('Missing `methodName` in request body');
async getResourceMappingFields(
req: AuthenticatedRequest,
_res: Response,
@Body payload: ResourceMapperFieldsRequestDto,
) {
const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = payload;
const additionalData = await getBase(req.user.id, currentNodeParameters);
@ -94,10 +107,12 @@ export class DynamicNodeParametersController {
}
@Post('/local-resource-mapper-fields')
async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) {
const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body;
if (!methodName) throw new BadRequestError('Missing `methodName` in request body');
async getLocalResourceMappingFields(
req: AuthenticatedRequest,
_res: Response,
@Body payload: ResourceMapperFieldsRequestDto,
) {
const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = payload;
const additionalData = await getBase(req.user.id, currentNodeParameters);
@ -111,25 +126,29 @@ export class DynamicNodeParametersController {
@Post('/action-result')
async getActionResult(
req: DynamicNodeParametersRequest.ActionResult,
req: AuthenticatedRequest,
_res: Response,
@Body payload: ActionResultRequestDto,
): Promise<NodeParameterValueType> {
const { currentNodeParameters, nodeTypeAndVersion, path, credentials, handler, payload } =
req.body;
const {
currentNodeParameters,
nodeTypeAndVersion,
path,
credentials,
handler,
payload: actionPayload,
} = payload;
const additionalData = await getBase(req.user.id, currentNodeParameters);
if (handler) {
return await this.service.getActionResult(
handler,
path,
additionalData,
nodeTypeAndVersion,
currentNodeParameters,
payload,
credentials,
);
}
return;
return await this.service.getActionResult(
handler,
path,
additionalData,
nodeTypeAndVersion,
currentNodeParameters,
actionPayload,
credentials,
);
}
}

View file

@ -3,11 +3,7 @@ import type express from 'express';
import type {
ICredentialDataDecryptedObject,
IDataObject,
ILoadOptions,
INodeCredentialTestRequest,
INodeCredentials,
INodeParameters,
INodeTypeNameVersion,
IPersonalizationSurveyAnswersV4,
IUser,
} from 'n8n-workflow';
@ -268,47 +264,6 @@ export declare namespace OAuthRequest {
}
}
// ----------------------------------
// /dynamic-node-parameters
// ----------------------------------
export declare namespace DynamicNodeParametersRequest {
type BaseRequest<RequestBody = {}> = AuthenticatedRequest<
{},
{},
{
path: string;
nodeTypeAndVersion: INodeTypeNameVersion;
currentNodeParameters: INodeParameters;
methodName?: string;
credentials?: INodeCredentials;
} & RequestBody,
{}
>;
/** POST /dynamic-node-parameters/options */
type Options = BaseRequest<{
loadOptions?: ILoadOptions;
}>;
/** POST /dynamic-node-parameters/resource-locator-results */
type ResourceLocatorResults = BaseRequest<{
methodName: string;
filter?: string;
paginationToken?: string;
}>;
/** POST dynamic-node-parameters/resource-mapper-fields */
type ResourceMapperFields = BaseRequest<{
methodName: string;
}>;
/** POST /dynamic-node-parameters/action-result */
type ActionResult = BaseRequest<{
handler: string;
payload: IDataObject | string | undefined;
}>;
}
// ----------------------------------
// /tags
// ----------------------------------

View file

@ -3,16 +3,21 @@ import type {
INodeListSearchResult,
IWorkflowExecuteAdditionalData,
ResourceMapperFields,
NodeParameterValueType,
} from 'n8n-workflow';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import * as AdditionalData from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking';
import { createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
describe('DynamicNodeParametersController', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const service = mockInstance(DynamicNodeParametersService);
const testServer = setupTestServer({ endpointGroups: ['dynamic-node-parameters'] });
let ownerAgent: SuperAgentTest;
@ -21,62 +26,171 @@ describe('DynamicNodeParametersController', () => {
ownerAgent = testServer.authAgentFor(owner);
});
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(additionalData);
});
const commonRequestParams = {
credentials: {},
currentNodeParameters: {},
nodeTypeAndVersion: {},
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
path: 'path',
methodName: 'methodName',
};
describe('POST /dynamic-node-parameters/options', () => {
jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(mock<IWorkflowExecuteAdditionalData>());
it('should take params via body', async () => {
jest
.spyOn(DynamicNodeParametersService.prototype, 'getOptionsViaMethodName')
.mockResolvedValue([]);
service.getOptionsViaMethodName.mockResolvedValue([]);
await ownerAgent
.post('/dynamic-node-parameters/options')
.send({
...commonRequestParams,
loadOptions: {},
methodName: 'testMethod',
})
.expect(200);
});
it('should take params with loadOptions', async () => {
const expectedResult = [{ name: 'Test Option', value: 'test' }];
service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult);
const response = await ownerAgent
.post('/dynamic-node-parameters/options')
.send({
...commonRequestParams,
loadOptions: { type: 'test' },
})
.expect(200);
expect(response.body).toEqual({ data: expectedResult });
});
it('should return empty array when no method or loadOptions provided', async () => {
const response = await ownerAgent
.post('/dynamic-node-parameters/options')
.send({
...commonRequestParams,
})
.expect(200);
expect(response.body).toEqual({ data: [] });
});
});
describe('POST /dynamic-node-parameters/resource-locator-results', () => {
it('should take params via body', async () => {
jest
.spyOn(DynamicNodeParametersService.prototype, 'getResourceLocatorResults')
.mockResolvedValue(mock<INodeListSearchResult>());
it('should return resource locator results', async () => {
const expectedResult: INodeListSearchResult = { results: [] };
service.getResourceLocatorResults.mockResolvedValue(expectedResult);
const response = await ownerAgent
.post('/dynamic-node-parameters/resource-locator-results')
.send({
...commonRequestParams,
methodName: 'testMethod',
filter: 'testFilter',
paginationToken: 'testToken',
})
.expect(200);
expect(response.body).toEqual({ data: expectedResult });
});
it('should handle resource locator results without pagination', async () => {
const mockResults = mock<INodeListSearchResult>();
service.getResourceLocatorResults.mockResolvedValue(mockResults);
await ownerAgent
.post('/dynamic-node-parameters/resource-locator-results')
.send({
methodName: 'testMethod',
...commonRequestParams,
filter: 'filter',
paginationToken: 'paginationToken',
})
.expect(200);
});
it('should return a 400 if methodName is not defined', async () => {
await ownerAgent
.post('/dynamic-node-parameters/resource-locator-results')
.send(commonRequestParams)
.expect(400);
});
});
describe('POST /dynamic-node-parameters/resource-mapper-fields', () => {
it('should take params via body', async () => {
jest
.spyOn(DynamicNodeParametersService.prototype, 'getResourceMappingFields')
.mockResolvedValue(mock<ResourceMapperFields>());
it('should return resource mapper fields', async () => {
const expectedResult: ResourceMapperFields = { fields: [] };
service.getResourceMappingFields.mockResolvedValue(expectedResult);
await ownerAgent
const response = await ownerAgent
.post('/dynamic-node-parameters/resource-mapper-fields')
.send({
...commonRequestParams,
loadOptions: 'loadOptions',
methodName: 'testMethod',
loadOptions: 'testLoadOptions',
})
.expect(200);
expect(response.body).toEqual({ data: expectedResult });
});
it('should return a 400 if methodName is not defined', async () => {
await ownerAgent
.post('/dynamic-node-parameters/resource-mapper-fields')
.send(commonRequestParams)
.expect(400);
});
});
describe('POST /dynamic-node-parameters/local-resource-mapper-fields', () => {
it('should return local resource mapper fields', async () => {
const expectedResult: ResourceMapperFields = { fields: [] };
service.getLocalResourceMappingFields.mockResolvedValue(expectedResult);
const response = await ownerAgent
.post('/dynamic-node-parameters/local-resource-mapper-fields')
.send({
...commonRequestParams,
methodName: 'testMethod',
})
.expect(200);
expect(response.body).toEqual({ data: expectedResult });
});
it('should return a 400 if methodName is not defined', async () => {
await ownerAgent
.post('/dynamic-node-parameters/local-resource-mapper-fields')
.send(commonRequestParams)
.expect(400);
});
});
describe('POST /dynamic-node-parameters/action-result', () => {
it('should return action result with handler', async () => {
const expectedResult: NodeParameterValueType = { test: true };
service.getActionResult.mockResolvedValue(expectedResult);
const response = await ownerAgent
.post('/dynamic-node-parameters/action-result')
.send({
...commonRequestParams,
handler: 'testHandler',
payload: { someData: 'test' },
})
.expect(200);
expect(response.body).toEqual({ data: expectedResult });
});
it('should return a 400 if handler is not defined', async () => {
await ownerAgent
.post('/dynamic-node-parameters/action-result')
.send({
...commonRequestParams,
payload: { someData: 'test' },
})
.expect(400);
});
});
});

View file

@ -27,9 +27,6 @@ import type {
IWorkflowSettings as IWorkflowSettingsWorkflow,
WorkflowExecuteMode,
PublicInstalledPackage,
INodeTypeNameVersion,
ILoadOptions,
INodeCredentials,
INodeListSearchItems,
NodeParameterValueType,
IDisplayOptions,
@ -1266,35 +1263,6 @@ export type NodeAuthenticationOption = {
displayOptions?: IDisplayOptions;
};
export declare namespace DynamicNodeParameters {
interface BaseRequest {
path: string;
nodeTypeAndVersion: INodeTypeNameVersion;
currentNodeParameters: INodeParameters;
methodName?: string;
credentials?: INodeCredentials;
}
interface OptionsRequest extends BaseRequest {
loadOptions?: ILoadOptions;
}
interface ResourceLocatorResultsRequest extends BaseRequest {
methodName: string;
filter?: string;
paginationToken?: string;
}
interface ResourceMapperFieldsRequest extends BaseRequest {
methodName: string;
}
interface ActionResultRequest extends BaseRequest {
handler: string;
payload: IDataObject | string | undefined;
}
}
export interface EnvironmentVariable {
id: string;
key: string;

View file

@ -1,5 +1,11 @@
import type {
ActionResultRequestDto,
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
} from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { DynamicNodeParameters, INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import type {
INodeListSearchResult,
INodePropertyOptions,
@ -30,14 +36,14 @@ export async function getNodesInformation(
export async function getNodeParameterOptions(
context: IRestApiContext,
sendData: DynamicNodeParameters.OptionsRequest,
sendData: OptionsRequestDto,
): Promise<INodePropertyOptions[]> {
return await makeRestApiRequest(context, 'POST', '/dynamic-node-parameters/options', sendData);
}
export async function getResourceLocatorResults(
context: IRestApiContext,
sendData: DynamicNodeParameters.ResourceLocatorResultsRequest,
sendData: ResourceLocatorRequestDto,
): Promise<INodeListSearchResult> {
return await makeRestApiRequest(
context,
@ -49,7 +55,7 @@ export async function getResourceLocatorResults(
export async function getResourceMapperFields(
context: IRestApiContext,
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
@ -61,7 +67,7 @@ export async function getResourceMapperFields(
export async function getLocalResourceMapperFields(
context: IRestApiContext,
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
@ -73,7 +79,7 @@ export async function getLocalResourceMapperFields(
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: DynamicNodeParameters.ActionResultRequest,
sendData: ActionResultRequestDto,
): Promise<NodeParameterValueType> {
return await makeRestApiRequest(
context,

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { DynamicNodeParameters, IResourceLocatorResultExpanded } from '@/Interface';
import type { ResourceLocatorRequestDto } from '@n8n/api-types';
import type { IResourceLocatorResultExpanded } from '@/Interface';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
@ -563,7 +564,7 @@ async function loadResources() {
) as INodeParameters;
const loadOptionsMethod = getPropertyArgument(currentMode.value, 'searchListMethod') as string;
const requestParams: DynamicNodeParameters.ResourceLocatorResultsRequest = {
const requestParams: ResourceLocatorRequestDto = {
nodeTypeAndVersion: {
name: props.node.type,
version: props.node.typeVersion,

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IUpdateInformation, DynamicNodeParameters } from '@/Interface';
import type { ResourceMapperFieldsRequestDto } from '@n8n/api-types';
import type { IUpdateInformation } from '@/Interface';
import { resolveRequiredParameters } from '@/composables/useWorkflowHelpers';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type {
@ -294,7 +295,7 @@ const createRequestParams = (methodName: string) => {
if (!props.node) {
return;
}
const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = {
const requestParams: ResourceMapperFieldsRequestDto = {
nodeTypeAndVersion: {
name: props.node.type,
version: props.node.typeVersion,
@ -320,12 +321,12 @@ async function fetchFields(): Promise<ResourceMapperFields | null> {
if (typeof resourceMapperMethod === 'string') {
const requestParams = createRequestParams(
resourceMapperMethod,
) as DynamicNodeParameters.ResourceMapperFieldsRequest;
) as ResourceMapperFieldsRequestDto;
fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams);
} else if (typeof localResourceMapperMethod === 'string') {
const requestParams = createRequestParams(
localResourceMapperMethod,
) as DynamicNodeParameters.ResourceMapperFieldsRequest;
) as ResourceMapperFieldsRequestDto;
fetchedFields = await nodeTypesStore.getLocalResourceMapperFields(requestParams);
}

View file

@ -1,6 +1,12 @@
import type {
ActionResultRequestDto,
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
} from '@n8n/api-types';
import * as nodeTypesApi from '@/api/nodeTypes';
import { HTTP_REQUEST_NODE_TYPE, STORES, CREDENTIAL_ONLY_HTTP_NODE_VERSION } from '@/constants';
import type { DynamicNodeParameters, NodeTypesByTypeNameAndVersion } from '@/Interface';
import type { NodeTypesByTypeNameAndVersion } from '@/Interface';
import { addHeaders, addNodeTranslation } from '@/plugins/i18n';
import { omit } from '@/utils/typesUtils';
import type {
@ -282,19 +288,15 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}
};
const getNodeParameterOptions = async (sendData: DynamicNodeParameters.OptionsRequest) => {
const getNodeParameterOptions = async (sendData: OptionsRequestDto) => {
return await nodeTypesApi.getNodeParameterOptions(rootStore.restApiContext, sendData);
};
const getResourceLocatorResults = async (
sendData: DynamicNodeParameters.ResourceLocatorResultsRequest,
) => {
const getResourceLocatorResults = async (sendData: ResourceLocatorRequestDto) => {
return await nodeTypesApi.getResourceLocatorResults(rootStore.restApiContext, sendData);
};
const getResourceMapperFields = async (
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
) => {
const getResourceMapperFields = async (sendData: ResourceMapperFieldsRequestDto) => {
try {
return await nodeTypesApi.getResourceMapperFields(rootStore.restApiContext, sendData);
} catch (error) {
@ -302,9 +304,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}
};
const getLocalResourceMapperFields = async (
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
) => {
const getLocalResourceMapperFields = async (sendData: ResourceMapperFieldsRequestDto) => {
try {
return await nodeTypesApi.getLocalResourceMapperFields(rootStore.restApiContext, sendData);
} catch (error) {
@ -312,9 +312,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}
};
const getNodeParameterActionResult = async (
sendData: DynamicNodeParameters.ActionResultRequest,
) => {
const getNodeParameterActionResult = async (sendData: ActionResultRequestDto) => {
return await nodeTypesApi.getNodeParameterActionResult(rootStore.restApiContext, sendData);
};