mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(RSS Feed Read Node): Respect proxy settings (#30059)
This commit is contained in:
parent
0494f24967
commit
2e046d5b7f
63
packages/nodes-base/nodes/RssFeedRead/GenericFunctions.ts
Normal file
63
packages/nodes-base/nodes/RssFeedRead/GenericFunctions.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestOptions,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { sanitizeXmlName } from 'n8n-workflow';
|
||||
import Parser from 'rss-parser';
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
'User-Agent': 'rss-parser',
|
||||
Accept: 'application/rss+xml',
|
||||
};
|
||||
|
||||
const RELAXED_ACCEPT_HEADER =
|
||||
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4';
|
||||
|
||||
type RequestHelpers = IExecuteFunctions['helpers'] | IPollFunctions['helpers'];
|
||||
|
||||
export async function parseFeedUrl(
|
||||
helpers: RequestHelpers,
|
||||
feedUrl: string,
|
||||
options: {
|
||||
customFields?: string;
|
||||
ignoreSSL?: boolean;
|
||||
useRelaxedAcceptHeader?: boolean;
|
||||
} = {},
|
||||
): Promise<Parser.Output<IDataObject>> {
|
||||
const headers = {
|
||||
...DEFAULT_HEADERS,
|
||||
...(options.useRelaxedAcceptHeader ? { Accept: RELAXED_ACCEPT_HEADER } : {}),
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
method: 'GET',
|
||||
url: feedUrl,
|
||||
headers,
|
||||
json: false,
|
||||
encoding: 'text',
|
||||
skipSslCertificateValidation: options.ignoreSSL,
|
||||
};
|
||||
|
||||
const feedXmlResponse = await helpers.httpRequest(requestOptions);
|
||||
const feedXml = typeof feedXmlResponse === 'string' ? feedXmlResponse : String(feedXmlResponse);
|
||||
|
||||
const parserOptions: Parser.ParserOptions<IDataObject, IDataObject> = {
|
||||
xml2js: {
|
||||
tagNameProcessors: [sanitizeXmlName],
|
||||
attrNameProcessors: [sanitizeXmlName],
|
||||
},
|
||||
...(options.customFields
|
||||
? {
|
||||
customFields: {
|
||||
item: options.customFields.split(',').map((field) => field.trim()),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const parser = new Parser(parserOptions);
|
||||
|
||||
return await parser.parseString(feedXml);
|
||||
}
|
||||
|
|
@ -5,11 +5,13 @@ import type {
|
|||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError, sanitizeXmlName } from 'n8n-workflow';
|
||||
import Parser from 'rss-parser';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import { URL } from 'url';
|
||||
|
||||
import type Parser from 'rss-parser';
|
||||
|
||||
import { generatePairedItemData } from '../../utils/utilities';
|
||||
import { parseFeedUrl } from './GenericFunctions';
|
||||
|
||||
// Utility function
|
||||
|
||||
|
|
@ -105,35 +107,13 @@ export class RssFeedRead implements INodeType {
|
|||
});
|
||||
}
|
||||
|
||||
const parserOptions: IDataObject = {
|
||||
requestOptions: {
|
||||
rejectUnauthorized: !ignoreSSL,
|
||||
},
|
||||
xml2js: {
|
||||
tagNameProcessors: [sanitizeXmlName],
|
||||
attrNameProcessors: [sanitizeXmlName],
|
||||
},
|
||||
};
|
||||
|
||||
if (nodeVersion >= 1.2) {
|
||||
parserOptions.headers = {
|
||||
Accept:
|
||||
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4',
|
||||
};
|
||||
}
|
||||
|
||||
if (options.customFields) {
|
||||
const customFields = options.customFields as string;
|
||||
parserOptions.customFields = {
|
||||
item: customFields.split(',').map((field) => field.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
const parser = new Parser(parserOptions);
|
||||
|
||||
let feed: Parser.Output<IDataObject>;
|
||||
try {
|
||||
feed = await parser.parseURL(url);
|
||||
feed = await parseFeedUrl(this.helpers, url, {
|
||||
customFields: options.customFields as string | undefined,
|
||||
ignoreSSL,
|
||||
useRelaxedAcceptHeader: nodeVersion >= 1.2,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new NodeOperationError(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import type {
|
|||
INodeTypeDescription,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError, sanitizeXmlName } from 'n8n-workflow';
|
||||
import Parser from 'rss-parser';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type Parser from 'rss-parser';
|
||||
|
||||
import { parseFeedUrl } from './GenericFunctions';
|
||||
|
||||
interface PollData {
|
||||
lastItemDate?: string;
|
||||
|
|
@ -54,16 +57,9 @@ export class RssFeedReadTrigger implements INodeType {
|
|||
throw new NodeOperationError(this.getNode(), 'The parameter "URL" has to be set!');
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
xml2js: {
|
||||
tagNameProcessors: [sanitizeXmlName],
|
||||
attrNameProcessors: [sanitizeXmlName],
|
||||
},
|
||||
});
|
||||
|
||||
let feed: Parser.Output<IDataObject>;
|
||||
try {
|
||||
feed = await parser.parseURL(feedUrl);
|
||||
feed = await parseFeedUrl(this.helpers, feedUrl);
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new NodeOperationError(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import Parser from 'rss-parser';
|
||||
|
||||
import { parseFeedUrl } from '../GenericFunctions';
|
||||
|
||||
jest.mock('rss-parser');
|
||||
|
||||
const ParserMock = Parser as unknown as jest.Mock;
|
||||
|
||||
const RELAXED_ACCEPT =
|
||||
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4';
|
||||
|
||||
describe('parseFeedUrl', () => {
|
||||
const feedUrl = 'https://example.com/feed';
|
||||
const xmlBody = '<rss />';
|
||||
const parsed = { items: [{ title: 'item-1' }] };
|
||||
|
||||
let helpers: ReturnType<typeof mock<IExecuteFunctions['helpers']>>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
helpers = mock<IExecuteFunctions['helpers']>();
|
||||
helpers.httpRequest.mockResolvedValue(xmlBody);
|
||||
|
||||
(Parser.prototype.parseString as jest.Mock).mockResolvedValue(parsed);
|
||||
});
|
||||
|
||||
it('uses GET with default headers, text encoding, and no SSL skip when no options are passed', async () => {
|
||||
const result = await parseFeedUrl(helpers, feedUrl);
|
||||
|
||||
expect(result).toBe(parsed);
|
||||
|
||||
expect(helpers.httpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith({
|
||||
method: 'GET',
|
||||
url: feedUrl,
|
||||
headers: {
|
||||
'User-Agent': 'rss-parser',
|
||||
Accept: 'application/rss+xml',
|
||||
},
|
||||
json: false,
|
||||
encoding: 'text',
|
||||
skipSslCertificateValidation: undefined,
|
||||
});
|
||||
|
||||
expect(Parser.prototype.parseString).toHaveBeenCalledWith(xmlBody);
|
||||
});
|
||||
|
||||
it('switches to the relaxed Accept header when useRelaxedAcceptHeader is true', async () => {
|
||||
await parseFeedUrl(helpers, feedUrl, { useRelaxedAcceptHeader: true });
|
||||
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Accept: RELAXED_ACCEPT }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes skipSslCertificateValidation when ignoreSSL is true', async () => {
|
||||
await parseFeedUrl(helpers, feedUrl, { ignoreSSL: true });
|
||||
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skipSslCertificateValidation: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('splits and trims customFields before passing them to the parser', async () => {
|
||||
await parseFeedUrl(helpers, feedUrl, { customFields: 'custom, dc:creator , media:content' });
|
||||
|
||||
expect(ParserMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customFields: { item: ['custom', 'dc:creator', 'media:content'] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set a customFields parser option when none are provided', async () => {
|
||||
await parseFeedUrl(helpers, feedUrl);
|
||||
|
||||
const parserOptions = ParserMock.mock.calls.at(-1)?.[0] ?? {};
|
||||
expect(parserOptions).not.toHaveProperty('customFields');
|
||||
});
|
||||
|
||||
it('always sets the sanitizing xml2js processors on the parser', async () => {
|
||||
await parseFeedUrl(helpers, feedUrl);
|
||||
|
||||
const parserOptions = ParserMock.mock.calls.at(-1)?.[0];
|
||||
expect(parserOptions?.xml2js?.tagNameProcessors).toHaveLength(1);
|
||||
expect(parserOptions?.xml2js?.attrNameProcessors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('coerces a non-string response body to a string before parsing', async () => {
|
||||
helpers.httpRequest.mockResolvedValue(Buffer.from('<rss>buf</rss>') as unknown as string);
|
||||
|
||||
await parseFeedUrl(helpers, feedUrl);
|
||||
|
||||
expect(Parser.prototype.parseString).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<rss>buf</rss>'),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by the underlying httpRequest helper', async () => {
|
||||
const networkError = Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
|
||||
helpers.httpRequest.mockRejectedValue(networkError);
|
||||
|
||||
await expect(parseFeedUrl(helpers, feedUrl)).rejects.toBe(networkError);
|
||||
expect(Parser.prototype.parseString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors thrown by the parser', async () => {
|
||||
const parseError = new Error('bad xml');
|
||||
(Parser.prototype.parseString as jest.Mock).mockRejectedValue(parseError);
|
||||
|
||||
await expect(parseFeedUrl(helpers, feedUrl)).rejects.toBe(parseError);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,8 +17,11 @@ describe('RssFeedReadTrigger', () => {
|
|||
const newItemDate = '2022-01-02T00:00:00.000Z';
|
||||
|
||||
const node = new RssFeedReadTrigger();
|
||||
const pollFunctions = mock<IPollFunctions>({
|
||||
helpers: mock({ returnJsonArray }),
|
||||
const helpers = mock<IPollFunctions['helpers']>({ returnJsonArray });
|
||||
const pollFunctions = mock<IPollFunctions>({ helpers });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error if the feed URL is empty', async () => {
|
||||
|
|
@ -27,14 +30,16 @@ describe('RssFeedReadTrigger', () => {
|
|||
await expect(node.poll.call(pollFunctions)).rejects.toThrowError();
|
||||
|
||||
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
|
||||
expect(Parser.prototype.parseURL).not.toHaveBeenCalled();
|
||||
expect(helpers.httpRequest).not.toHaveBeenCalled();
|
||||
expect(Parser.prototype.parseString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return new items from the feed', async () => {
|
||||
const pollData = mock({ lastItemDate });
|
||||
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
|
||||
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
|
||||
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({
|
||||
helpers.httpRequest.mockResolvedValue('<rss />');
|
||||
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({
|
||||
items: [{ isoDate: lastItemDate }, { isoDate: newItemDate }],
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +48,10 @@ describe('RssFeedReadTrigger', () => {
|
|||
expect(result).toEqual([[{ json: { isoDate: newItemDate } }]]);
|
||||
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
|
||||
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
|
||||
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET', url: feedUrl }),
|
||||
);
|
||||
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
|
||||
expect(pollData.lastItemDate).toEqual(newItemDate);
|
||||
});
|
||||
|
||||
|
|
@ -51,28 +59,36 @@ describe('RssFeedReadTrigger', () => {
|
|||
const pollData = mock();
|
||||
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
|
||||
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
|
||||
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({ items: [{}, {}] });
|
||||
helpers.httpRequest.mockResolvedValue('<rss />');
|
||||
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({ items: [{}, {}] });
|
||||
|
||||
const result = await node.poll.call(pollFunctions);
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
|
||||
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
|
||||
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET', url: feedUrl }),
|
||||
);
|
||||
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
|
||||
});
|
||||
|
||||
it('should return null if the feed is empty', async () => {
|
||||
const pollData = mock({ lastItemDate });
|
||||
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
|
||||
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
|
||||
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({ items: [] });
|
||||
helpers.httpRequest.mockResolvedValue('<rss />');
|
||||
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({ items: [] });
|
||||
|
||||
const result = await node.poll.call(pollFunctions);
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
|
||||
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
|
||||
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET', url: feedUrl }),
|
||||
);
|
||||
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
|
||||
expect(pollData.lastItemDate).toEqual(lastItemDate);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user