diff --git a/packages/nodes-base/credentials/WordpressApi.credentials.ts b/packages/nodes-base/credentials/WordpressApi.credentials.ts index 44cef12a681..c3ada2c4d52 100644 --- a/packages/nodes-base/credentials/WordpressApi.credentials.ts +++ b/packages/nodes-base/credentials/WordpressApi.credentials.ts @@ -56,8 +56,8 @@ export class WordpressApi implements ICredentialType { test: ICredentialTestRequest = { request: { - baseURL: '={{$credentials?.url}}/wp-json/wp/v2', - url: '/users', + baseURL: '={{$credentials?.url}}', + url: '/wp-json/wp/v2/users', method: 'GET', skipSslCertificateValidation: '={{$credentials.allowUnauthorizedCerts}}', }, diff --git a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts index ac0feb564cd..bf7ffa3953e 100644 --- a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts @@ -8,6 +8,35 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; +/** + * Builds the WordPress API URL, supporting both pretty and non-pretty permalinks + * @param baseUrl The base WordPress site URL + * @param resource The API resource path (e.g., '/posts') + * @returns The full API URL + */ +function buildWordPressApiUrl(baseUrl: string, resource: string): string { + const url = baseUrl.toString(); + + // Remove trailing slash from base URL if present + const normalizedUrl = url.replace(/\/$/, ''); + + // Check if the URL already contains query parameters + const hasQueryParams = normalizedUrl.includes('?'); + + // For non-pretty permalinks, use ?rest_route= format + // This is more reliable than /wp-json/ which requires pretty permalinks to be enabled + // See: https://github.com/n8n-io/n8n/issues/18883 + if (hasQueryParams) { + // If there are already query params, append with & + return `${normalizedUrl}&rest_route=/wp/v2${resource}`; + } else { + // Try pretty permalinks first (/wp-json/), but fall back to ?rest_route= if needed + // The initial request will use /wp-json/, and if it fails, the user should configure + // their WordPress to use pretty permalinks or we'll automatically detect it + return `${normalizedUrl}/wp-json/wp/v2${resource}`; + } +} + export async function wordpressApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, @@ -29,7 +58,7 @@ export async function wordpressApiRequest( method, qs, body, - uri: uri || `${credentials.url}/wp-json/wp/v2${resource}`, + uri: uri || buildWordPressApiUrl(credentials.url as string, resource), rejectUnauthorized: !credentials.allowUnauthorizedCerts, json: true, }; @@ -37,10 +66,31 @@ export async function wordpressApiRequest( if (Object.keys(options.body as IDataObject).length === 0) { delete options.body; } + const credentialType = 'wordpressApi'; + try { - const credentialType = 'wordpressApi'; return await this.helpers.requestWithAuthentication.call(this, credentialType, options); } catch (error) { + // If we get a 404 and haven't tried the non-pretty permalink format yet, + // retry with ?rest_route= format + const errorData = error as JsonObject; + if ( + errorData.statusCode === 404 && + !uri && + options.uri && + !options.uri.toString().includes('rest_route') + ) { + const baseUrl = credentials.url as string; + const normalizedUrl = baseUrl.replace(/\/$/, ''); + options.uri = `${normalizedUrl}/?rest_route=/wp/v2${resource}`; + + try { + return await this.helpers.requestWithAuthentication.call(this, credentialType, options); + } catch (retryError) { + // If retry also fails, throw a NodeApiError with the retry error + throw new NodeApiError(this.getNode(), retryError as JsonObject); + } + } throw new NodeApiError(this.getNode(), error as JsonObject); } } diff --git a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts index b46cecedad4..42c15ddcac8 100644 --- a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts @@ -19,15 +19,70 @@ describe('Wordpress > GenericFunctions', () => { }); describe('wordpressApiRequest', () => { - it('should make a successful request', async () => { + it('should make a successful request with pretty permalinks', async () => { mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' }); const result = await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); expect(result).toEqual({ data: 'testData' }); - expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalled(); + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressApi', + expect.objectContaining({ + uri: 'http://example.com/wp-json/wp/v2/posts', + }), + ); }); - it('should throw NodeApiError on failure', async () => { - mockFunctions.helpers.requestWithAuthentication.mockRejectedValue({ message: 'fail' }); + it('should retry with non-pretty permalinks on 404 error', async () => { + // First call fails with 404 + mockFunctions.helpers.requestWithAuthentication + .mockRejectedValueOnce({ statusCode: 404, message: 'Not Found' }) + .mockResolvedValueOnce({ data: 'testData' }); + + const result = await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + expect(result).toEqual({ data: 'testData' }); + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(2); + + // Second call should use non-pretty permalink format + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenNthCalledWith( + 2, + 'wordpressApi', + expect.objectContaining({ + uri: 'http://example.com/?rest_route=/wp/v2/posts', + }), + ); + }); + + it('should handle URL with trailing slash', async () => { + mockFunctions.getCredentials.mockResolvedValueOnce({ + url: 'http://example.com/', + allowUnauthorizedCerts: false, + }); + mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' }); + + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressApi', + expect.objectContaining({ + uri: 'http://example.com/wp-json/wp/v2/posts', + }), + ); + }); + + it('should throw NodeApiError on non-404 failure', async () => { + mockFunctions.helpers.requestWithAuthentication.mockRejectedValue({ + statusCode: 500, + message: 'Internal Server Error', + }); + await expect( + wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}), + ).rejects.toThrow(NodeApiError); + }); + + it('should throw NodeApiError if both pretty and non-pretty permalinks fail', async () => { + mockFunctions.helpers.requestWithAuthentication + .mockRejectedValueOnce({ statusCode: 404, message: 'Not Found' }) + .mockRejectedValueOnce({ statusCode: 404, message: 'Not Found' }); + await expect( wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}), ).rejects.toThrow(NodeApiError);