refactor(core): Extract Worfklow import request payload into a DTO (#12441)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-03 12:19:09 +01:00 committed by GitHub
parent b1940268e6
commit 1d3cb9f5ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 152 additions and 13 deletions

View file

@ -35,3 +35,5 @@ export { CommunityRegisteredRequestDto } from './license/community-registered-re
export { VariableListRequestDto } from './variables/variables-list-request.dto';
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';

View file

@ -0,0 +1,63 @@
import { ImportWorkflowFromUrlDto } from '../import-workflow-from-url.dto';
describe('ImportWorkflowFromUrlDto', () => {
describe('Valid requests', () => {
test('should validate $name', () => {
const result = ImportWorkflowFromUrlDto.safeParse({
url: 'https://example.com/workflow.json',
});
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid URL (not ending with .json)',
url: 'https://example.com/workflow',
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (missing protocol)',
url: 'example.com/workflow.json',
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (not a URL)',
url: 'not-a-url',
expectedErrorPath: ['url'],
},
{
name: 'missing URL',
url: undefined,
expectedErrorPath: ['url'],
},
{
name: 'null URL',
url: null,
expectedErrorPath: ['url'],
},
{
name: 'invalid URL (ends with .json but not a valid URL)',
url: 'not-a-url.json',
expectedErrorPath: ['url'],
},
{
name: 'valid URL with query parameters',
url: 'https://example.com/workflow.json?param=value',
},
{
name: 'valid URL with fragments',
url: 'https://example.com/workflow.json#section',
},
])('should fail validation for $name', ({ url, expectedErrorPath }) => {
const result = ImportWorkflowFromUrlDto.safeParse({ url });
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ImportWorkflowFromUrlDto extends Z.class({
url: z.string().url().endsWith('.json'),
}) {}

View file

@ -0,0 +1,72 @@
import type { ImportWorkflowFromUrlDto } from '@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto';
import axios from 'axios';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { AuthenticatedRequest } from '@/requests';
import { WorkflowsController } from '../workflows.controller';
jest.mock('axios');
describe('WorkflowsController', () => {
const controller = Object.create(WorkflowsController.prototype);
const axiosMock = axios.get as jest.Mock;
const req = mock<AuthenticatedRequest>();
const res = mock<Response>();
describe('getFromUrl', () => {
describe('should return workflow data', () => {
it('when the URL points to a valid JSON file', async () => {
const mockWorkflowData = {
nodes: [],
connections: {},
};
axiosMock.mockResolvedValue({ data: mockWorkflowData });
const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/workflow.json' };
const result = await controller.getFromUrl(req, res, query);
expect(result).toEqual(mockWorkflowData);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
});
describe('should throw a BadRequestError', () => {
const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/invalid.json' };
it('when the URL does not point to a valid JSON file', async () => {
axiosMock.mockRejectedValue(new Error('Network Error'));
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
it('when the data is not a valid n8n workflow JSON', async () => {
const invalidWorkflowData = {
nodes: 'not an array',
connections: 'not an object',
};
axiosMock.mockResolvedValue({ data: invalidWorkflowData });
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
it('when the data is missing required fields', async () => {
const incompleteWorkflowData = {
nodes: [],
// Missing connections field
};
axiosMock.mockResolvedValue({ data: incompleteWorkflowData });
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError);
expect(axiosMock).toHaveBeenCalledWith(query.url);
});
});
});
});

View file

@ -69,6 +69,4 @@ export declare namespace WorkflowRequest {
{},
{ destinationProjectId: string }
>;
type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>;
}

View file

@ -1,3 +1,4 @@
import { ImportWorkflowFromUrlDto } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm';
@ -18,7 +19,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl
import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import * as Db from '@/db';
import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators';
import { Delete, Get, Patch, Post, ProjectScope, Put, Query, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@ -29,6 +30,7 @@ import { validateEntity } from '@/generic-helpers';
import type { IWorkflowResponse } from '@/interfaces';
import { License } from '@/license';
import { listQueryMiddleware } from '@/middlewares';
import { AuthenticatedRequest } from '@/requests';
import * as ResponseHelper from '@/response-helper';
import { NamingService } from '@/services/naming.service';
import { ProjectService } from '@/services/project.service.ee';
@ -215,18 +217,14 @@ export class WorkflowsController {
}
@Get('/from-url')
async getFromUrl(req: WorkflowRequest.FromUrl) {
if (req.query.url === undefined) {
throw new BadRequestError('The parameter "url" is missing!');
}
if (!/^http[s]?:\/\/.*\.json$/i.exec(req.query.url)) {
throw new BadRequestError(
'The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.',
);
}
async getFromUrl(
_req: AuthenticatedRequest,
_res: express.Response,
@Query query: ImportWorkflowFromUrlDto,
) {
let workflowData: IWorkflowResponse | undefined;
try {
const { data } = await axios.get<IWorkflowResponse>(req.query.url);
const { data } = await axios.get<IWorkflowResponse>(query.url);
workflowData = data;
} catch (error) {
throw new BadRequestError('The URL does not point to valid JSON file!');