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 .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
yarn lint-staged
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.

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

## [7.0.0-beta] - 2025-11-14

### Changed

- Add 429 status code mapping
- Changed logging format. Removed the inner `message` object.
- Update packages
- Use axios-cache-interceptor instead of axios-cache-adapter

## [6.1.1] - 2025-10-27

### Changed
Expand Down
47 changes: 25 additions & 22 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lambda-essentials-ts",
"version": "6.1.1",
"version": "7.0.0-beta",
"description": "A selection of the finest modules supporting authorization, API routing, error handling, logging and sending HTTP requests.",
"main": "lib/index.js",
"private": false,
Expand All @@ -26,30 +26,33 @@
},
"homepage": "https://github.com/Cimpress-MCP/lambda-essentials-ts#readme",
"dependencies": {
"@aws-sdk/client-kms": "^3.569.0",
"@aws-sdk/client-secrets-manager": "^3.569.0",
"axios": "~0.21.3",
"axios-cache-adapter": "~2.7.3",
"fast-safe-stringify": "~2.0.7",
"@aws-sdk/client-kms": "^3.930.0",
"@aws-sdk/client-secrets-manager": "^3.930.0",
"@types/node": "^24.10.1",
"axios": "1.13.2",
"axios-cache-interceptor": "^1.8.3",
"fast-safe-stringify": "~2.1.1",
"is-error": "~2.2.2",
"jsonwebtoken": "9.0.0",
"jsonwebtoken": "9.0.2",
"md5": "~2.3.0",
"openapi-factory": "5.4.60",
"retry-axios": "~2.6.0",
"uuid": "~8.3.2"
"retry-axios": "~3.2.1",
"uuid": "~11.1.0"
},
"devDependencies": {
"@types/jest": "^26.0.20",
"@types/newrelic": "^9.14.0",
"eslint": "^7.18.0",
"eslint-config-cimpress-atsquad": "^2.1.2",
"husky": "^4.2.5",
"jest": "^26.2.2",
"lint-staged": "^10.2.13",
"prettier": "^2.7.1",
"ts-jest": "^26.1.4",
"ts-node": "^10.9.1",
"typescript": "^4.8.2"
"@types/jest": "^29.5.14",
"@types/newrelic": "^9.14.8",
"redis": "^5.9.0",
"axios-mock-adapter": "^2.1.0",
"eslint": "^8.57.1",
"eslint-config-cimpress-atsquad": "^2.2.0-beta",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.5.2",
"prettier": "^2.8.8",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"eslintConfig": {
"extends": "cimpress-atsquad"
Expand All @@ -74,9 +77,9 @@
"collectCoverage": false
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"newrelic": "^10.2.0"
"newrelic": "^12.0.0"
}
}
1 change: 1 addition & 0 deletions src/exceptions/clientException.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class ClientException extends Exception {
403: 403,
404: 422,
422: 422,
429: 429,
};

constructor(
Expand Down
24 changes: 0 additions & 24 deletions src/httpClient/deduplicateRequestAdapter.ts

This file was deleted.

60 changes: 26 additions & 34 deletions src/httpClient/httpClient.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import * as uuid from 'uuid';
import { URL } from 'url';
import axios, {
AxiosAdapter,
AxiosError,
AxiosHeaderValue,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
RawAxiosRequestHeaders,
} from 'axios';
import { IAxiosCacheAdapterOptions, setupCache } from 'axios-cache-adapter';
import * as rax from 'retry-axios';
import { RetryConfig } from 'retry-axios';
import md5 from 'md5';
import { safeJwtCanonicalIdParse, safeJsonParse, serializeAxiosError } from '../util';
import { InternalException } from '../exceptions/internalException';
import { ClientException } from '../exceptions/clientException';
import { orionCorrelationIdRoot } from '../shared';
import { createDebounceRequestAdapter } from './deduplicateRequestAdapter';
import { CacheOptions, setupCache } from 'axios-cache-interceptor';

const invalidToken: string = 'Invalid token';

Expand Down Expand Up @@ -60,29 +59,22 @@ export default class HttpClient {
this.enableRetry = options?.enableRetry ?? false;
this.timeout = options?.timeout;
this.clientExceptionStatusCodeMapOverride = options?.clientExceptionStatusCodeMapOverride;
this.client =
options?.client ??
axios.create({
adapter: (() => {
let adapters = axios.defaults.adapter as AxiosAdapter;
if (this.enableCache) {
const cache = setupCache({
maxAge: 5 * 60 * 1000, // all items are cached for 5 minutes
readHeaders: false, // ignore cache control headers in favor of the static 5 minutes
readOnError: true,
exclude: {
query: false, // also cache requests with query parameters
},
...options?.cacheOptions, // allow to overwrite the defaults except of cache-key
key: (req) => HttpClient.generateCacheKey(req),
});

// debounce concurrent calls with the same cacheKey so that only one HTTP request is made
adapters = createDebounceRequestAdapter(cache.adapter, HttpClient.generateCacheKey);
}
return adapters;
})(),
this.client = options?.client ?? axios.create();

if (this.enableCache) {
setupCache(this.client, {
// 5 minutes TTL
ttl: 5 * 60 * 1000,
// Respect cache-control headers and have ttl as a fallback https://axios-cache-interceptor.js.org/config/request-specifics#cache-interpretheader
interpretHeader: true,
// Serve stale cache on error
staleIfError: true,
// Use custom cache key to include auth, url, params and body hash
generateKey: (req) => HttpClient.generateCacheKey(req),
// Allow overriding defaults via provided options
...options?.cacheOptions,
});
}

if (this.enableRetry) {
this.client.defaults.raxConfig = {
Expand Down Expand Up @@ -224,15 +216,15 @@ export default class HttpClient {
* Resolves the token with the token provider and adds it to the headers
*/
async createHeadersWithResolvedToken(
headers: Record<string, string> = {},
): Promise<Record<string, string>> {
const newHeaders: Record<string, string> = {};
headers?: RawAxiosRequestHeaders | { [key: string]: AxiosHeaderValue } | Record<string, string>,
): Promise<{ [p: string]: AxiosHeaderValue }> {
const newHeaders: { [key: string]: AxiosHeaderValue } = {};
if (this.correlationIdResolverFunction) {
newHeaders[orionCorrelationIdRoot] = this.correlationIdResolverFunction();
}

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

return {
...headers,
...(headers as { [key: string]: AxiosHeaderValue }),
...newHeaders,
};
}
Expand Down Expand Up @@ -349,10 +341,10 @@ export interface HttpClientOptions {
*/
enableCache?: boolean;
/**
* Cache options
* @link https://github.com/RasCarlito/axios-cache-adapter/blob/master/axios-cache-adapter.d.ts#L26
* Cache options (global defaults for axios-cache-interceptor)
* @link https://axios-cache-interceptor.js.org/config
*/
cacheOptions?: IAxiosCacheAdapterOptions;
cacheOptions?: CacheOptions;
/**
* Enable automatic retries
*/
Expand Down
41 changes: 41 additions & 0 deletions src/httpClient/redisStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { buildStorage, canStale } from 'axios-cache-interceptor';
import type { StorageValue } from 'axios-cache-interceptor';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createClient } from 'redis';

const KEY_PREFIX = 'axios-cache-';

const MIN_TTL = 60000;

export default function createRedisStorage(client: ReturnType<typeof createClient>) {
// source https://axios-cache-interceptor.js.org/guide/storages#node-redis-storage
return buildStorage({
async find(key) {
const result = await client.get(`${KEY_PREFIX}${key}`);
return result ? (JSON.parse(result) as StorageValue) : undefined;
},

// eslint-disable-next-line complexity
async set(key, value, req) {
await client.set(`${KEY_PREFIX}${key}`, JSON.stringify(value), {
PXAT:
// We don't want to keep indefinitely values in the storage if
// their request don't finish somehow. Either set its value as
// the TTL or 1 minute (MIN_TTL).
value.state === 'loading'
? Date.now() +
(req?.cache && typeof req.cache.ttl === 'number' ? req.cache.ttl : MIN_TTL)
: (value.state === 'stale' && value.ttl) ||
(value.state === 'cached' && !canStale(value))
? value.createdAt + value.ttl!
: // otherwise, we can't determine when it should expire, so we keep
// it indefinitely.
undefined,
});
},

async remove(key) {
await client.del(`${KEY_PREFIX}${key}`);
},
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { InvalidDataException } from './exceptions/invalidDataException';
import { NotFoundException } from './exceptions/notFoundException';
import { ValidationException } from './exceptions/validationException';
import { serializeObject, serializeAxiosError } from './util';
import RedisStorage from './httpClient/redisStorage';

export {
Logger,
Expand All @@ -50,6 +51,7 @@ export {
ClientException,
serializeObject,
serializeAxiosError,
RedisStorage,
};

export type {
Expand Down
33 changes: 25 additions & 8 deletions src/logger/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,37 @@ import stringify from 'fast-safe-stringify';
import isError from 'is-error';
import { redactSecret } from '../util';

const DEFAULT_PAYLOAD_LIMIT = 32768;

const MIN_PAYLOAD_LIMIT = 10000;

export default class Logger {
public invocationId: string;

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

private readonly jsonSpace: number;

private readonly payloadLimit: number;

private staticData: any;

constructor(configuration?: LoggerConfiguration) {
this.logFunction = configuration?.logFunction ?? console.log;
this.jsonSpace = configuration?.jsonSpace ?? 2;
this.payloadLimit = !configuration?.payloadLimit
? DEFAULT_PAYLOAD_LIMIT
: configuration?.payloadLimit < MIN_PAYLOAD_LIMIT
? MIN_PAYLOAD_LIMIT
: configuration.payloadLimit;

this.invocationId = 'none';
}

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

const payload = {
invocationId: this.invocationId,
message: messageAsObject,
...messageAsObject,
};

const truncateToken = (innerPayload: string): string => {
Expand All @@ -69,15 +81,12 @@ export default class Logger {
const replacer = (key, value) => (isError(value) ? Logger.errorToObject(value) : value);
let stringifiedPayload = truncateToken(stringify(payload, replacer, this.jsonSpace));
stringifiedPayload = redactSecret(stringifiedPayload);
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html 256KB => 32768 characters
if (stringifiedPayload.length >= 32768) {
if (stringifiedPayload.length >= this.payloadLimit) {
const replacementPayload = {
invocationId: this.invocationId,
message: {
title: 'Payload too large',
fields: Object.keys(payload),
truncatedPayload: stringifiedPayload.substring(0, 10000),
},
title: 'Payload too large',
fields: Object.keys(payload),
truncatedPayload: stringifiedPayload.substring(0, this.payloadLimit - 3000),
};
stringifiedPayload = stringify(replacementPayload, replacer, this.jsonSpace);
}
Expand All @@ -104,6 +113,14 @@ export interface LoggerConfiguration {
* the number of spaces that are used then stringifying the message.
*/
jsonSpace?: number;

/**
* the limit of a stringified payload in characters above which the payload will be truncated.
* 3000 characters are reserved
* @min 10000
* @default 32768
*/
payloadLimit?: number;
}

export interface SuggestedLogObject {
Expand Down
8 changes: 6 additions & 2 deletions src/tokenProvider/kmsTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ export default class KmsTokenProvider extends TokenProvider {
public async getClientSecret(): Promise<Auth0Secret | undefined> {
const data = await this.kmsClient.send(
new DecryptCommand({
CiphertextBlob: Buffer.from(this.kmsConfiguration.encryptedClientSecret, 'base64'),
CiphertextBlob: new Uint8Array(
Buffer.from(this.kmsConfiguration.encryptedClientSecret, 'base64'),
),
}),
);
const secret = data.Plaintext?.toString();
const secret = data.Plaintext
? Buffer.from(data.Plaintext as Uint8Array).toString()
: undefined;
if (!secret) {
throw new Error('Request error: failed to decrypt secret using KMS');
}
Expand Down
Loading