diff --git a/packages/axios-large-response/README.md b/packages/axios-large-response/README.md index 89fa8b9..1f2cd25 100644 --- a/packages/axios-large-response/README.md +++ b/packages/axios-large-response/README.md @@ -58,17 +58,50 @@ const response = await axiosInstance.get('https://api.example.com/data', { }); ``` +## Clients that don't dispatch through axios (`withLargeResponse`) + +Interceptors only run for requests that go through the axios adapter. A client configured with its own runner bypasses that adapter entirely, so the interceptor never sees those requests - and responses over the transport's payload limit fail with a `413`. The most common case is service-to-service calls over an AWS Lambda invoke, wired up with `openapi-client-axios`' `registerRunner`. + +`withLargeResponse` wraps such a runner and gives it the same behaviour as the interceptor: + +```ts +import { withLargeResponse } from '@epilot/axios-large-response'; +import { getLambdaRunner } from 'openapi-lambda-adapter'; + +client.api.registerRunner( + withLargeResponse(getLambdaRunner(lambdaName, context), { + enabled: true, + // ... same options as the interceptor + }), +); +``` + +The runner is described structurally - anything with a `runRequest(request, ...rest)` method whose first argument is an object qualifies - so this is not tied to AWS Lambda, or to any particular transport. HTTP clients, in-process calls and test doubles all work, and trailing arguments (such as `openapi-client-axios`' `operation` and `context`) are passed through untouched. The package gains no dependency beyond `axios` as a result. + +Per-request options work as they do on the interceptor: set them under the `axios-large-response` key on the request and they override the global ones for that call. The key is stripped before the request reaches your runner. + +```ts +client.getThings({}, null, { + 'axios-large-response': { onFetchLargePayloadFromRef: myAuthenticatedFetch }, +}); +``` + +Two notes: + +- **`enabled` is read once at wrap time** to decide whether to wrap at all - when it is `false`, the original runner is returned untouched with zero overhead. A per-request `enabled: false` still opts an individual call out. +- **The wrapped runner keeps the original's prototype and own properties.** This matters: `openapi-client-axios` invokes a registered runner as `runner.runRequest(request, operation, runner.context)`, and the lambda runner reads the target function name off that `context`. Class instances keep their methods and their identity, and `runRequest` stays bound to the original, so a method that reads `this` still works. Wrap your runner rather than rebuilding it. + ## Options | Name | Type | Default | Description | |------|------|---------|-------------| | enabled | Boolean | false | Enable/disable the interceptor | | headerFlag | String | 'application/large-response.vnd+json' | Content type header indicating a large payload reference response | -| refProperty | String | '$payloadRef' | Property name containing the reference URL in the response | +| refProperty | String | '$payload_ref' | Property name containing the reference URL in the response | | debug | Boolean | false | Enable debug logging | -| logger | Object | console | Logger object with debug() and error() methods | +| logger | Object | console | Logger object with debug(), error() and warn() methods | | onFetchLargePayloadFromRef | Function | Fetches the reference URL and returns the full payload | Callback function to fetch the full payload from the reference URL | -| errorPayload | Unknown/Any | undefined | Error payload to return if the reference URL is not found or something goes wrong - this will be returned in the response data instead of throwing an error | +| errorPayload | Unknown/Any | undefined | Error payload to return if the reference URL is not found or something goes wrong - this will be returned in the response data instead of throwing an error. Any value other than `undefined` counts as configured, falsy ones (`null`, `0`, `''`, `false`) included | | disableWarnings | Boolean | false | Disable warnings, only available globally in the options | For debug purposes, you can also set the `AXIOS_INTERCEPTOR_LARGE_RESPONSE_DEBUG` environment variable to `true` or `1` to enable debug logging. @@ -84,7 +117,7 @@ Example server response for a large payload: ```json { - "$payloadRef": "https://api.example.com/large-payloads/123" + "$payload_ref": "https://api.example.com/large-payloads/123" } ``` diff --git a/packages/axios-large-response/package.json b/packages/axios-large-response/package.json index fe5fcfb..84fd108 100644 --- a/packages/axios-large-response/package.json +++ b/packages/axios-large-response/package.json @@ -1,6 +1,6 @@ { "name": "@epilot/axios-large-response", - "version": "0.0.2", + "version": "0.0.3-alpha.1", "main": "dist/index.js", "types": "dist/index.d.ts", "module": "dist/index.mjs", diff --git a/packages/axios-large-response/src/index.ts b/packages/axios-large-response/src/index.ts index b29125a..61f4838 100644 --- a/packages/axios-large-response/src/index.ts +++ b/packages/axios-large-response/src/index.ts @@ -1,2 +1,3 @@ export { axiosLargeResponse } from './interceptor/axios-interceptor'; +export { withLargeResponse } from './runner/large-response-runner'; export * from './types'; diff --git a/packages/axios-large-response/src/interceptor/axios-interceptor.test.ts b/packages/axios-large-response/src/interceptor/axios-interceptor.test.ts index 23dda47..58cf2b4 100644 --- a/packages/axios-large-response/src/interceptor/axios-interceptor.test.ts +++ b/packages/axios-large-response/src/interceptor/axios-interceptor.test.ts @@ -500,3 +500,112 @@ const getInterceptors = (axiosInstance: AxiosInstance, requestId: number, respon responseInterceptor, }; }; + +/** + * The suite above calls the interceptor handlers directly, with hand-built config and + * response objects. That is precise but it never exercises axios itself: `headers` is a + * plain object in those tests, where a real request carries an `AxiosHeaders` instance. + * + * These go through the full pipeline against a stub adapter, so header handling is checked + * against the types axios actually produces. Without them, a break in that integration - + * an axios release changing `AxiosHeaders`, say - would pass the rest of the suite. + */ +describe('axiosLargeResponse through a real axios instance', () => { + const options = ( + overrides: Partial> = {}, + ): Required => ({ + enabled: true, + disableWarnings: true, + debug: false, + headerFlag: LARGE_PAYLOAD_MIME_TYPE, + refProperty: '$payload_ref', + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, + onFetchLargePayloadFromRef: vi.fn().mockResolvedValue({ huge: 'payload' }), + errorPayload: undefined, + ...overrides, + }); + + /** + * Returns the instance plus the config the adapter was handed, so assertions can be made + * about what would have gone on the wire. + */ + const instanceWithStubAdapter = (response: { headers: unknown; data: unknown }) => { + const instance = axios.create(); + const seen: { headers?: unknown } = {}; + + instance.defaults.adapter = async (config) => { + seen.headers = config.headers; + + return { + status: 200, + statusText: 'OK', + config, + headers: axios.AxiosHeaders.from(response.headers as Record), + data: response.data, + }; + }; + + return { instance, seen }; + }; + + it('should resolve an envelope end to end', async () => { + // given + const { instance } = instanceWithStubAdapter({ + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + + axiosLargeResponse(instance, options()); + + // when + const response = await instance.get('https://api.example.com/data'); + + // then + expect(response.data).toEqual({ huge: 'payload' }); + }); + + it('should leave a normal response untouched end to end', async () => { + // given + const globalOptions = options(); + const { instance } = instanceWithStubAdapter({ + headers: { 'content-type': 'application/json' }, + data: { foo: 'bar' }, + }); + + axiosLargeResponse(instance, globalOptions); + + // when + const response = await instance.get('https://api.example.com/data'); + + // then + expect(response.data).toEqual({ foo: 'bar' }); + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + }); + + /** + * The middleware matches `Accept` by exact value. The request interceptor assigns the + * flag directly, relying on axios to collapse a pre-existing lowercase `accept` before + * dispatch - if a release stopped doing that, two variants would travel together and the + * server could read the wrong one. + */ + it('should send exactly one Accept header when the request already set one', async () => { + // given + const { instance, seen } = instanceWithStubAdapter({ + headers: { 'content-type': 'application/json' }, + data: { foo: 'bar' }, + }); + + axiosLargeResponse(instance, options()); + + // when + await instance.get('https://api.example.com/data', { headers: { accept: 'application/json' } }); + + // then + const headers = seen.headers as { toJSON: () => Record }; + const bag = headers.toJSON(); + const acceptKeys = Object.keys(bag).filter((key) => key.toLowerCase() === 'accept'); + + expect(acceptKeys).toHaveLength(1); + expect(bag[acceptKeys[0]]).toEqual(LARGE_PAYLOAD_MIME_TYPE); + }); +}); diff --git a/packages/axios-large-response/src/interceptor/axios-interceptor.ts b/packages/axios-large-response/src/interceptor/axios-interceptor.ts index 37721eb..de4d819 100644 --- a/packages/axios-large-response/src/interceptor/axios-interceptor.ts +++ b/packages/axios-large-response/src/interceptor/axios-interceptor.ts @@ -1,5 +1,5 @@ -import type { AxiosLargeResponse, AxiosLargeResponseRequestOptions, LargePayloadResponse } from '../types'; -import { NAMESPACE, getOptions, isDebugEnabled, usageWarnings } from '../utils/utils'; +import type { AxiosLargeResponse, AxiosLargeResponseRequestOptions } from '../types'; +import { NAMESPACE, getOptions, resolveLargePayload, usageWarnings } from '../utils/utils'; /** * This is the main function that adds the interceptors to the axios instance. @@ -24,42 +24,14 @@ const axiosLargeResponse: AxiosLargeResponse = (axiosInstance, globalOptions) => // response interceptor const responseInterceptorId = axiosInstance.interceptors.response.use(async (response) => { - const configRequestOptions = response?.config?.[NAMESPACE]; - const { debug, logger, headerFlag, refProperty, onFetchLargePayloadFromRef, enabled, errorPayload } = getOptions( - configRequestOptions, - globalOptions, - ); + const options = getOptions(response?.config?.[NAMESPACE], globalOptions); - if (!enabled) { + if (!options.enabled) { return response; } - if ( - response.headers['content-type'] === headerFlag && - response.data && - (response.data as LargePayloadResponse)[refProperty] - ) { - if (isDebugEnabled(debug)) { - logger.debug('[axios-large-response] Fetching large payload from ref url', { - ref: (response.data as LargePayloadResponse)[refProperty], - }); - } - try { - response.data = await onFetchLargePayloadFromRef((response.data as LargePayloadResponse)[refProperty]); - } catch (error) { - logger.error('[axios-large-response] Error fetching large payload from ref url', { - reason: error instanceof Error ? error.message : 'unknown', - }); - - if (errorPayload) { - response.data = errorPayload; + await resolveLargePayload(response, options); - return response; - } - - throw error; - } - } return response; }); diff --git a/packages/axios-large-response/src/runner/large-response-runner.test.ts b/packages/axios-large-response/src/runner/large-response-runner.test.ts new file mode 100644 index 0000000..3d0b2e3 --- /dev/null +++ b/packages/axios-large-response/src/runner/large-response-runner.test.ts @@ -0,0 +1,587 @@ +import { AxiosHeaders, type AxiosRequestConfig, type AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosLargeResponseOptions } from '../types'; +import { LARGE_PAYLOAD_MIME_TYPE, NAMESPACE } from '../utils/utils'; +import { withLargeResponse } from './large-response-runner'; + +/** + * Test suite for the withLargeResponse runner wrapper. + * + * The runner is described structurally, so a plain object stands in for any + * transport-specific runner. + */ +describe('withLargeResponse', () => { + let runRequest: ReturnType; + let globalOptions: Required; + + /** + * Setup a bare runner and global options. Before each test. + */ + beforeEach(() => { + runRequest = vi.fn().mockResolvedValue({ status: 200, headers: {}, data: { foo: 'bar' } }); + globalOptions = { + enabled: true, + disableWarnings: true, + headerFlag: LARGE_PAYLOAD_MIME_TYPE, + refProperty: '$payload_ref', + debug: false, + logger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + onFetchLargePayloadFromRef: vi.fn().mockResolvedValue({ huge: 'data' }), + errorPayload: undefined, + }; + }); + + /** + * The runner's other members are part of its contract and must survive wrapping: + * openapi-client-axios invokes a registered runner as + * `runner.runRequest(request, operation, runner.context)`. + */ + it('should preserve other members of the wrapped runner', () => { + // given + const context = { functionName: 'my-lambda' }; + + // when + const wrapped = withLargeResponse({ runRequest, context }, globalOptions); + + // then + expect(wrapped.context).toEqual(context); + }); + + /** + * A frozen or sealed runner contributes a non-configurable `runRequest` descriptor, so + * the replacement has to be overridden as the copy is built rather than redefined on it. + */ + it.each([ + ['frozen', Object.freeze], + ['sealed', Object.seal], + ])('should wrap a %s runner', async (_label, harden) => { + // given + const context = { functionName: 'my-lambda' }; + const runner = harden({ runRequest, context }); + + // when + const wrapped = withLargeResponse(runner, globalOptions); + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(wrapped.context).toEqual(context); + expect(response.data).toEqual({ foo: 'bar' }); + }); + + /** + * A concretely typed runner, unlike a vi.fn() double, actually exercises the generic + * signature: a runner declaring narrower request/response types must still be accepted, + * and its sibling members must survive wrapping at the type level, not just at runtime. + */ + it('should accept a concretely typed runner and keep its members typed', async () => { + // given + type Operation = { operationId: string }; + type LambdaContext = { functionName: string }; + + const lambdaRunner = { + context: { functionName: 'my-lambda' } satisfies LambdaContext, + runRequest: async ( + _request: AxiosRequestConfig, + _operation: Operation, + _context: LambdaContext, + ): Promise => + ({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }) as unknown as AxiosResponse, + }; + + // when + const wrapped = withLargeResponse(lambdaRunner, globalOptions); + const functionName: string = wrapped.context.functionName; + const response = await wrapped.runRequest({}, { operationId: 'getThings' }, wrapped.context); + + // then + expect(functionName).toEqual('my-lambda'); + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * The middleware matches Accept by exact value. + */ + it('should advertise the flag on Accept and drop any existing casing variant', async () => { + // given + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + await wrapped.runRequest({ headers: { accept: 'application/json', authorization: 'Bearer x' } }); + + // then + const forwarded = runRequest.mock.calls[0][0]; + + expect(forwarded.headers.Accept).toEqual(LARGE_PAYLOAD_MIME_TYPE); + expect(forwarded.headers.accept).toBeUndefined(); + expect(forwarded.headers.authorization).toEqual('Bearer x'); + }); + + /** + * Trailing arguments belong to the wrapped runner and must pass through untouched. + */ + it('should forward trailing arguments to the wrapped runner', async () => { + // given + const wrapped = withLargeResponse({ runRequest }, globalOptions); + const operation = { operationId: 'getThings' }; + const context = { functionName: 'my-lambda' }; + + // when + await wrapped.runRequest({ headers: {} }, operation, context); + + // then + expect(runRequest.mock.calls[0][1]).toBe(operation); + expect(runRequest.mock.calls[0][2]).toBe(context); + }); + + /** + * Normal responses should pass through unchanged. + */ + it('should allow normal responses to pass through unchanged', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { foo: 'bar' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(response.data).toEqual({ foo: 'bar' }); + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + }); + + /** + * Large responses should be resolved from the ref. + */ + it('should resolve a large response from the payload ref', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(globalOptions.onFetchLargePayloadFromRef).toHaveBeenCalledWith('https://bucket.s3.amazonaws.com/ref'); + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * Response header casing is not guaranteed across transports: a raw lambda-proxy + * response carries whatever casing the handler produced. + */ + it('should detect the flag regardless of response header casing', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'Content-Type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * A charset parameter on the content type must not stop the ref being resolved. + */ + it('should detect the flag when the content type carries parameters', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': `${LARGE_PAYLOAD_MIME_TYPE}; charset=utf-8` }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * An envelope-looking response without a ref is not a large response. + */ + it('should leave the response alone when the ref is missing', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { message: 'too large, but no ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + expect(response.data).toEqual({ message: 'too large, but no ref' }); + }); + + /** + * A failed ref fetch propagates, unless an errorPayload is configured. + */ + it('should propagate a failed ref fetch', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const error = new Error('s3 is having a day'); + globalOptions.onFetchLargePayloadFromRef = vi.fn().mockRejectedValue(error); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when / then + await expect(wrapped.runRequest({ headers: {} })).rejects.toThrow(error); + expect(globalOptions.logger.error).toHaveBeenCalled(); + }); + + /** + * With an errorPayload configured, a failed ref fetch degrades instead of throwing. + */ + it('should fall back to the errorPayload when the ref fetch fails', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + globalOptions.onFetchLargePayloadFromRef = vi.fn().mockRejectedValue(new Error('nope')); + globalOptions.errorPayload = { degraded: true }; + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ headers: {} }); + + // then + expect(response.data).toEqual({ degraded: true }); + }); + + /** + * Errors raised by the wrapped runner itself are not this wrapper's business. + */ + it('should propagate errors from the wrapped runner', async () => { + // given + const error = new Error('Request failed with status code 404'); + + runRequest.mockRejectedValue(error); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when / then + await expect(wrapped.runRequest({ headers: {} })).rejects.toThrow(error); + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + }); + + /** + * Disabled means untouched - no wrapper, no Accept header, no overhead. + */ + it('should return the original runner when disabled', async () => { + // given + const runner = { runRequest, context: { functionName: 'my-lambda' } }; + + // when + const wrapped = withLargeResponse(runner, { ...globalOptions, enabled: false }); + await wrapped.runRequest({ headers: { accept: 'application/json' } }); + + // then + expect(wrapped).toBe(runner); + expect(runRequest.mock.calls[0][0].headers).toEqual({ accept: 'application/json' }); + }); + + /** + * A request without headers is still given the flag. + */ + it('should add the flag to a request that has no headers', async () => { + // given + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + await wrapped.runRequest({}); + + // then + expect(runRequest.mock.calls[0][0].headers.Accept).toEqual(LARGE_PAYLOAD_MIME_TYPE); + }); + + /** + * A class-based runner must keep working: `runRequest` stays bound to the original, and + * prototype methods and class identity survive. Arrow-function doubles never catch this. + */ + it('should keep a class-based runner working, with its prototype intact', async () => { + // given + class LambdaRunner { + constructor(public context: { functionName: string }) {} + + async runRequest(_request: unknown) { + return { + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: `https://bucket.s3.amazonaws.com/${this.context.functionName}` }, + }; + } + + getFunctionName() { + return this.context.functionName; + } + } + + const runner = new LambdaRunner({ functionName: 'my-lambda' }); + + // when + const wrapped = withLargeResponse(runner, globalOptions); + const response = await wrapped.runRequest({}); + + // then + expect(wrapped.getFunctionName()).toEqual('my-lambda'); + expect(wrapped instanceof LambdaRunner).toBe(true); + expect(globalOptions.onFetchLargePayloadFromRef).toHaveBeenCalledWith('https://bucket.s3.amazonaws.com/my-lambda'); + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * Header containers need not store values as own enumerable properties - a WHATWG + * `Headers` from a fetch-based runner exposes them only through `entries()`. + */ + it('should detect the flag on a WHATWG Headers container', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': LARGE_PAYLOAD_MIME_TYPE }), + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * The counterpart to the WHATWG case, and the one that actually ships: axios' own + * `AxiosHeaders` keeps values as own enumerable properties and has no `entries()`, so it + * must fall through to `Object.entries`. Pinned against the real type rather than a plain + * object, so an axios release that adds `entries()` fails here instead of silently + * changing which branch runs. + */ + it('should detect the flag on an AxiosHeaders container', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: AxiosHeaders.from({ 'content-type': LARGE_PAYLOAD_MIME_TYPE }), + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * A `Map` is the other shape that hides its values behind `entries()`. + */ + it('should detect the flag on a Map container', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: new Map([['content-type', LARGE_PAYLOAD_MIME_TYPE]]), + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * Own property descriptors are carried over, not just own enumerable values, so a runner + * member hidden from enumeration still survives wrapping. + */ + it('should preserve a non-enumerable member of the wrapped runner', () => { + // given + const runner: { runRequest: typeof runRequest; hidden?: string } = { runRequest }; + + Object.defineProperty(runner, 'hidden', { value: 'kept', enumerable: false }); + + // when + const wrapped = withLargeResponse(runner, globalOptions); + + // then + expect(wrapped.hidden).toEqual('kept'); + expect(Object.keys(wrapped)).not.toContain('hidden'); + }); + + /** + * Deliberate asymmetry with the interceptor, pinned so it stays a decision rather than a + * surprise: the interceptor can be disabled globally and enabled per request, but a + * disabled wrapper is never installed, so there is nothing left to read the request. + * Wrap with `enabled: true` and opt individual requests out instead. + */ + it('should not let a per-request enabled re-enable a globally disabled wrapper', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, { ...globalOptions, enabled: false }); + + // when + const response = await wrapped.runRequest({ [NAMESPACE]: { enabled: true } }); + + // then + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + expect(response.data).toEqual({ $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }); + }); + + /** + * A runner that does not parse the proxy body leaves the envelope as a JSON string; + * treating that as "not an envelope" would skip the fetch in the case this exists for. + */ + it('should resolve an envelope the transport left as an unparsed JSON string', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: JSON.stringify({ $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }), + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + /** + * `errorPayload` is "configured" whenever it is not undefined - a falsy value is a + * deliberate degraded payload, not an absent option. + */ + it('should degrade to a falsy errorPayload rather than throwing', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + globalOptions.onFetchLargePayloadFromRef = vi.fn().mockRejectedValue(new Error('s3 down')); + globalOptions.errorPayload = null; + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toBeNull(); + }); + + /** + * `headerFlag` is a caller-supplied option: it may arrive with media-type parameters, and + * it may be present but undefined (an unset env var spread over the defaults). + */ + it('should match a headerFlag that carries media type parameters', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse( + { runRequest }, + { + ...globalOptions, + headerFlag: `${LARGE_PAYLOAD_MIME_TYPE}; charset=utf-8`, + }, + ); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(response.data).toEqual({ huge: 'data' }); + }); + + it('should pass responses through when headerFlag is undefined, rather than throwing', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: {}, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, { ...globalOptions, headerFlag: undefined }); + + // when + const response = await wrapped.runRequest({}); + + // then + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + expect(response.data).toEqual({ $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }); + }); + + /** + * The runner honours the same per-request options channel as the interceptor, and strips + * it from the forwarded request so it never reaches the transport as payload. + */ + it('should honour per-request options and strip them from the forwarded request', async () => { + // given + runRequest.mockResolvedValue({ + status: 200, + headers: { 'content-type': LARGE_PAYLOAD_MIME_TYPE }, + data: { $payload_ref: 'https://bucket.s3.amazonaws.com/ref' }, + }); + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + const response = await wrapped.runRequest({ + [NAMESPACE]: { onFetchLargePayloadFromRef: vi.fn().mockResolvedValue({ perRequest: true }) }, + }); + + // then + expect(response.data).toEqual({ perRequest: true }); + expect(globalOptions.onFetchLargePayloadFromRef).not.toHaveBeenCalled(); + expect(Object.keys(runRequest.mock.calls[0][0])).toEqual(['headers']); + }); + + /** + * A per-request `enabled: false` opts a single request out, as on the interceptor path. + */ + it('should let a per-request enabled false opt out of a single request', async () => { + // given + const wrapped = withLargeResponse({ runRequest }, globalOptions); + + // when + await wrapped.runRequest({ headers: { accept: 'application/json' }, [NAMESPACE]: { enabled: false } }); + + // then + expect(runRequest.mock.calls[0][0].headers).toEqual({ accept: 'application/json' }); + }); +}); diff --git a/packages/axios-large-response/src/runner/large-response-runner.ts b/packages/axios-large-response/src/runner/large-response-runner.ts new file mode 100644 index 0000000..b1fd30d --- /dev/null +++ b/packages/axios-large-response/src/runner/large-response-runner.ts @@ -0,0 +1,106 @@ +import type { + AxiosLargeResponseOptions, + AxiosLargeResponseRequestOptions, + LargeResponseRunner, + RunnerRequest, + RunnerResponse, + WithObjectRequest, +} from '../types'; +import { NAMESPACE, getOptions, resolveLargePayload, usageWarnings, withAcceptHeader } from '../utils/utils'; + +/** + * Adds large-response support to a transport runner, for clients that do not dispatch + * through an axios instance. + * + * Interceptors only run for requests that go through the axios adapter. A client + * configured with its own runner - `openapi-client-axios`' `registerRunner`, for instance - + * bypasses that adapter entirely, so `axiosLargeResponse` never sees those requests and + * responses over the transport's payload limit fail with a 413. Wrapping the runner closes + * that gap. See the README for the full rationale. + * + * The returned runner keeps the original's prototype and own properties, so class + * instances, prototype methods and sibling properties all survive - `openapi-client-axios` + * invokes a registered runner as `runner.runRequest(request, operation, runner.context)`, + * and the lambda runner reads the target function name off that `context`. `runRequest` + * stays bound to the original runner, so a method that reads `this` still works. + * + * Unlike the interceptor, `enabled` is resolved once when the runner is wrapped, since + * that decision is what determines whether to wrap at all; when disabled the original + * runner is returned untouched. Every other option is resolved per request, read from a + * `[NAMESPACE]` key on the request just as the interceptor reads it off the axios config. + * + * That one difference is worth knowing: a per-request `enabled: false` opts a request out, + * but a global `enabled: false` cannot be re-enabled per request the way it can on the + * interceptor - there is no wrapper left to read the request. Wrap with `enabled: true` and + * opt individual requests out, rather than the other way round. + * + * @example + * ```ts + * client.api.registerRunner( + * withLargeResponse(getLambdaRunner(lambdaName, context), { enabled: true }), + * ); + * ``` + */ +const withLargeResponse = ( + runner: TRunner & WithObjectRequest, + globalOptions?: AxiosLargeResponseOptions, +): TRunner => { + // check for warnings + usageWarnings(globalOptions); + + if (!getOptions(undefined, globalOptions).enabled) { + return runner; + } + + // `LargeResponseRunner` accepts any runner shape, so its `runRequest` is not callable as + // declared; the wrapper handles requests and responses structurally instead. Bound to the + // runner so a `runRequest` that reads `this` keeps working. + const dispatch = runner.runRequest.bind(runner) as unknown as ( + request: RunnerRequest, + ...rest: unknown[] + ) => Promise; + + const runRequest = async (request: RunnerRequest, ...rest: unknown[]) => { + // the per-request options channel the interceptor reads off the axios config; stripped + // from the forwarded request so it never reaches the transport as payload + const { [NAMESPACE]: requestOptions, ...forwarded } = (request ?? {}) as RunnerRequest & { + [NAMESPACE]?: AxiosLargeResponseRequestOptions; + }; + + const options = getOptions(requestOptions, globalOptions); + + // a per-request `enabled: false` opts this one request out, as it does on the + // interceptor path + if (!options.enabled) { + return dispatch(forwarded, ...rest); + } + + const response = await dispatch( + { ...forwarded, headers: withAcceptHeader(forwarded.headers, options.headerFlag) }, + ...rest, + ); + + await resolveLargePayload(response, options); + + return response; + }; + + // own property descriptors and prototype are carried over so class-based runners keep + // their methods and their identity; `runRequest` is defined as an own property, which + // shadows a prototype method of the same name. + // + // It is overridden in the descriptor map rather than redefined afterwards: a frozen or + // sealed runner contributes a non-configurable `runRequest` descriptor, and redefining + // that on the copy throws. Building the map first also leaves the original untouched. + return Object.create(Object.getPrototypeOf(runner), { + ...Object.getOwnPropertyDescriptors(runner), + runRequest: { + value: runRequest, + writable: true, + enumerable: true, + configurable: true, + }, + }) as TRunner; +}; + +export { withLargeResponse }; diff --git a/packages/axios-large-response/src/types.ts b/packages/axios-large-response/src/types.ts index a32c9a3..4a4e0d2 100644 --- a/packages/axios-large-response/src/types.ts +++ b/packages/axios-large-response/src/types.ts @@ -34,10 +34,62 @@ type AxiosLargeResponse = ( responseInterceptorId: number; }; +/** + * The only part of an outgoing request the runner wrapper touches. + */ +type RunnerRequest = { + headers?: unknown; +}; + +/** + * The only part of a response the runner wrapper touches. + */ +type RunnerResponse = { + headers?: unknown; + data?: unknown; +}; + +/** + * Any transport that turns a request into a response. + * + * This intentionally describes nothing about *how* the request is dispatched - HTTP, an + * AWS Lambda invoke, an in-process call or a test double all satisfy it. `never` parameters + * are what make that true: a parameter list is checked contravariantly, so naming a request + * type here (even one as loose as `{ headers?: unknown }`) would *reject* every runner that + * declares a narrower one - including the `AxiosRequestConfig`-typed runners this wrapper + * exists to support. + */ +type LargeResponseRunner = { + runRequest: (...args: never[]) => Promise; +}; + +/** + * `TRunner` unless its request is a primitive, in which case an error-shaped type. + * + * `LargeResponseRunner` has to accept any parameter list (see above), which on its own also + * admits runners the wrapper cannot drive - it rewrites headers via `{ ...request }`, so a + * `runRequest(url: string, init)` would receive a spread of the string's indices instead of + * a URL. Inferring the request position separately restores that guarantee without putting + * a named type in the contravariant slot. + * + * The test is "is it a primitive", not "does it extend object", so that a runner declaring + * a deliberately permissive request (`unknown`, `any`) is still accepted - only shapes that + * genuinely cannot be spread are turned away. + */ +type WithObjectRequest = TRunner extends { runRequest: (request: infer TRequest, ...rest: never[]) => unknown } + ? TRequest extends string | number | boolean | bigint | symbol | null | undefined + ? { runRequest: 'withLargeResponse requires a runner whose first argument is an object' } + : TRunner + : TRunner; + export type { AxiosLargeResponse, AxiosLargeResponseOptions, AxiosLargeResponseRequestOptions, LargePayloadResponse, + LargeResponseRunner, Logger, + RunnerRequest, + RunnerResponse, + WithObjectRequest, }; diff --git a/packages/axios-large-response/src/utils/utils.test.ts b/packages/axios-large-response/src/utils/utils.test.ts index e81b2fa..5c0f4c9 100644 --- a/packages/axios-large-response/src/utils/utils.test.ts +++ b/packages/axios-large-response/src/utils/utils.test.ts @@ -83,6 +83,42 @@ describe('getOptions', () => { const options = getOptions(); expect(options).toEqual(DEFAULT_OPTIONS); }); + + /** + * A key present with an undefined value means "not specified". Letting it shadow the + * default leaves the merged options claiming a type they do not have - an undefined + * `logger` then throws while reporting a failed ref fetch and masks the original error. + */ + it('should not let explicitly undefined options shadow the defaults', () => { + const options = getOptions(undefined, { + enabled: true, + headerFlag: undefined, + logger: undefined, + refProperty: undefined, + onFetchLargePayloadFromRef: undefined, + }); + + expect(options.headerFlag).toEqual(DEFAULT_OPTIONS.headerFlag); + expect(options.logger).toBe(DEFAULT_OPTIONS.logger); + expect(options.refProperty).toEqual(DEFAULT_OPTIONS.refProperty); + expect(options.onFetchLargePayloadFromRef).toBe(DEFAULT_OPTIONS.onFetchLargePayloadFromRef); + expect(options.enabled).toBe(true); + }); + + /** + * The same applies per request: `enabled: undefined` must not silently disable a + * globally-enabled client, while an explicit `false` still must. + */ + it('should not let an undefined per-request option shadow a global one', () => { + const globalOptions = { enabled: true, refProperty: 'global_ref' } satisfies AxiosLargeResponseOptions; + + expect(getOptions({ enabled: undefined, refProperty: undefined }, globalOptions)).toMatchObject({ + enabled: true, + refProperty: 'global_ref', + }); + + expect(getOptions({ enabled: false }, globalOptions).enabled).toBe(false); + }); }); describe('usageWarnings', () => { diff --git a/packages/axios-large-response/src/utils/utils.ts b/packages/axios-large-response/src/utils/utils.ts index 5ae33b9..a0c53db 100644 --- a/packages/axios-large-response/src/utils/utils.ts +++ b/packages/axios-large-response/src/utils/utils.ts @@ -1,5 +1,10 @@ import axios from 'axios'; -import type { AxiosLargeResponseOptions, AxiosLargeResponseRequestOptions } from '../types'; +import type { + AxiosLargeResponseOptions, + AxiosLargeResponseRequestOptions, + LargePayloadResponse, + RunnerResponse, +} from '../types'; const DEBUG_ENV_VAR = 'AXIOS_INTERCEPTOR_LARGE_RESPONSE_DEBUG'; @@ -29,6 +34,20 @@ export const DEFAULT_OPTIONS: Required = { disableWarnings: false, }; +/** + * A key present with an `undefined` value means "not specified", so it must not shadow the + * layer beneath it. + * + * Plain spreading does let it shadow, which is how `{ headerFlag: process.env.UNSET }` or a + * spread of a partial config ends up overriding a default with `undefined` - leaving the + * merged options claiming a type they do not have. The consequences are real: an undefined + * `logger` throws while reporting a failed ref fetch and masks the original error, an + * undefined `onFetchLargePayloadFromRef` is not callable, and a per-request + * `enabled: undefined` silently disables large-response handling for that request. + */ +const definedOnly = (options: TOptions | undefined) => + Object.fromEntries(Object.entries(options ?? {}).filter(([, value]) => value !== undefined)) as Partial; + /** * This function merges the global options with the config request options. * If the config request options are not provided, it will use the global options. @@ -40,14 +59,173 @@ const getOptions = ( ) => { return { ...DEFAULT_OPTIONS, - ...globalOptions, - ...configRequestOptions, + ...definedOnly(globalOptions), + ...definedOnly(configRequestOptions), } satisfies AxiosLargeResponseOptions; }; const NAMESPACE = 'axios-large-response'; -export { LARGE_PAYLOAD_MIME_TYPE, NAMESPACE, fetchLargePayloadFromS3Ref, getOptions, isDebugEnabled }; +/** + * Header bags reach us as `unknown`: they may come from axios, from a raw lambda-proxy + * response, or from a custom transport, so every read narrows through here first. + * + * `Object.entries` alone is not enough. A WHATWG `Headers` (fetch/undici) or a `Map` keeps + * its values off own enumerable properties, so it reads as empty and the envelope silently + * goes undetected; both expose `entries()` instead. Plain objects and axios' `AxiosHeaders` + * (own enumerable properties, no `entries()`) still go through `Object.entries`. + */ +const entriesOf = (value: unknown): [string, unknown][] => { + if (!value || typeof value !== 'object') { + return []; + } + + const bag = value as { entries?: () => Iterable<[unknown, unknown]> }; + + return typeof bag.entries === 'function' + ? Array.from(bag.entries(), ([key, entry]): [string, unknown] => [String(key), entry]) + : Object.entries(value); +}; + +/** + * Case-insensitive header lookup. Header casing is not guaranteed across transports: + * axios lowercases response headers, while a raw lambda-proxy response carries whatever + * casing the handler produced. + */ +const getHeader = (headers: unknown, name: string): string | undefined => { + const target = name.toLowerCase(); + const value = entriesOf(headers).find(([key]) => key.toLowerCase() === target)?.[1]; + + return typeof value === 'string' ? value : undefined; +}; + +/** + * The media type with any parameters stripped, so a `; charset=utf-8` suffix still + * matches the flag. + */ +const getMediaType = (contentType: string | undefined) => (contentType ?? '').split(';')[0].trim().toLowerCase(); + +/** + * Returns the headers with the large-response flag advertised on `Accept`. + * + * The middleware matches `Accept` by exact value, so any existing casing variant is + * dropped first - otherwise two variants travel together and the wrong one may win. + * The axios request interceptor needs no equivalent: assigning `Accept` on an + * `AxiosHeaders` instance collapses a pre-existing `accept` for us. + */ +const withAcceptHeader = (headers: unknown, headerFlag: string): Record => { + const next: Record = {}; + + for (const [key, value] of entriesOf(headers)) { + if (key.toLowerCase() !== 'accept') { + next[key] = value; + } + } + + next.Accept = headerFlag; + + return next; +}; + +/** + * The envelope body, parsed if the transport left it as an unparsed JSON string. + * + * Axios parses response bodies for us, but a runner need not: a lambda-invoke runner that + * only parses proxy bodies it recognises as JSON hands us the envelope verbatim, and + * treating that as "not an envelope" would skip the ref fetch in exactly the case this + * package exists for. + */ +const parseEnvelope = (data: unknown): object | undefined => { + if (data && typeof data === 'object') { + return data; + } + + if (typeof data !== 'string') { + return undefined; + } + + try { + const parsed = JSON.parse(data); + + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +}; + +/** + * The payload ref when the response is a large-response envelope, `undefined` otherwise. + * + * Both sides of the content-type comparison are normalised: the response side because + * transports append parameters (`; charset=utf-8`), and `headerFlag` because it is a + * caller-supplied option that may carry parameters of its own or be absent entirely. + */ +const resolvePayloadRef = (response: RunnerResponse, headerFlag: string, refProperty: string): string | undefined => { + const flagMediaType = getMediaType(headerFlag); + + // an absent flag must never match an absent content-type, which both normalise to '' + if (!flagMediaType || getMediaType(getHeader(response?.headers, 'content-type')) !== flagMediaType) { + return undefined; + } + + const envelope = parseEnvelope(response.data); + + if (!envelope) { + return undefined; + } + + const ref = (envelope as LargePayloadResponse)[refProperty]; + + return typeof ref === 'string' ? ref : undefined; +}; + +/** + * Replaces a large-response envelope's `data` with the payload behind its ref, in place. + * A no-op for any response that is not an envelope. + * + * This is the whole large-response policy - detection, debug logging, and whether a failed + * fetch degrades to `errorPayload` or throws - so the interceptor and the runner wrapper + * share it rather than each carrying their own copy that can drift. + */ +const resolveLargePayload = async (response: RunnerResponse, options: Required) => { + const { debug, logger, headerFlag, refProperty, onFetchLargePayloadFromRef, errorPayload } = options; + + const payloadRef = resolvePayloadRef(response, headerFlag, refProperty); + + if (!payloadRef) { + return; + } + + if (isDebugEnabled(debug)) { + logger.debug('[axios-large-response] Fetching large payload from ref url', { ref: payloadRef }); + } + + try { + response.data = await onFetchLargePayloadFromRef(payloadRef); + } catch (error) { + logger.error('[axios-large-response] Error fetching large payload from ref url', { + reason: error instanceof Error ? error.message : 'unknown', + }); + + // `undefined` means "not configured"; any other value, falsy ones included, is a + // deliberate degraded payload + if (errorPayload === undefined) { + throw error; + } + + response.data = errorPayload; + } +}; + +export { + LARGE_PAYLOAD_MIME_TYPE, + NAMESPACE, + fetchLargePayloadFromS3Ref, + getOptions, + isDebugEnabled, + resolveLargePayload, + withAcceptHeader, +}; export const usageWarnings = (options: AxiosLargeResponseOptions | undefined) => { if (options?.disableWarnings) { diff --git a/packages/axios-large-response/tsconfig.json b/packages/axios-large-response/tsconfig.json index 71670bb..7b86540 100644 --- a/packages/axios-large-response/tsconfig.json +++ b/packages/axios-large-response/tsconfig.json @@ -22,5 +22,5 @@ "allowSyntheticDefaultImports": true }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"] + "exclude": ["node_modules", "dist"] }