Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/api/CommercetoolsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/auth/CommercetoolsAuthApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 20 additions & 3 deletions src/lib/request/request-executor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,13 +16,14 @@ export interface GetRequestExecutorProps extends CommercetoolsHooks {
timeoutMs?: number
retry?: Partial<CommercetoolsRetryConfig>
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,
Expand All @@ -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<RequestExecutor>((next, middleware) => {
return (requestConfig: CommercetoolsRequest): Promise<any> => middleware(next, requestConfig)
}, baseExecutor)

return composedExecutor
}
23 changes: 23 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export interface CommercetoolsBaseConfig extends CommercetoolsHooks {
timeoutMs?: number
retry?: Partial<CommercetoolsRetryConfig>

/**
* 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
Expand Down Expand Up @@ -175,4 +186,16 @@ export interface RequestExecutor<T = any> {
(options: CommercetoolsRequest): Promise<T>
}

/**
* 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<any>

export * from './types/models/index.js'
59 changes: 59 additions & 0 deletions src/test/api/CommercetoolsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
180 changes: 180 additions & 0 deletions src/test/request/__tests__/request-executor.test.ts
Original file line number Diff line number Diff line change
@@ -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<any> => {
order.push('m1-before')
const result = await next(requestConfig)
order.push('m1-after')
return result
},
async (next, requestConfig): Promise<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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',
}),
}),
)
})
})