Skip to content

Commit 8a60cd2

Browse files
authored
Merge pull request #65 from Cimpress-MCP/mh/7.0.0-beta_AxiosAndLoggingChanges
7.0.0-beta
2 parents c0d037e + c99434b commit 8a60cd2

File tree

17 files changed

+3594
-3869
lines changed

17 files changed

+3594
-3869
lines changed

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
yarn lint-staged

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66

7+
## [7.0.0-beta] - 2025-11-14
8+
9+
### Changed
10+
11+
- Add 429 status code mapping
12+
- Changed logging format. Removed the inner `message` object.
13+
- Update packages
14+
- Use axios-cache-interceptor instead of axios-cache-adapter
15+
716
## [6.1.1] - 2025-10-27
817

918
### Changed

package.json

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lambda-essentials-ts",
3-
"version": "6.1.1",
3+
"version": "7.0.0-beta",
44
"description": "A selection of the finest modules supporting authorization, API routing, error handling, logging and sending HTTP requests.",
55
"main": "lib/index.js",
66
"private": false,
@@ -26,30 +26,33 @@
2626
},
2727
"homepage": "https://github.com/Cimpress-MCP/lambda-essentials-ts#readme",
2828
"dependencies": {
29-
"@aws-sdk/client-kms": "^3.569.0",
30-
"@aws-sdk/client-secrets-manager": "^3.569.0",
31-
"axios": "~0.21.3",
32-
"axios-cache-adapter": "~2.7.3",
33-
"fast-safe-stringify": "~2.0.7",
29+
"@aws-sdk/client-kms": "^3.930.0",
30+
"@aws-sdk/client-secrets-manager": "^3.930.0",
31+
"@types/node": "^24.10.1",
32+
"axios": "1.13.2",
33+
"axios-cache-interceptor": "^1.8.3",
34+
"fast-safe-stringify": "~2.1.1",
3435
"is-error": "~2.2.2",
35-
"jsonwebtoken": "9.0.0",
36+
"jsonwebtoken": "9.0.2",
3637
"md5": "~2.3.0",
3738
"openapi-factory": "5.4.60",
38-
"retry-axios": "~2.6.0",
39-
"uuid": "~8.3.2"
39+
"retry-axios": "~3.2.1",
40+
"uuid": "~11.1.0"
4041
},
4142
"devDependencies": {
42-
"@types/jest": "^26.0.20",
43-
"@types/newrelic": "^9.14.0",
44-
"eslint": "^7.18.0",
45-
"eslint-config-cimpress-atsquad": "^2.1.2",
46-
"husky": "^4.2.5",
47-
"jest": "^26.2.2",
48-
"lint-staged": "^10.2.13",
49-
"prettier": "^2.7.1",
50-
"ts-jest": "^26.1.4",
51-
"ts-node": "^10.9.1",
52-
"typescript": "^4.8.2"
43+
"@types/jest": "^29.5.14",
44+
"@types/newrelic": "^9.14.8",
45+
"redis": "^5.9.0",
46+
"axios-mock-adapter": "^2.1.0",
47+
"eslint": "^8.57.1",
48+
"eslint-config-cimpress-atsquad": "^2.2.0-beta",
49+
"husky": "^9.1.7",
50+
"jest": "^29.7.0",
51+
"lint-staged": "^15.5.2",
52+
"prettier": "^2.8.8",
53+
"ts-jest": "^29.4.5",
54+
"ts-node": "^10.9.2",
55+
"typescript": "^5.9.3"
5356
},
5457
"eslintConfig": {
5558
"extends": "cimpress-atsquad"
@@ -74,9 +77,9 @@
7477
"collectCoverage": false
7578
},
7679
"engines": {
77-
"node": ">=14.0.0"
80+
"node": ">=20.0.0"
7881
},
7982
"peerDependencies": {
80-
"newrelic": "^10.2.0"
83+
"newrelic": "^12.0.0"
8184
}
8285
}

src/exceptions/clientException.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class ClientException extends Exception {
1111
403: 403,
1212
404: 422,
1313
422: 422,
14+
429: 429,
1415
};
1516

1617
constructor(

src/httpClient/deduplicateRequestAdapter.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/httpClient/httpClient.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import * as uuid from 'uuid';
2-
import { URL } from 'url';
32
import axios, {
4-
AxiosAdapter,
53
AxiosError,
4+
AxiosHeaderValue,
65
AxiosInstance,
76
AxiosRequestConfig,
87
AxiosResponse,
8+
RawAxiosRequestHeaders,
99
} from 'axios';
10-
import { IAxiosCacheAdapterOptions, setupCache } from 'axios-cache-adapter';
1110
import * as rax from 'retry-axios';
1211
import { RetryConfig } from 'retry-axios';
1312
import md5 from 'md5';
1413
import { safeJwtCanonicalIdParse, safeJsonParse, serializeAxiosError } from '../util';
1514
import { InternalException } from '../exceptions/internalException';
1615
import { ClientException } from '../exceptions/clientException';
1716
import { orionCorrelationIdRoot } from '../shared';
18-
import { createDebounceRequestAdapter } from './deduplicateRequestAdapter';
17+
import { CacheOptions, setupCache } from 'axios-cache-interceptor';
1918

2019
const invalidToken: string = 'Invalid token';
2120

@@ -60,29 +59,22 @@ export default class HttpClient {
6059
this.enableRetry = options?.enableRetry ?? false;
6160
this.timeout = options?.timeout;
6261
this.clientExceptionStatusCodeMapOverride = options?.clientExceptionStatusCodeMapOverride;
63-
this.client =
64-
options?.client ??
65-
axios.create({
66-
adapter: (() => {
67-
let adapters = axios.defaults.adapter as AxiosAdapter;
68-
if (this.enableCache) {
69-
const cache = setupCache({
70-
maxAge: 5 * 60 * 1000, // all items are cached for 5 minutes
71-
readHeaders: false, // ignore cache control headers in favor of the static 5 minutes
72-
readOnError: true,
73-
exclude: {
74-
query: false, // also cache requests with query parameters
75-
},
76-
...options?.cacheOptions, // allow to overwrite the defaults except of cache-key
77-
key: (req) => HttpClient.generateCacheKey(req),
78-
});
79-
80-
// debounce concurrent calls with the same cacheKey so that only one HTTP request is made
81-
adapters = createDebounceRequestAdapter(cache.adapter, HttpClient.generateCacheKey);
82-
}
83-
return adapters;
84-
})(),
62+
this.client = options?.client ?? axios.create();
63+
64+
if (this.enableCache) {
65+
setupCache(this.client, {
66+
// 5 minutes TTL
67+
ttl: 5 * 60 * 1000,
68+
// Respect cache-control headers and have ttl as a fallback https://axios-cache-interceptor.js.org/config/request-specifics#cache-interpretheader
69+
interpretHeader: true,
70+
// Serve stale cache on error
71+
staleIfError: true,
72+
// Use custom cache key to include auth, url, params and body hash
73+
generateKey: (req) => HttpClient.generateCacheKey(req),
74+
// Allow overriding defaults via provided options
75+
...options?.cacheOptions,
8576
});
77+
}
8678

8779
if (this.enableRetry) {
8880
this.client.defaults.raxConfig = {
@@ -224,15 +216,15 @@ export default class HttpClient {
224216
* Resolves the token with the token provider and adds it to the headers
225217
*/
226218
async createHeadersWithResolvedToken(
227-
headers: Record<string, string> = {},
228-
): Promise<Record<string, string>> {
229-
const newHeaders: Record<string, string> = {};
219+
headers?: RawAxiosRequestHeaders | { [key: string]: AxiosHeaderValue } | Record<string, string>,
220+
): Promise<{ [p: string]: AxiosHeaderValue }> {
221+
const newHeaders: { [key: string]: AxiosHeaderValue } = {};
230222
if (this.correlationIdResolverFunction) {
231223
newHeaders[orionCorrelationIdRoot] = this.correlationIdResolverFunction();
232224
}
233225

234226
if (this.tokenResolverFunction) {
235-
if (headers.Authorization) {
227+
if (headers && headers.Authorization) {
236228
throw new InternalException(
237229
'Authorization header already specified, please create a new HttpClient with a different (or without a) tokenResolver',
238230
);
@@ -243,7 +235,7 @@ export default class HttpClient {
243235
}
244236

245237
return {
246-
...headers,
238+
...(headers as { [key: string]: AxiosHeaderValue }),
247239
...newHeaders,
248240
};
249241
}
@@ -349,10 +341,10 @@ export interface HttpClientOptions {
349341
*/
350342
enableCache?: boolean;
351343
/**
352-
* Cache options
353-
* @link https://github.com/RasCarlito/axios-cache-adapter/blob/master/axios-cache-adapter.d.ts#L26
344+
* Cache options (global defaults for axios-cache-interceptor)
345+
* @link https://axios-cache-interceptor.js.org/config
354346
*/
355-
cacheOptions?: IAxiosCacheAdapterOptions;
347+
cacheOptions?: CacheOptions;
356348
/**
357349
* Enable automatic retries
358350
*/

src/httpClient/redisStorage.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { buildStorage, canStale } from 'axios-cache-interceptor';
2+
import type { StorageValue } from 'axios-cache-interceptor';
3+
// eslint-disable-next-line import/no-extraneous-dependencies
4+
import { createClient } from 'redis';
5+
6+
const KEY_PREFIX = 'axios-cache-';
7+
8+
const MIN_TTL = 60000;
9+
10+
export default function createRedisStorage(client: ReturnType<typeof createClient>) {
11+
// source https://axios-cache-interceptor.js.org/guide/storages#node-redis-storage
12+
return buildStorage({
13+
async find(key) {
14+
const result = await client.get(`${KEY_PREFIX}${key}`);
15+
return result ? (JSON.parse(result) as StorageValue) : undefined;
16+
},
17+
18+
// eslint-disable-next-line complexity
19+
async set(key, value, req) {
20+
await client.set(`${KEY_PREFIX}${key}`, JSON.stringify(value), {
21+
PXAT:
22+
// We don't want to keep indefinitely values in the storage if
23+
// their request don't finish somehow. Either set its value as
24+
// the TTL or 1 minute (MIN_TTL).
25+
value.state === 'loading'
26+
? Date.now() +
27+
(req?.cache && typeof req.cache.ttl === 'number' ? req.cache.ttl : MIN_TTL)
28+
: (value.state === 'stale' && value.ttl) ||
29+
(value.state === 'cached' && !canStale(value))
30+
? value.createdAt + value.ttl!
31+
: // otherwise, we can't determine when it should expire, so we keep
32+
// it indefinitely.
33+
undefined,
34+
});
35+
},
36+
37+
async remove(key) {
38+
await client.del(`${KEY_PREFIX}${key}`);
39+
},
40+
});
41+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { InvalidDataException } from './exceptions/invalidDataException';
3131
import { NotFoundException } from './exceptions/notFoundException';
3232
import { ValidationException } from './exceptions/validationException';
3333
import { serializeObject, serializeAxiosError } from './util';
34+
import RedisStorage from './httpClient/redisStorage';
3435

3536
export {
3637
Logger,
@@ -50,6 +51,7 @@ export {
5051
ClientException,
5152
serializeObject,
5253
serializeAxiosError,
54+
RedisStorage,
5355
};
5456

5557
export type {

src/logger/logger.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,37 @@ import stringify from 'fast-safe-stringify';
55
import isError from 'is-error';
66
import { redactSecret } from '../util';
77

8+
const DEFAULT_PAYLOAD_LIMIT = 32768;
9+
10+
const MIN_PAYLOAD_LIMIT = 10000;
11+
812
export default class Logger {
913
public invocationId: string;
1014

1115
private readonly logFunction: (...data: any[]) => void;
1216

1317
private readonly jsonSpace: number;
1418

19+
private readonly payloadLimit: number;
20+
1521
private staticData: any;
1622

1723
constructor(configuration?: LoggerConfiguration) {
1824
this.logFunction = configuration?.logFunction ?? console.log;
1925
this.jsonSpace = configuration?.jsonSpace ?? 2;
26+
this.payloadLimit = !configuration?.payloadLimit
27+
? DEFAULT_PAYLOAD_LIMIT
28+
: configuration?.payloadLimit < MIN_PAYLOAD_LIMIT
29+
? MIN_PAYLOAD_LIMIT
30+
: configuration.payloadLimit;
2031

2132
this.invocationId = 'none';
2233
}
2334

2435
/**
2536
* Create a new invocation which will end up setting the additional invocation metadata for the request, which will be used when logging.
2637
* @param staticData Any static data that are assigned to every log message. Typical might be an environment parameter or version number.
38+
* @param invocationId
2739
*/
2840
startInvocation(staticData?: any, invocationId?: string): void {
2941
this.staticData = staticData;
@@ -56,7 +68,7 @@ export default class Logger {
5668

5769
const payload = {
5870
invocationId: this.invocationId,
59-
message: messageAsObject,
71+
...messageAsObject,
6072
};
6173

6274
const truncateToken = (innerPayload: string): string => {
@@ -69,15 +81,12 @@ export default class Logger {
6981
const replacer = (key, value) => (isError(value) ? Logger.errorToObject(value) : value);
7082
let stringifiedPayload = truncateToken(stringify(payload, replacer, this.jsonSpace));
7183
stringifiedPayload = redactSecret(stringifiedPayload);
72-
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html 256KB => 32768 characters
73-
if (stringifiedPayload.length >= 32768) {
84+
if (stringifiedPayload.length >= this.payloadLimit) {
7485
const replacementPayload = {
7586
invocationId: this.invocationId,
76-
message: {
77-
title: 'Payload too large',
78-
fields: Object.keys(payload),
79-
truncatedPayload: stringifiedPayload.substring(0, 10000),
80-
},
87+
title: 'Payload too large',
88+
fields: Object.keys(payload),
89+
truncatedPayload: stringifiedPayload.substring(0, this.payloadLimit - 3000),
8190
};
8291
stringifiedPayload = stringify(replacementPayload, replacer, this.jsonSpace);
8392
}
@@ -104,6 +113,14 @@ export interface LoggerConfiguration {
104113
* the number of spaces that are used then stringifying the message.
105114
*/
106115
jsonSpace?: number;
116+
117+
/**
118+
* the limit of a stringified payload in characters above which the payload will be truncated.
119+
* 3000 characters are reserved
120+
* @min 10000
121+
* @default 32768
122+
*/
123+
payloadLimit?: number;
107124
}
108125

109126
export interface SuggestedLogObject {

src/tokenProvider/kmsTokenProvider.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ export default class KmsTokenProvider extends TokenProvider {
1919
public async getClientSecret(): Promise<Auth0Secret | undefined> {
2020
const data = await this.kmsClient.send(
2121
new DecryptCommand({
22-
CiphertextBlob: Buffer.from(this.kmsConfiguration.encryptedClientSecret, 'base64'),
22+
CiphertextBlob: new Uint8Array(
23+
Buffer.from(this.kmsConfiguration.encryptedClientSecret, 'base64'),
24+
),
2325
}),
2426
);
25-
const secret = data.Plaintext?.toString();
27+
const secret = data.Plaintext
28+
? Buffer.from(data.Plaintext as Uint8Array).toString()
29+
: undefined;
2630
if (!secret) {
2731
throw new Error('Request error: failed to decrypt secret using KMS');
2832
}

0 commit comments

Comments
 (0)