@api3/commons 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -15
- package/dist/config-hash/index.d.ts +4 -0
- package/dist/config-hash/index.d.ts.map +1 -0
- package/dist/config-hash/index.js +30 -0
- package/dist/config-hash/index.js.map +1 -0
- package/dist/config-parsing/index.d.ts +15 -0
- package/dist/config-parsing/index.d.ts.map +1 -0
- package/dist/config-parsing/index.js +55 -0
- package/dist/config-parsing/index.js.map +1 -0
- package/dist/eslint/react.js +1 -1
- package/dist/eslint/react.js.map +1 -1
- package/dist/http/index.d.ts +29 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +36 -0
- package/dist/http/index.js.map +1 -0
- package/dist/logger/index.d.ts.map +1 -1
- package/dist/logger/index.js +15 -15
- package/dist/logger/index.js.map +1 -1
- package/dist/node-index.d.ts +6 -0
- package/dist/node-index.d.ts.map +1 -0
- package/dist/{index.js → node-index.js} +4 -2
- package/dist/node-index.js.map +1 -0
- package/dist/processing/unsafe-evaluate.d.ts.map +1 -1
- package/dist/processing/unsafe-evaluate.js +0 -2
- package/dist/processing/unsafe-evaluate.js.map +1 -1
- package/dist/universal-index.d.ts +2 -0
- package/dist/universal-index.d.ts.map +1 -0
- package/dist/universal-index.js +19 -0
- package/dist/universal-index.js.map +1 -0
- package/package.json +11 -3
- package/src/config-hash/README.md +25 -0
- package/src/config-hash/index.test.ts +93 -0
- package/src/config-hash/index.ts +25 -0
- package/src/config-parsing/README.md +48 -0
- package/src/config-parsing/index.test.ts +158 -0
- package/src/config-parsing/index.ts +75 -0
- package/src/eslint/react.js +1 -1
- package/src/http/README.md +3 -0
- package/src/http/index.test.ts +43 -0
- package/src/http/index.ts +61 -0
- package/src/logger/index.test.ts +19 -17
- package/src/logger/index.ts +15 -16
- package/src/{index.ts → node-index.ts} +3 -1
- package/src/processing/processing.test.ts +1 -1
- package/src/processing/unsafe-evaluate.test.ts +0 -1
- package/src/processing/unsafe-evaluate.ts +0 -2
- package/src/universal-index.ts +2 -0
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { createSha256Hash, serializePlainObject, sortObjectKeysRecursively } from '.';
|
|
6
|
+
|
|
7
|
+
describe(sortObjectKeysRecursively.name, () => {
|
|
8
|
+
it('should sort the keys alphabetically', () => {
|
|
9
|
+
const plainObject = {
|
|
10
|
+
c: 3,
|
|
11
|
+
a: 1,
|
|
12
|
+
b: 2,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
expect(sortObjectKeysRecursively(plainObject)).toStrictEqual({ a: 1, b: 2, c: 3 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should handle nested objects', () => {
|
|
19
|
+
const plainObject = {
|
|
20
|
+
c: 3,
|
|
21
|
+
a: 1,
|
|
22
|
+
b: {
|
|
23
|
+
e: 5,
|
|
24
|
+
d: 4,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
expect(sortObjectKeysRecursively(plainObject)).toStrictEqual({ a: 1, b: { d: 4, e: 5 }, c: 3 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('handles other primitive values', () => {
|
|
32
|
+
const plainObject = {
|
|
33
|
+
c: 'c-val',
|
|
34
|
+
a: 1,
|
|
35
|
+
F: null,
|
|
36
|
+
B: false,
|
|
37
|
+
b: [2, null, 1],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
expect(sortObjectKeysRecursively(plainObject)).toStrictEqual({
|
|
41
|
+
B: false,
|
|
42
|
+
F: null,
|
|
43
|
+
a: 1,
|
|
44
|
+
b: [2, null, 1],
|
|
45
|
+
c: 'c-val',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe(serializePlainObject.name, () => {
|
|
51
|
+
it('creates the same serialization string for equal objects', () => {
|
|
52
|
+
const plainObject = {
|
|
53
|
+
c: 'c-val',
|
|
54
|
+
a: 1,
|
|
55
|
+
F: null,
|
|
56
|
+
B: false,
|
|
57
|
+
b: [2, null, 1],
|
|
58
|
+
};
|
|
59
|
+
const otherPlainObject = {
|
|
60
|
+
a: 1,
|
|
61
|
+
F: null,
|
|
62
|
+
B: false,
|
|
63
|
+
c: 'c-val',
|
|
64
|
+
b: [2, null, 1],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
expect(serializePlainObject(plainObject)).toBe(serializePlainObject(otherPlainObject));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe(createSha256Hash.name, () => {
|
|
72
|
+
it('should create same hash as SubtleCrypto in browser', () => {
|
|
73
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
|
|
74
|
+
const text = 'An obscure body in the S-K System, your majesty. The inhabitants refer to it as the planet Earth.';
|
|
75
|
+
|
|
76
|
+
expect(createSha256Hash(text)).toBe('0x6efd383745a964768989b9df420811abc6e5873f874fc22a76fe9258e020c2e1');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('can produce the same hash on UNIX', () => {
|
|
81
|
+
const fixturePath = join(__dirname, '../../test/fixtures/config-hash/file.json');
|
|
82
|
+
|
|
83
|
+
// Compute the hash using this module
|
|
84
|
+
const rawConfig = JSON.parse(readFileSync(fixturePath, 'utf8'));
|
|
85
|
+
const serializedConfig = serializePlainObject(rawConfig);
|
|
86
|
+
const hash = createSha256Hash(serializedConfig);
|
|
87
|
+
|
|
88
|
+
// Compute the hash using UNIX commands
|
|
89
|
+
const unixCommand = `jq --sort-keys --compact-output . ${fixturePath} | tr -d '\n' | sha256sum | awk '{ print "0x"$1 }'`;
|
|
90
|
+
const unixCommandHash = execSync(unixCommand, { encoding: 'utf8' }).trim();
|
|
91
|
+
|
|
92
|
+
expect(hash).toBe(unixCommandHash);
|
|
93
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import isObject from 'lodash/isObject';
|
|
4
|
+
|
|
5
|
+
// We need to make sure the object is stringified in the same way every time, so we sort the keys alphabetically.
|
|
6
|
+
export const sortObjectKeysRecursively = (value: any) => {
|
|
7
|
+
if (value === null) return null;
|
|
8
|
+
if (!isObject(value) || Array.isArray(value)) return value;
|
|
9
|
+
|
|
10
|
+
const sortedKeys = Object.keys(value).sort();
|
|
11
|
+
const sortedObject: any = {};
|
|
12
|
+
|
|
13
|
+
for (const key of sortedKeys) {
|
|
14
|
+
sortedObject[key] = sortObjectKeysRecursively((value as any)[key]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return sortedObject;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const serializePlainObject = (plainObject: any) => {
|
|
21
|
+
const sortedObject = sortObjectKeysRecursively(plainObject);
|
|
22
|
+
return JSON.stringify(sortedObject);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const createSha256Hash = (value: string) => `0x${createHash('sha256').update(value).digest('hex')}`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Config parsing
|
|
2
|
+
|
|
3
|
+
> Node.js module for parsing configuration files with support of interpolating secrets.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
### Parsing a configuration file and secrets
|
|
8
|
+
|
|
9
|
+
You can use the following helper functions to read the configuration and secrets file from filesystem:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const rawConfig = loadConfig(join(__dirname, 'config.json'));
|
|
13
|
+
const rawSecrets = loadSecrets(join(__dirname, 'secrets.env'));
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Creating the full configuration object
|
|
17
|
+
|
|
18
|
+
The module defines a `interpolateSecretsIntoConfig` function that takes a config object along with the secrets and
|
|
19
|
+
returns the config with the secrets interpolated into it.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// Basic interpolation
|
|
25
|
+
const rawConfig = {
|
|
26
|
+
prop: 'value',
|
|
27
|
+
secret: '${SECRET}',
|
|
28
|
+
};
|
|
29
|
+
const config = interpolateSecretsIntoConfig(rawConfig, { SECRET: 'secretValue' }); // { prop: 'value', secret: 'secretValue' }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// Allows escaping the interpolation syntax
|
|
34
|
+
const rawConfig = {
|
|
35
|
+
prop: 'value',
|
|
36
|
+
secret: '\\${SECRET}',
|
|
37
|
+
};
|
|
38
|
+
const config = interpolateSecretsIntoConfig(rawConfig, { SECRET: 'secretValue' }); // { prop: 'value', secret: '${SECRET}' }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Throws an error if something is not right
|
|
43
|
+
const rawConfig = {
|
|
44
|
+
prop: 'value',
|
|
45
|
+
secret: '${SECRET}',
|
|
46
|
+
};
|
|
47
|
+
const config = interpolateSecretsIntoConfig(rawConfig); // Error: SECRET is not defined
|
|
48
|
+
```
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { interpolateSecretsIntoConfig } from './index';
|
|
4
|
+
|
|
5
|
+
const rawConfig = {
|
|
6
|
+
property: 'value',
|
|
7
|
+
secretB: '${SECRET_B}',
|
|
8
|
+
secretA: '${SECRET_A}',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe(interpolateSecretsIntoConfig.name, () => {
|
|
12
|
+
it('interpolates secrets into config', () => {
|
|
13
|
+
const config = interpolateSecretsIntoConfig(rawConfig, {
|
|
14
|
+
SECRET_A: 'secretValueA',
|
|
15
|
+
SECRET_B: 'secretValueB',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(config).toStrictEqual({
|
|
19
|
+
property: 'value',
|
|
20
|
+
secretA: 'secretValueA',
|
|
21
|
+
secretB: 'secretValueB',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('allows empty secrets by default', () => {
|
|
26
|
+
const config = interpolateSecretsIntoConfig(rawConfig, {
|
|
27
|
+
SECRET_A: '',
|
|
28
|
+
SECRET_B: '',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(config).toStrictEqual({
|
|
32
|
+
property: 'value',
|
|
33
|
+
secretA: '',
|
|
34
|
+
secretB: '',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('disallows empty secrets when configured so', () => {
|
|
39
|
+
expect(() => {
|
|
40
|
+
interpolateSecretsIntoConfig(
|
|
41
|
+
rawConfig,
|
|
42
|
+
{
|
|
43
|
+
SECRET_A: '',
|
|
44
|
+
SECRET_B: '',
|
|
45
|
+
},
|
|
46
|
+
{ allowBlankSecretValue: false }
|
|
47
|
+
);
|
|
48
|
+
}).toThrow('Secret cannot be blank');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('can use "\\" to escape interpolation', () => {
|
|
52
|
+
const escapedConfig = {
|
|
53
|
+
...rawConfig,
|
|
54
|
+
secretA: '\\${SECRET_A}',
|
|
55
|
+
};
|
|
56
|
+
const config = interpolateSecretsIntoConfig(escapedConfig, {
|
|
57
|
+
SECRET_A: 'secretValueA',
|
|
58
|
+
SECRET_B: 'secretValueB',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(config).toStrictEqual({
|
|
62
|
+
property: 'value',
|
|
63
|
+
secretA: '${SECRET_A}',
|
|
64
|
+
secretB: 'secretValueB',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('allows extraneous secrets', () => {
|
|
69
|
+
const config = interpolateSecretsIntoConfig(rawConfig, {
|
|
70
|
+
SECRET_A: 'secretValueA',
|
|
71
|
+
SECRET_B: 'secretValueB',
|
|
72
|
+
SECRET_C: 'secretValueC',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(config).toStrictEqual({
|
|
76
|
+
property: 'value',
|
|
77
|
+
secretA: 'secretValueA',
|
|
78
|
+
secretB: 'secretValueB',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('throws an error when a secret is missing', () => {
|
|
83
|
+
expect(() => {
|
|
84
|
+
interpolateSecretsIntoConfig(rawConfig, {
|
|
85
|
+
SECRET_A: 'secretValueA',
|
|
86
|
+
});
|
|
87
|
+
}).toThrow('SECRET_B is not defined');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('allows no secrets', () => {
|
|
91
|
+
const noSecretsConfig = { value: 'no secrets' };
|
|
92
|
+
|
|
93
|
+
expect(interpolateSecretsIntoConfig(noSecretsConfig, {})).toStrictEqual(noSecretsConfig);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws when secret name is invalid', () => {
|
|
97
|
+
expect(() => {
|
|
98
|
+
interpolateSecretsIntoConfig(rawConfig, {
|
|
99
|
+
SECRET_A: 'secretValueA',
|
|
100
|
+
'0_SECRET_STARTING_WITH_NUMBER': 'invalid',
|
|
101
|
+
});
|
|
102
|
+
}).toThrow(
|
|
103
|
+
new ZodError([
|
|
104
|
+
{
|
|
105
|
+
validation: 'regex',
|
|
106
|
+
code: 'invalid_string',
|
|
107
|
+
message: 'Secret name is not a valid. Secret name must match /^[A-Z][\\dA-Z_]*$/',
|
|
108
|
+
path: ['0_SECRET_STARTING_WITH_NUMBER'],
|
|
109
|
+
},
|
|
110
|
+
])
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(() => {
|
|
114
|
+
interpolateSecretsIntoConfig(rawConfig, {
|
|
115
|
+
SECRET_A: 'secretValueA',
|
|
116
|
+
'CANNOT-CONTAIN-HYPHEN': 'invalid',
|
|
117
|
+
});
|
|
118
|
+
}).toThrow(
|
|
119
|
+
new ZodError([
|
|
120
|
+
{
|
|
121
|
+
validation: 'regex',
|
|
122
|
+
code: 'invalid_string',
|
|
123
|
+
message: 'Secret name is not a valid. Secret name must match /^[A-Z][\\dA-Z_]*$/',
|
|
124
|
+
path: ['CANNOT-CONTAIN-HYPHEN'],
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('provides up to date README examples', () => {
|
|
131
|
+
// Basic interpolation
|
|
132
|
+
const basicInterpolationConfig = {
|
|
133
|
+
prop: 'value',
|
|
134
|
+
secret: '${SECRET}',
|
|
135
|
+
};
|
|
136
|
+
expect(interpolateSecretsIntoConfig(basicInterpolationConfig, { SECRET: 'secretValue' })).toStrictEqual({
|
|
137
|
+
prop: 'value',
|
|
138
|
+
secret: 'secretValue',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Allows escaping the interpolation syntax
|
|
142
|
+
const escapingInterpolationConfig = {
|
|
143
|
+
prop: 'value',
|
|
144
|
+
secret: '\\${SECRET}',
|
|
145
|
+
};
|
|
146
|
+
expect(interpolateSecretsIntoConfig(escapingInterpolationConfig, { SECRET: 'secretValue' })).toStrictEqual({
|
|
147
|
+
prop: 'value',
|
|
148
|
+
secret: '${SECRET}',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Throws an error if something is not right
|
|
152
|
+
const missingSecretConfig = {
|
|
153
|
+
prop: 'value',
|
|
154
|
+
secret: '${SECRET}',
|
|
155
|
+
};
|
|
156
|
+
expect(() => interpolateSecretsIntoConfig(missingSecretConfig, {})).toThrow('SECRET is not defined');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import reduce from 'lodash/reduce';
|
|
5
|
+
import template from 'lodash/template';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
export const secretNamePattern = /^[A-Z][\dA-Z_]*$/;
|
|
9
|
+
|
|
10
|
+
export const secretNameSchema = z
|
|
11
|
+
.string()
|
|
12
|
+
.regex(secretNamePattern, `Secret name is not a valid. Secret name must match ${secretNamePattern.toString()}`);
|
|
13
|
+
|
|
14
|
+
export const secretsSchema = z.record(secretNameSchema, z.string());
|
|
15
|
+
|
|
16
|
+
export const nonBlankSecretsSchema = z.record(
|
|
17
|
+
secretNameSchema,
|
|
18
|
+
z.string().min(1, { message: 'Secret cannot be blank' })
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export type Secrets = Record<string, string>;
|
|
22
|
+
|
|
23
|
+
// Regular expression that does not match anything, ensuring no escaping or interpolation happens
|
|
24
|
+
// https://github.com/lodash/lodash/blob/4.17.15/lodash.js#L199
|
|
25
|
+
// eslint-disable-next-line prefer-named-capture-group
|
|
26
|
+
const NO_MATCH_REGEXP = /($^)/;
|
|
27
|
+
// Regular expression matching ES template literal delimiter (${}) with escaping
|
|
28
|
+
// https://github.com/lodash/lodash/blob/4.17.15/lodash.js#L175
|
|
29
|
+
// eslint-disable-next-line prefer-named-capture-group
|
|
30
|
+
const ES_MATCH_REGEXP = /(?<!\\)\${([^\\}]*(?:\\.[^\\}]*)*)}/g;
|
|
31
|
+
// Regular expression matching the escaped ES template literal delimiter (${}). We need to use "\\\\" (four backslashes)
|
|
32
|
+
// because "\\" becomes "\\\\" when converted to string
|
|
33
|
+
// eslint-disable-next-line prefer-named-capture-group
|
|
34
|
+
const ESCAPED_ES_MATCH_REGEXP = /\\\\(\${([^\\}]*(?:\\.[^\\}]*)*)})/g;
|
|
35
|
+
|
|
36
|
+
export interface InterpolationOptions {
|
|
37
|
+
allowBlankSecretValue: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AnyObject = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
export function interpolateSecretsIntoConfig<T = AnyObject>(
|
|
43
|
+
config: T,
|
|
44
|
+
secrets: unknown,
|
|
45
|
+
options: InterpolationOptions = { allowBlankSecretValue: true }
|
|
46
|
+
) {
|
|
47
|
+
const { allowBlankSecretValue } = options;
|
|
48
|
+
const validatedSecrets = (allowBlankSecretValue ? secretsSchema : nonBlankSecretsSchema).parse(secrets);
|
|
49
|
+
|
|
50
|
+
const stringifiedSecrets = reduce(
|
|
51
|
+
validatedSecrets,
|
|
52
|
+
(acc, value, key) => {
|
|
53
|
+
return {
|
|
54
|
+
...acc,
|
|
55
|
+
// Convert to value to JSON to encode new lines as "\n". The resulting value will be a JSON string with quotes
|
|
56
|
+
// which are sliced off.
|
|
57
|
+
[key]: JSON.stringify(value).slice(1, -1),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
{} as Secrets
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const interpolatedConfig = template(JSON.stringify(config), {
|
|
64
|
+
escape: NO_MATCH_REGEXP,
|
|
65
|
+
evaluate: NO_MATCH_REGEXP,
|
|
66
|
+
interpolate: ES_MATCH_REGEXP,
|
|
67
|
+
})(stringifiedSecrets);
|
|
68
|
+
// Un-escape the escaped config interpolations (e.g. to enable interpolation in processing snippets). Optimistically
|
|
69
|
+
// assume, the config type has not changed.
|
|
70
|
+
return JSON.parse(interpolatedConfig.replaceAll(ESCAPED_ES_MATCH_REGEXP, '$1')) as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const loadSecrets = (path: string) => dotenv.parse(readFileSync(path, 'utf8'));
|
|
74
|
+
|
|
75
|
+
export const loadConfig = (path: string) => JSON.parse(readFileSync(path, 'utf8'));
|
package/src/eslint/react.js
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AxiosError, type AxiosResponse } from 'axios';
|
|
2
|
+
|
|
3
|
+
import { executeRequest, extractAxiosErrorData } from '.';
|
|
4
|
+
|
|
5
|
+
describe(extractAxiosErrorData.name, () => {
|
|
6
|
+
it('should return an error response object', () => {
|
|
7
|
+
const axiosError = new AxiosError('error message', '500', undefined, {}, {
|
|
8
|
+
data: 'error data',
|
|
9
|
+
status: 500,
|
|
10
|
+
statusText: 'Internal Server Error',
|
|
11
|
+
} as any as AxiosResponse);
|
|
12
|
+
|
|
13
|
+
expect(extractAxiosErrorData(axiosError)).toStrictEqual({
|
|
14
|
+
axiosResponse: {
|
|
15
|
+
data: 'error data',
|
|
16
|
+
status: 500,
|
|
17
|
+
},
|
|
18
|
+
code: '500',
|
|
19
|
+
message: 'error message',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe(executeRequest.name, () => {
|
|
25
|
+
it('fails to call invalid URL', async () => {
|
|
26
|
+
const request = {
|
|
27
|
+
method: 'GET',
|
|
28
|
+
url: 'http://localhost:9999',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
const response = await executeRequest(request);
|
|
32
|
+
|
|
33
|
+
expect(response).toStrictEqual({
|
|
34
|
+
data: undefined,
|
|
35
|
+
errorData: {
|
|
36
|
+
axiosResponse: undefined,
|
|
37
|
+
code: 'ECONNREFUSED',
|
|
38
|
+
message: expect.any(String), // The message is empty in node@20, but "connect ECONNREFUSED ::1:9999" on node@18
|
|
39
|
+
},
|
|
40
|
+
success: false,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { go } from '@api3/promise-utils';
|
|
2
|
+
import axios, { type Method, type AxiosError, type AxiosResponse } from 'axios';
|
|
3
|
+
import pick from 'lodash/pick';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export interface Request {
|
|
8
|
+
readonly method: Method;
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly headers?: Record<string, string>;
|
|
11
|
+
readonly queryParams?: Record<string, any>;
|
|
12
|
+
readonly timeout?: number;
|
|
13
|
+
readonly body?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ErrorResponse {
|
|
17
|
+
readonly axiosResponse: Pick<AxiosResponse, 'data' | 'headers' | 'status'> | undefined;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly code: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const extractAxiosErrorData = (error: AxiosError): ErrorResponse => {
|
|
23
|
+
// Inspired by: https://axios-http.com/docs/handling_errors
|
|
24
|
+
return {
|
|
25
|
+
axiosResponse: error.response ? pick(error.response, ['data', 'status']) : undefined,
|
|
26
|
+
message: error.message,
|
|
27
|
+
code: error.code,
|
|
28
|
+
} as ErrorResponse;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface ExecuteRequestSuccess<T> {
|
|
32
|
+
success: true;
|
|
33
|
+
errorData: undefined;
|
|
34
|
+
data: T;
|
|
35
|
+
}
|
|
36
|
+
interface ExecuteRequestError {
|
|
37
|
+
success: false;
|
|
38
|
+
errorData: ErrorResponse;
|
|
39
|
+
data: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ExecuteRequestResult<T> = ExecuteRequestError | ExecuteRequestSuccess<T>;
|
|
43
|
+
|
|
44
|
+
export async function executeRequest<T>(request: Request): Promise<ExecuteRequestResult<T>> {
|
|
45
|
+
const { url, method, body, headers = {}, queryParams = {}, timeout = DEFAULT_TIMEOUT_MS } = request;
|
|
46
|
+
|
|
47
|
+
const goAxios = await go<Promise<AxiosResponse<T>>, AxiosError>(async () =>
|
|
48
|
+
axios({
|
|
49
|
+
url,
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
data: body,
|
|
53
|
+
params: queryParams,
|
|
54
|
+
timeout,
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
if (!goAxios.success) return { success: false, errorData: extractAxiosErrorData(goAxios.error), data: undefined };
|
|
58
|
+
const response = goAxios.data;
|
|
59
|
+
|
|
60
|
+
return { success: true, errorData: undefined, data: response.data };
|
|
61
|
+
}
|
package/src/logger/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import noop from 'lodash/noop';
|
|
2
2
|
|
|
3
3
|
import { type LogConfig, createBaseLogger, wrapper } from '.';
|
|
4
4
|
|
|
@@ -29,9 +29,9 @@ describe('log context', () => {
|
|
|
29
29
|
logger.debug('parent end');
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { requestId: 'parent' });
|
|
33
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('child', { requestId: 'child' });
|
|
34
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { requestId: 'parent' });
|
|
32
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { ctx: { requestId: 'parent' } });
|
|
33
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('child', { ctx: { requestId: 'child' } });
|
|
34
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { ctx: { requestId: 'parent' } });
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it('works with async functions', async () => {
|
|
@@ -48,9 +48,9 @@ describe('log context', () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
expect(baseLogger.debug).toHaveBeenCalledTimes(3);
|
|
51
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { requestId: 'parent' });
|
|
52
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('child', { requestId: 'child' });
|
|
53
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { requestId: 'parent' });
|
|
51
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { ctx: { requestId: 'parent' } });
|
|
52
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('child', { ctx: { requestId: 'child' } });
|
|
53
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { ctx: { requestId: 'parent' } });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it('works with deeply nested functions', async () => {
|
|
@@ -78,14 +78,14 @@ describe('log context', () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
expect(baseLogger.debug).toHaveBeenCalledTimes(8);
|
|
81
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { parent: true });
|
|
82
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('A start', { parent: true, A: true });
|
|
83
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('C', { parent: true, A: true, B: true });
|
|
84
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('D', { parent: true, A: true, B: true });
|
|
85
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('E', { parent: true, A: true, B: true });
|
|
86
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('B end', { parent: true, A: true, B: true });
|
|
87
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('A end', { parent: true, A: true });
|
|
88
|
-
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { parent: true });
|
|
81
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent start', { ctx: { parent: true } });
|
|
82
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('A start', { ctx: { parent: true, A: true } });
|
|
83
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('C', { ctx: { parent: true, A: true, B: true } });
|
|
84
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('D', { ctx: { parent: true, A: true, B: true } });
|
|
85
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('E', { ctx: { parent: true, A: true, B: true } });
|
|
86
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('B end', { ctx: { parent: true, A: true, B: true } });
|
|
87
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('A end', { ctx: { parent: true, A: true } });
|
|
88
|
+
expect(baseLogger.debug).toHaveBeenCalledWith('parent end', { ctx: { parent: true } });
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
it('throws if the sync callback function throws', () => {
|
|
@@ -118,10 +118,12 @@ describe('log context', () => {
|
|
|
118
118
|
logger.error('message, error and context', new Error('some-error'), { requestId: 'parent' });
|
|
119
119
|
|
|
120
120
|
expect(baseLogger.error).toHaveBeenNthCalledWith(1, 'only message', undefined);
|
|
121
|
-
expect(baseLogger.error).toHaveBeenNthCalledWith(2, 'message and context', { requestId: 'parent' });
|
|
121
|
+
expect(baseLogger.error).toHaveBeenNthCalledWith(2, 'message and context', { ctx: { requestId: 'parent' } });
|
|
122
122
|
expect(baseLogger.error).toHaveBeenNthCalledWith(3, 'message and error', new Error('some-error'), undefined);
|
|
123
123
|
expect(baseLogger.error).toHaveBeenNthCalledWith(4, 'message, error and context', new Error('some-error'), {
|
|
124
|
-
|
|
124
|
+
ctx: {
|
|
125
|
+
requestId: 'parent',
|
|
126
|
+
},
|
|
125
127
|
});
|
|
126
128
|
});
|
|
127
129
|
});
|
package/src/logger/index.ts
CHANGED
|
@@ -61,7 +61,6 @@ export const createBaseLogger = (config: LogConfig) => {
|
|
|
61
61
|
// This format is recommended by the "winston-console-format" package.
|
|
62
62
|
format: winston.format.combine(
|
|
63
63
|
winston.format.timestamp(),
|
|
64
|
-
winston.format.ms(),
|
|
65
64
|
winston.format.errors({ stack: true }),
|
|
66
65
|
winston.format.splat(),
|
|
67
66
|
winston.format.json()
|
|
@@ -88,36 +87,36 @@ export interface Logger {
|
|
|
88
87
|
child: (options: { name: string }) => Logger;
|
|
89
88
|
}
|
|
90
89
|
|
|
90
|
+
const createFullContext = (localContext: LogContext | undefined) => {
|
|
91
|
+
const globalContext = getAsyncLocalStorage().getStore();
|
|
92
|
+
if (!globalContext && !localContext) return;
|
|
93
|
+
const fullContext = { ...globalContext, ...localContext };
|
|
94
|
+
|
|
95
|
+
// If the context contains a `name` or `message` field, it will override the `name` and `message` fields of the log
|
|
96
|
+
// entry. To avoid this, we return the context as a separate field.
|
|
97
|
+
return { ctx: fullContext };
|
|
98
|
+
};
|
|
99
|
+
|
|
91
100
|
// Winston by default merges content of `context` among the rest of the fields for the JSON format.
|
|
92
101
|
// That's causing an override of fields `name` and `message` if they are present.
|
|
93
102
|
export const wrapper = (logger: winston.Logger): Logger => {
|
|
94
103
|
return {
|
|
95
104
|
debug: (message, localContext) => {
|
|
96
|
-
|
|
97
|
-
const fullContext = globalContext || localContext ? { ...globalContext, ...localContext } : undefined;
|
|
98
|
-
logger.debug(message, fullContext);
|
|
105
|
+
logger.debug(message, createFullContext(localContext));
|
|
99
106
|
},
|
|
100
107
|
info: (message, localContext) => {
|
|
101
|
-
|
|
102
|
-
const fullContext = globalContext || localContext ? { ...globalContext, ...localContext } : undefined;
|
|
103
|
-
logger.info(message, fullContext);
|
|
108
|
+
logger.info(message, createFullContext(localContext));
|
|
104
109
|
},
|
|
105
110
|
warn: (message, localContext) => {
|
|
106
|
-
|
|
107
|
-
const fullContext = globalContext || localContext ? { ...globalContext, ...localContext } : undefined;
|
|
108
|
-
logger.warn(message, fullContext);
|
|
111
|
+
logger.warn(message, createFullContext(localContext));
|
|
109
112
|
},
|
|
110
113
|
// We need to handle both overloads of the `error` function
|
|
111
114
|
error: (message, errorOrLocalContext: Error | LogContext, localContext?: LogContext) => {
|
|
112
|
-
const globalContext = getAsyncLocalStorage().getStore();
|
|
113
115
|
// eslint-disable-next-line lodash/prefer-lodash-typecheck
|
|
114
116
|
if (errorOrLocalContext instanceof Error) {
|
|
115
|
-
|
|
116
|
-
logger.error(message, errorOrLocalContext, fullContext);
|
|
117
|
+
logger.error(message, errorOrLocalContext, createFullContext(localContext));
|
|
117
118
|
} else {
|
|
118
|
-
|
|
119
|
-
globalContext || errorOrLocalContext ? { ...globalContext, ...errorOrLocalContext } : undefined;
|
|
120
|
-
logger.error(message, fullContext);
|
|
119
|
+
logger.error(message, createFullContext(errorOrLocalContext));
|
|
121
120
|
}
|
|
122
121
|
},
|
|
123
122
|
child: (options) => wrapper(logger.child(options)),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// NOTE: Not exporting ESLint rules because they need to be imported in a special way inside .eslintrc.js config.
|
|
2
|
+
export * from './universal-index';
|
|
2
3
|
export * from './logger';
|
|
3
4
|
export * from './processing';
|
|
4
|
-
export * from './
|
|
5
|
+
export * from './config-parsing';
|
|
6
|
+
export * from './config-hash';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable jest/prefer-strict-equal */ // Because the errors are thrown from the "vm" module (different context), they are not strictly equal.
|
|
2
2
|
import { ZodError } from 'zod';
|
|
3
3
|
|
|
4
|
-
import { createEndpoint } from '../../test/fixtures';
|
|
4
|
+
import { createEndpoint } from '../../test/fixtures/processing';
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
addReservedParameters,
|