diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts new file mode 100644 index 0000000000000..eb451e5b092cd --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts new file mode 100644 index 0000000000000..28c5534cc758a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts new file mode 100644 index 0000000000000..d64f31dec23da --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts new file mode 100644 index 0000000000000..2370177ab0ae5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts new file mode 100644 index 0000000000000..d6f867af6d3e3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts @@ -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, z.string()]) + .optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts new file mode 100644 index 0000000000000..66b9cd7629595 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts @@ -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, + currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType, + methodName: z.string().optional(), + credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType< + INodeCredentials | undefined + >, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts new file mode 100644 index 0000000000000..b9d34ef75d5f4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts @@ -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, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts new file mode 100644 index 0000000000000..ac8e8df274544 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts new file mode 100644 index 0000000000000..3c6d00eb3c390 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 92e5c56fa0f16..e2642746c7660 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts new file mode 100644 index 0000000000000..098db8209697b --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts new file mode 100644 index 0000000000000..3edb8cc5fe6df --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts @@ -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', + }, + ); diff --git a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts index ff983cdd4ab77..87ed378a020ad 100644 --- a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts @@ -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(); - const controller = new DynamicNodeParametersController(service); + let service: jest.Mocked; + let controller: DynamicNodeParametersController; + let mockUser: { id: string }; + let baseAdditionalData: IWorkflowExecuteAdditionalData; beforeEach(() => { - jest.clearAllMocks(); + service = mock(); + controller = new DynamicNodeParametersController(service); + + mockUser = { id: 'user123' }; + baseAdditionalData = mock(); + + jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(baseAdditionalData); }); describe('getOptions', () => { - it('should take `loadOptions` as object', async () => { - jest - .spyOn(AdditionalData, 'getBase') - .mockResolvedValue(mock()); + const basePayload: OptionsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }; + + it('should call getOptionsViaMethodName when methodName is provided', async () => { + const payload: OptionsRequestDto = { + ...basePayload, + methodName: 'testMethod', + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }]; + service.getOptionsViaMethodName.mockResolvedValue(expectedResult); + + const result = await controller.getOptions(req, mock(), payload); + + 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: {}, + }; - const req = mock(); - const loadOptions: ILoadOptions = {}; - req.body.loadOptions = loadOptions; + it('should call getActionResult with correct parameters', async () => { + const payload: ActionResultRequestDto = { + ...basePayload, + payload: { test: 'value' }, + }; + const req = { user: mockUser } as AuthenticatedRequest; - await controller.getOptions(req); + const expectedResult: NodeParameterValueType = 'test result'; + service.getActionResult.mockResolvedValue(expectedResult); - const zerothArg = service.getOptionsViaLoadOptions.mock.calls[0][0]; + const result = await controller.getActionResult(req, mock(), payload); - expect(zerothArg).toEqual(loadOptions); + expect(service.getActionResult).toHaveBeenCalledWith( + 'testHandler', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + { test: 'value' }, + undefined, + ); + expect(result).toEqual(expectedResult); }); }); }); diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index 2858fd99cae9d..987040d010671 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -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 { + async getOptions( + req: AuthenticatedRequest, + _res: Response, + @Body payload: OptionsRequestDto, + ): Promise { 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 { - 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, + ); } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index fdde592f28aa4..16fe6a08561aa 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 = 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 // ---------------------------------- diff --git a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts index 8f7436fc7598a..82e97a11f6b26 100644 --- a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts @@ -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(); + 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()); - 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()); + 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(); + 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()); + it('should return resource mapper fields', async () => { + const expectedResult: ResourceMapperFields = { fields: [] }; + service.getResourceMappingFields.mockResolvedValue(expectedResult); + const response = await ownerAgent + .post('/dynamic-node-parameters/resource-mapper-fields') + .send({ + ...commonRequestParams, + 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, - loadOptions: 'loadOptions', + 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); }); }); }); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 1f799a3fa168f..46b28d034c4dd 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -27,9 +27,6 @@ import type { IWorkflowSettings as IWorkflowSettingsWorkflow, WorkflowExecuteMode, PublicInstalledPackage, - INodeTypeNameVersion, - ILoadOptions, - INodeCredentials, INodeListSearchItems, NodeParameterValueType, IDisplayOptions, @@ -1265,35 +1262,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; diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index ec3e2bdba58fc..01537a871792e 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -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 { return await makeRestApiRequest(context, 'POST', '/dynamic-node-parameters/options', sendData); } export async function getResourceLocatorResults( context: IRestApiContext, - sendData: DynamicNodeParameters.ResourceLocatorResultsRequest, + sendData: ResourceLocatorRequestDto, ): Promise { return await makeRestApiRequest( context, @@ -49,7 +55,7 @@ export async function getResourceLocatorResults( export async function getResourceMapperFields( context: IRestApiContext, - sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, + sendData: ResourceMapperFieldsRequestDto, ): Promise { return await makeRestApiRequest( context, @@ -61,7 +67,7 @@ export async function getResourceMapperFields( export async function getLocalResourceMapperFields( context: IRestApiContext, - sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, + sendData: ResourceMapperFieldsRequestDto, ): Promise { return await makeRestApiRequest( context, @@ -73,7 +79,7 @@ export async function getLocalResourceMapperFields( export async function getNodeParameterActionResult( context: IRestApiContext, - sendData: DynamicNodeParameters.ActionResultRequest, + sendData: ActionResultRequestDto, ): Promise { return await makeRestApiRequest( context, diff --git a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue index 1409d15ec9d4c..5deffba19767d 100644 --- a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue +++ b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue @@ -1,5 +1,6 @@