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
41 changes: 37 additions & 4 deletions packages/axios-large-response/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/axios-large-response/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/axios-large-response/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { axiosLargeResponse } from './interceptor/axios-interceptor';
export { withLargeResponse } from './runner/large-response-runner';
export * from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -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<AxiosLargeResponseOptions>> = {},
): Required<AxiosLargeResponseOptions> => ({
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<string, string>),
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<string, string> };
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);
});
});
38 changes: 5 additions & 33 deletions packages/axios-large-response/src/interceptor/axios-interceptor.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
});

Expand Down
Loading
Loading