diff --git a/src/lib/api/CommercetoolsApi.ts b/src/lib/api/CommercetoolsApi.ts index 7a65241037..5b776ab87a 100644 --- a/src/lib/api/CommercetoolsApi.ts +++ b/src/lib/api/CommercetoolsApi.ts @@ -328,6 +328,7 @@ export class CommercetoolsApi { timeoutMs: config.timeoutMs, httpsAgent: config.httpsAgent, systemIdentifier: config.systemIdentifier, + operationMiddlewares: config.operationMiddlewares, onBeforeRequest: config.onBeforeRequest, onAfterResponse: config.onAfterResponse, retry: config.retry, diff --git a/src/lib/auth/CommercetoolsAuthApi.ts b/src/lib/auth/CommercetoolsAuthApi.ts index 5c77a934d0..86b47b7eef 100644 --- a/src/lib/auth/CommercetoolsAuthApi.ts +++ b/src/lib/auth/CommercetoolsAuthApi.ts @@ -47,6 +47,7 @@ export class CommercetoolsAuthApi { timeoutMs: config.timeoutMs, httpsAgent: config.httpsAgent, systemIdentifier: config.systemIdentifier, + operationMiddlewares: config.operationMiddlewares, onBeforeRequest: config.onBeforeRequest, onAfterResponse: config.onAfterResponse, retry: config.retry, diff --git a/src/lib/request/request-executor.ts b/src/lib/request/request-executor.ts index f227d56f49..9d108da21e 100644 --- a/src/lib/request/request-executor.ts +++ b/src/lib/request/request-executor.ts @@ -1,6 +1,11 @@ -import https from 'https' +import https from 'node:https' import { CommercetoolsRetryConfig } from '../api/index.js' -import { CommercetoolsHooks, CommercetoolsRequest, RequestExecutor } from '../types.js' +import { + CommercetoolsHooks, + CommercetoolsOperationMiddleware, + CommercetoolsRequest, + RequestExecutor, +} from '../types.js' import { request } from './index.js' import { buildUserAgent, createAxiosInstance } from '../utils/index.js' import { DEFAULT_RETRY_CONFIG } from '../constants.js' @@ -11,13 +16,14 @@ export interface GetRequestExecutorProps extends CommercetoolsHooks { timeoutMs?: number retry?: Partial systemIdentifier?: string + operationMiddlewares?: CommercetoolsOperationMiddleware[] } export function getRequestExecutor(props: GetRequestExecutorProps): RequestExecutor { const axiosInstance = createAxiosInstance({ httpsAgent: props.httpsAgent }) const instanceHeaders = { 'User-Agent': buildUserAgent(props.systemIdentifier) } - return (requestConfig: CommercetoolsRequest) => { + const baseExecutor: RequestExecutor = (requestConfig: CommercetoolsRequest) => { const headers = { ...instanceHeaders, ...requestConfig.headers } return request({ axiosInstance, @@ -37,4 +43,15 @@ export function getRequestExecutor(props: GetRequestExecutorProps): RequestExecu abortController: requestConfig.abortController, }) } + + const middlewares = props.operationMiddlewares ?? [] + if (!middlewares.length) { + return baseExecutor + } + + const composedExecutor = middlewares.reduceRight((next, middleware) => { + return (requestConfig: CommercetoolsRequest): Promise => middleware(next, requestConfig) + }, baseExecutor) + + return composedExecutor } diff --git a/src/lib/types.ts b/src/lib/types.ts index 53b4c95d69..3c6e2f4ed6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -39,6 +39,17 @@ export interface CommercetoolsBaseConfig extends CommercetoolsHooks { timeoutMs?: number retry?: Partial + /** + * Middleware pipeline that wraps a full logical request operation. + * + * Each middleware receives the next executor in the chain and the request + * config for the current operation. Middleware can: + * - call `next(requestConfig)` to continue, + * - short-circuit by returning a value without calling `next`, or + * - throw to fail the operation. + */ + operationMiddlewares?: CommercetoolsOperationMiddleware[] + /** * If provided, will be passed across to commercetools in the * 'User-Agent' HTTP header, in order to help commercetools @@ -175,4 +186,16 @@ export interface RequestExecutor { (options: CommercetoolsRequest): Promise } +/** + * Middleware that wraps a single logical request operation. + */ +export type CommercetoolsOperationMiddleware = ( + /** + * Call `next(requestConfig)` to continue, or + * pass a modified config to continue with changes. + */ + next: RequestExecutor, + requestConfig: CommercetoolsRequest, +) => Promise + export * from './types/models/index.js' diff --git a/src/test/api/CommercetoolsApi.test.ts b/src/test/api/CommercetoolsApi.test.ts index ac3f06259c..3472c20038 100644 --- a/src/test/api/CommercetoolsApi.test.ts +++ b/src/test/api/CommercetoolsApi.test.ts @@ -130,6 +130,65 @@ describe('CommercetoolsApi', () => { expect(api.auth.config.authUrl).toBe('http://localhost:4000/auth') expect(api.endpoints.api).toBe('https://api.europe-west1.gcp.commercetools.com') }) + + it('should apply operation middleware for API requests', async () => { + const middleware = vi.fn().mockImplementation(async () => { + return { fromMiddleware: true } + }) + + const api = new CommercetoolsApi({ + ...defaultConfig, + operationMiddlewares: [middleware], + }) + + const result = await api.queryStores() + + expect(result).toEqual({ fromMiddleware: true }) + expect(middleware).toHaveBeenCalledTimes(2) + }) + + it('should run multiple operation middlewares in declaration order', async () => { + const order: string[] = [] + + const middleware1 = vi.fn(async (next, requestConfig) => { + order.push('m1-before') + const result = await next(requestConfig) + order.push('m1-after') + return result + }) + + const middleware2 = vi.fn(async (next, requestConfig) => { + order.push('m2-before') + const result = await next(requestConfig) + order.push('m2-after') + return result + }) + + nock('https://api.europe-west1.gcp.commercetools.com') + .get('/test-project-key/stores') + .reply(200, { success: true }) + + const api = new CommercetoolsApi({ + ...defaultConfig, + operationMiddlewares: [middleware1, middleware2], + }) + + const result = await api.queryStores() + + expect(result).toEqual({ success: true }) + expect(order).toEqual([ + 'm1-before', + 'm2-before', + 'm2-after', + 'm1-after', + 'm1-before', + 'm2-before', + 'm2-after', + 'm1-after', + ]) + expect(middleware1).toHaveBeenCalledTimes(2) + expect(middleware2).toHaveBeenCalledTimes(2) + }) }) describe('extractCommonRequestOptions', () => { diff --git a/src/test/request/__tests__/request-executor.test.ts b/src/test/request/__tests__/request-executor.test.ts new file mode 100644 index 0000000000..7c3f556389 --- /dev/null +++ b/src/test/request/__tests__/request-executor.test.ts @@ -0,0 +1,180 @@ +import { vi } from 'vitest' +import nock from 'nock' +import { getRequestExecutor } from '../../../lib/request/request-executor.js' + +describe('getRequestExecutor operationMiddlewares', () => { + beforeEach(() => { + nock.cleanAll() + }) + + afterAll(() => { + nock.cleanAll() + }) + + it('should execute operation middlewares in declaration order', async () => { + const order: string[] = [] + + const requestExecutor = getRequestExecutor({ + timeoutMs: 1000, + retry: { + delayMs: 0, + maxRetries: 0, + jitter: false, + }, + operationMiddlewares: [ + async (next, requestConfig): Promise => { + order.push('m1-before') + const result = await next(requestConfig) + order.push('m1-after') + return result + }, + async (next, requestConfig): Promise => { + order.push('m2-before') + const result = await next(requestConfig) + order.push('m2-after') + return result + }, + ], + }) + + const scope = nock('https://localhost') + .get('/test') + .matchHeader('X-From-Middleware', 'yes') + .reply(200, { success: true }) + + const response = await requestExecutor({ + url: 'https://localhost/test', + method: 'GET', + headers: { + 'X-From-Middleware': 'yes', + }, + }) + + expect(response).toEqual({ success: true }) + expect(scope.isDone()).toBe(true) + expect(order).toEqual(['m1-before', 'm2-before', 'm2-after', 'm1-after']) + }) + + it('should allow short-circuiting without making an HTTP call', async () => { + const requestExecutor = getRequestExecutor({ + retry: { + delayMs: 0, + maxRetries: 0, + jitter: false, + }, + operationMiddlewares: [ + async (): Promise => { + return { shortCircuited: true } + }, + ], + }) + + const result = await requestExecutor({ + url: 'https://localhost/test', + method: 'GET', + headers: {}, + }) + + expect(result).toEqual({ shortCircuited: true }) + expect(nock.pendingMocks()).toEqual([]) + }) + + it('should allow middleware to override request data before execution', async () => { + const requestExecutor = getRequestExecutor({ + retry: { + delayMs: 0, + maxRetries: 0, + jitter: false, + }, + operationMiddlewares: [ + async (next, requestConfig): Promise => { + return next({ + ...requestConfig, + headers: { + ...requestConfig.headers, + Authorization: 'Bearer middleware-token', + }, + }) + }, + ], + }) + + const scope = nock('https://localhost') + .get('/test') + .matchHeader('Authorization', 'Bearer middleware-token') + .reply(200, { success: true }) + + const response = await requestExecutor({ + url: 'https://localhost/test', + method: 'GET', + headers: {}, + }) + + expect(response).toEqual({ success: true }) + expect(scope.isDone()).toBe(true) + }) + + it('should propagate middleware errors', async () => { + const expectedError = new Error('middleware failed') + + const requestExecutor = getRequestExecutor({ + operationMiddlewares: [ + async (): Promise => { + throw expectedError + }, + ], + }) + + await expect( + requestExecutor({ + url: 'https://localhost/test', + method: 'GET', + headers: {}, + }), + ).rejects.toBe(expectedError) + }) + + it('should include middleware-modified request in onBeforeRequest callback', async () => { + const onBeforeRequest = vi.fn().mockImplementation((config: any) => config) + + const requestExecutor = getRequestExecutor({ + retry: { + delayMs: 0, + maxRetries: 0, + jitter: false, + }, + onBeforeRequest, + operationMiddlewares: [ + async (next, requestConfig): Promise => { + return next({ + ...requestConfig, + headers: { + ...requestConfig.headers, + 'X-From-Operation-Middleware': '1', + }, + }) + }, + ], + }) + + const scope = nock('https://localhost') + .get('/test') + .matchHeader('X-From-Operation-Middleware', '1') + .reply(200, { success: true }) + + await requestExecutor({ + url: 'https://localhost/test', + method: 'GET', + headers: {}, + }) + + expect(scope.isDone()).toBe(true) + expect(onBeforeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-From-Operation-Middleware': '1', + }), + }), + ) + }) +})