@unito/integration-sdk 1.0.5 → 1.0.6
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/.eslintrc.cjs +7 -2
- package/dist/src/index.cjs +67 -21
- package/dist/src/integration.js +2 -0
- package/dist/src/middlewares/finish.d.ts +1 -0
- package/dist/src/middlewares/finish.js +4 -1
- package/dist/src/middlewares/logger.d.ts +1 -1
- package/dist/src/middlewares/requestStartTime.d.ts +10 -0
- package/dist/src/middlewares/requestStartTime.js +5 -0
- package/dist/src/resources/provider.d.ts +12 -0
- package/dist/src/resources/provider.js +42 -5
- package/dist/test/handler.test.js +1 -1
- package/dist/test/middlewares/finish.test.js +3 -0
- package/dist/test/resources/provider.test.js +64 -1
- package/package.json +1 -1
- package/src/integration.ts +2 -0
- package/src/middlewares/finish.ts +6 -1
- package/src/middlewares/logger.ts +1 -1
- package/src/middlewares/requestStartTime.ts +18 -0
- package/src/resources/provider.ts +47 -5
- package/test/handler.test.ts +23 -23
- package/test/middlewares/finish.test.ts +3 -0
- package/test/resources/provider.test.ts +80 -1
package/.eslintrc.cjs
CHANGED
|
@@ -15,8 +15,7 @@ module.exports = {
|
|
|
15
15
|
'rules': {
|
|
16
16
|
'no-console': 'off',
|
|
17
17
|
// Typescript already checks for this, with an easy exception on leading underscores names
|
|
18
|
-
|
|
19
|
-
'@typescript-eslint/no-explicit-any': 'off'
|
|
18
|
+
'@typescript-eslint/no-unused-vars': 'off',
|
|
20
19
|
}
|
|
21
20
|
},
|
|
22
21
|
{
|
|
@@ -24,6 +23,12 @@ module.exports = {
|
|
|
24
23
|
'rules': {
|
|
25
24
|
'@typescript-eslint/no-floating-promises': 'off'
|
|
26
25
|
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
'files': ['**/*.ts'],
|
|
29
|
+
'rules': {
|
|
30
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
31
|
+
}
|
|
27
32
|
}
|
|
28
33
|
],
|
|
29
34
|
};
|
package/dist/src/index.cjs
CHANGED
|
@@ -353,13 +353,13 @@ function buildHttpError(responseStatus, message) {
|
|
|
353
353
|
return httpError;
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
const middleware$
|
|
356
|
+
const middleware$9 = (req, res, next) => {
|
|
357
357
|
res.locals.correlationId = req.header('X-Unito-Correlation-Id') ?? uuid__namespace.v4();
|
|
358
358
|
next();
|
|
359
359
|
};
|
|
360
360
|
|
|
361
361
|
const ADDITIONAL_CONTEXT_HEADER = 'X-Unito-Additional-Logging-Context';
|
|
362
|
-
const middleware$
|
|
362
|
+
const middleware$8 = (req, res, next) => {
|
|
363
363
|
const logger = new Logger({ correlation_id: res.locals.correlationId });
|
|
364
364
|
res.locals.logger = logger;
|
|
365
365
|
const rawAdditionalContext = req.header(ADDITIONAL_CONTEXT_HEADER);
|
|
@@ -376,7 +376,7 @@ const middleware$7 = (req, res, next) => {
|
|
|
376
376
|
};
|
|
377
377
|
|
|
378
378
|
const CREDENTIALS_HEADER = 'X-Unito-Credentials';
|
|
379
|
-
const middleware$
|
|
379
|
+
const middleware$7 = (req, res, next) => {
|
|
380
380
|
const credentialsHeader = req.header(CREDENTIALS_HEADER);
|
|
381
381
|
if (credentialsHeader) {
|
|
382
382
|
let credentials;
|
|
@@ -392,7 +392,7 @@ const middleware$6 = (req, res, next) => {
|
|
|
392
392
|
};
|
|
393
393
|
|
|
394
394
|
const OPERATION_DEADLINE_HEADER = 'X-Unito-Operation-Deadline';
|
|
395
|
-
const middleware$
|
|
395
|
+
const middleware$6 = (req, res, next) => {
|
|
396
396
|
const operationDeadlineHeader = Number(req.header(OPERATION_DEADLINE_HEADER));
|
|
397
397
|
if (operationDeadlineHeader) {
|
|
398
398
|
// `operationDeadlineHeader` represents a timestamp in the future, in seconds.
|
|
@@ -413,7 +413,7 @@ const middleware$5 = (req, res, next) => {
|
|
|
413
413
|
};
|
|
414
414
|
|
|
415
415
|
const SECRETS_HEADER = 'X-Unito-Secrets';
|
|
416
|
-
const middleware$
|
|
416
|
+
const middleware$5 = (req, res, next) => {
|
|
417
417
|
const secretsHeader = req.header(SECRETS_HEADER);
|
|
418
418
|
if (secretsHeader) {
|
|
419
419
|
let secrets;
|
|
@@ -428,7 +428,7 @@ const middleware$4 = (req, res, next) => {
|
|
|
428
428
|
next();
|
|
429
429
|
};
|
|
430
430
|
|
|
431
|
-
const middleware$
|
|
431
|
+
const middleware$4 = (req, res, next) => {
|
|
432
432
|
const rawSelect = req.query.select;
|
|
433
433
|
if (typeof rawSelect === 'string') {
|
|
434
434
|
res.locals.selects = rawSelect.split(',');
|
|
@@ -439,7 +439,7 @@ const middleware$3 = (req, res, next) => {
|
|
|
439
439
|
next();
|
|
440
440
|
};
|
|
441
441
|
|
|
442
|
-
const middleware$
|
|
442
|
+
const middleware$3 = (err, _req, res, next) => {
|
|
443
443
|
if (res.headersSent) {
|
|
444
444
|
return next(err);
|
|
445
445
|
}
|
|
@@ -475,12 +475,15 @@ const middleware$2 = (err, _req, res, next) => {
|
|
|
475
475
|
res.status(Number(error.code)).json(error);
|
|
476
476
|
};
|
|
477
477
|
|
|
478
|
-
const middleware$
|
|
478
|
+
const middleware$2 = (req, res, next) => {
|
|
479
479
|
if (req.originalUrl !== '/health') {
|
|
480
480
|
res.on('finish', function () {
|
|
481
481
|
const error = res.locals.error;
|
|
482
|
-
const
|
|
482
|
+
const durationInNs = Number(process.hrtime.bigint() - res.locals.requestStartTime);
|
|
483
|
+
const durationInMs = (durationInNs / 1_000_000) | 0;
|
|
484
|
+
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${durationInMs} ms`;
|
|
483
485
|
const metadata = {
|
|
486
|
+
duration: durationInNs,
|
|
484
487
|
// Use reserved and standard attributes of Datadog
|
|
485
488
|
// https://app.datadoghq.com/logs/pipelines/standard-attributes
|
|
486
489
|
http: { method: req.method, status_code: res.statusCode, url_details: { path: req.originalUrl } },
|
|
@@ -508,7 +511,7 @@ const middleware$1 = (req, res, next) => {
|
|
|
508
511
|
next();
|
|
509
512
|
};
|
|
510
513
|
|
|
511
|
-
const middleware = (req, res, _next) => {
|
|
514
|
+
const middleware$1 = (req, res, _next) => {
|
|
512
515
|
const error = {
|
|
513
516
|
code: '404',
|
|
514
517
|
message: `Path ${req.path} not found.`,
|
|
@@ -516,6 +519,11 @@ const middleware = (req, res, _next) => {
|
|
|
516
519
|
res.status(404).json(error);
|
|
517
520
|
};
|
|
518
521
|
|
|
522
|
+
const middleware = (_, res, next) => {
|
|
523
|
+
res.locals.requestStartTime = process.hrtime.bigint();
|
|
524
|
+
next();
|
|
525
|
+
};
|
|
526
|
+
|
|
519
527
|
function assertValidPath(path) {
|
|
520
528
|
if (!path.startsWith('/')) {
|
|
521
529
|
throw new InvalidHandler(`The provided path '${path}' is invalid. All paths must start with a '/'.`);
|
|
@@ -903,13 +911,14 @@ class Integration {
|
|
|
903
911
|
app.set('query parser', 'extended');
|
|
904
912
|
app.use(express.json());
|
|
905
913
|
// Must be one of the first handlers (to catch all the errors).
|
|
906
|
-
app.use(middleware$
|
|
914
|
+
app.use(middleware$2);
|
|
915
|
+
app.use(middleware);
|
|
916
|
+
app.use(middleware$9);
|
|
907
917
|
app.use(middleware$8);
|
|
908
918
|
app.use(middleware$7);
|
|
909
|
-
app.use(middleware$6);
|
|
910
|
-
app.use(middleware$4);
|
|
911
|
-
app.use(middleware$3);
|
|
912
919
|
app.use(middleware$5);
|
|
920
|
+
app.use(middleware$4);
|
|
921
|
+
app.use(middleware$6);
|
|
913
922
|
// Load handlers as needed.
|
|
914
923
|
if (this.handlers.length) {
|
|
915
924
|
for (const handler of this.handlers) {
|
|
@@ -925,9 +934,9 @@ class Integration {
|
|
|
925
934
|
process.exit(1);
|
|
926
935
|
}
|
|
927
936
|
// Must be the (last - 1) handler.
|
|
928
|
-
app.use(middleware$
|
|
937
|
+
app.use(middleware$3);
|
|
929
938
|
// Must be the last handler.
|
|
930
|
-
app.use(middleware);
|
|
939
|
+
app.use(middleware$1);
|
|
931
940
|
// Start the server.
|
|
932
941
|
this.instance = app.listen(this.port, () => console.info(`Server started on port ${this.port}.`));
|
|
933
942
|
}
|
|
@@ -976,6 +985,26 @@ class Provider {
|
|
|
976
985
|
},
|
|
977
986
|
});
|
|
978
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Performs a GET request to the provider and return the response as a ReadableStream.
|
|
990
|
+
*
|
|
991
|
+
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
|
|
992
|
+
* adds the following headers:
|
|
993
|
+
* - Accept: application/octet-stream
|
|
994
|
+
*
|
|
995
|
+
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
|
|
996
|
+
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
|
|
997
|
+
* @returns The streaming {@link Response} extracted from the provider.
|
|
998
|
+
*/
|
|
999
|
+
async streamingGet(endpoint, options) {
|
|
1000
|
+
return this.fetchWrapper(endpoint, null, {
|
|
1001
|
+
...options,
|
|
1002
|
+
method: 'GET',
|
|
1003
|
+
defaultHeaders: {
|
|
1004
|
+
Accept: 'application/octet-stream',
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
979
1008
|
/**
|
|
980
1009
|
* Performs a POST request to the provider.
|
|
981
1010
|
*
|
|
@@ -1103,13 +1132,30 @@ class Provider {
|
|
|
1103
1132
|
const textResult = await response.text();
|
|
1104
1133
|
throw buildHttpError(response.status, textResult);
|
|
1105
1134
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1135
|
+
const responseContentType = response.headers.get('content-type');
|
|
1136
|
+
let body;
|
|
1137
|
+
if (headers.Accept === 'application/json') {
|
|
1138
|
+
// Validate that the response content type is at least similar to what we expect
|
|
1139
|
+
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
1140
|
+
// Default to application/json if no Content-Type header is provided
|
|
1141
|
+
if (responseContentType && !responseContentType.includes('application/json')) {
|
|
1142
|
+
throw buildHttpError(500, `Unsupported content-type. Expected 'application/json', got '${responseContentType}'`);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
body = response.body ? await response.json() : undefined;
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
throw buildHttpError(500, `Invalid JSON response`);
|
|
1149
|
+
}
|
|
1109
1150
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1151
|
+
else if (headers.Accept == 'application/octet-stream') {
|
|
1152
|
+
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it.
|
|
1153
|
+
body = response.body;
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
throw buildHttpError(500, 'Unsupported accept header');
|
|
1112
1157
|
}
|
|
1158
|
+
return { status: response.status, headers: response.headers, body };
|
|
1113
1159
|
};
|
|
1114
1160
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
1115
1161
|
}
|
package/dist/src/integration.js
CHANGED
|
@@ -9,6 +9,7 @@ import selectsMiddleware from './middlewares/selects.js';
|
|
|
9
9
|
import errorsMiddleware from './middlewares/errors.js';
|
|
10
10
|
import finishMiddleware from './middlewares/finish.js';
|
|
11
11
|
import notFoundMiddleware from './middlewares/notFound.js';
|
|
12
|
+
import requestStartTimeMiddleware from './middlewares/requestStartTime.js';
|
|
12
13
|
import { Handler } from './handler.js';
|
|
13
14
|
function printErrorMessage(message) {
|
|
14
15
|
console.error();
|
|
@@ -112,6 +113,7 @@ export default class Integration {
|
|
|
112
113
|
app.use(express.json());
|
|
113
114
|
// Must be one of the first handlers (to catch all the errors).
|
|
114
115
|
app.use(finishMiddleware);
|
|
116
|
+
app.use(requestStartTimeMiddleware);
|
|
115
117
|
app.use(correlationIdMiddleware);
|
|
116
118
|
app.use(loggerMiddleware);
|
|
117
119
|
app.use(credentialsMiddleware);
|
|
@@ -2,8 +2,11 @@ const middleware = (req, res, next) => {
|
|
|
2
2
|
if (req.originalUrl !== '/health') {
|
|
3
3
|
res.on('finish', function () {
|
|
4
4
|
const error = res.locals.error;
|
|
5
|
-
const
|
|
5
|
+
const durationInNs = Number(process.hrtime.bigint() - res.locals.requestStartTime);
|
|
6
|
+
const durationInMs = (durationInNs / 1_000_000) | 0;
|
|
7
|
+
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${durationInMs} ms`;
|
|
6
8
|
const metadata = {
|
|
9
|
+
duration: durationInNs,
|
|
7
10
|
// Use reserved and standard attributes of Datadog
|
|
8
11
|
// https://app.datadoghq.com/logs/pipelines/standard-attributes
|
|
9
12
|
http: { method: req.method, status_code: res.statusCode, url_details: { path: req.originalUrl } },
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
declare global {
|
|
3
|
+
namespace Express {
|
|
4
|
+
interface Locals {
|
|
5
|
+
requestStartTime: bigint;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
declare const middleware: (_: Request, res: Response, next: NextFunction) => void;
|
|
10
|
+
export default middleware;
|
|
@@ -93,6 +93,18 @@ export declare class Provider {
|
|
|
93
93
|
* @returns The {@link Response} extracted from the provider.
|
|
94
94
|
*/
|
|
95
95
|
get<T>(endpoint: string, options: RequestOptions): Promise<Response<T>>;
|
|
96
|
+
/**
|
|
97
|
+
* Performs a GET request to the provider and return the response as a ReadableStream.
|
|
98
|
+
*
|
|
99
|
+
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
|
|
100
|
+
* adds the following headers:
|
|
101
|
+
* - Accept: application/octet-stream
|
|
102
|
+
*
|
|
103
|
+
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
|
|
104
|
+
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
|
|
105
|
+
* @returns The streaming {@link Response} extracted from the provider.
|
|
106
|
+
*/
|
|
107
|
+
streamingGet(endpoint: string, options: RequestOptions): Promise<Response<ReadableStream<Uint8Array>>>;
|
|
96
108
|
/**
|
|
97
109
|
* Performs a POST request to the provider.
|
|
98
110
|
*
|
|
@@ -42,6 +42,26 @@ export class Provider {
|
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Performs a GET request to the provider and return the response as a ReadableStream.
|
|
47
|
+
*
|
|
48
|
+
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
|
|
49
|
+
* adds the following headers:
|
|
50
|
+
* - Accept: application/octet-stream
|
|
51
|
+
*
|
|
52
|
+
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
|
|
53
|
+
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
|
|
54
|
+
* @returns The streaming {@link Response} extracted from the provider.
|
|
55
|
+
*/
|
|
56
|
+
async streamingGet(endpoint, options) {
|
|
57
|
+
return this.fetchWrapper(endpoint, null, {
|
|
58
|
+
...options,
|
|
59
|
+
method: 'GET',
|
|
60
|
+
defaultHeaders: {
|
|
61
|
+
Accept: 'application/octet-stream',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
45
65
|
/**
|
|
46
66
|
* Performs a POST request to the provider.
|
|
47
67
|
*
|
|
@@ -169,13 +189,30 @@ export class Provider {
|
|
|
169
189
|
const textResult = await response.text();
|
|
170
190
|
throw buildHttpError(response.status, textResult);
|
|
171
191
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
const responseContentType = response.headers.get('content-type');
|
|
193
|
+
let body;
|
|
194
|
+
if (headers.Accept === 'application/json') {
|
|
195
|
+
// Validate that the response content type is at least similar to what we expect
|
|
196
|
+
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
197
|
+
// Default to application/json if no Content-Type header is provided
|
|
198
|
+
if (responseContentType && !responseContentType.includes('application/json')) {
|
|
199
|
+
throw buildHttpError(500, `Unsupported content-type. Expected 'application/json', got '${responseContentType}'`);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
body = response.body ? await response.json() : undefined;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
throw buildHttpError(500, `Invalid JSON response`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (headers.Accept == 'application/octet-stream') {
|
|
209
|
+
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it.
|
|
210
|
+
body = response.body;
|
|
175
211
|
}
|
|
176
|
-
|
|
177
|
-
throw buildHttpError(
|
|
212
|
+
else {
|
|
213
|
+
throw buildHttpError(500, 'Unsupported accept header');
|
|
178
214
|
}
|
|
215
|
+
return { status: response.status, headers: response.headers, body };
|
|
179
216
|
};
|
|
180
217
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
181
218
|
}
|
|
@@ -11,6 +11,7 @@ describe('finish middleware', () => {
|
|
|
11
11
|
eventHandler = func;
|
|
12
12
|
},
|
|
13
13
|
locals: {
|
|
14
|
+
requestStartTime: 0n,
|
|
14
15
|
logger: {
|
|
15
16
|
info: (_message) => {
|
|
16
17
|
expected = 'works!';
|
|
@@ -32,6 +33,7 @@ describe('finish middleware', () => {
|
|
|
32
33
|
eventHandler = func;
|
|
33
34
|
},
|
|
34
35
|
locals: {
|
|
36
|
+
requestStartTime: 0n,
|
|
35
37
|
logger: {
|
|
36
38
|
error: (_message) => {
|
|
37
39
|
expected = 'works!';
|
|
@@ -53,6 +55,7 @@ describe('finish middleware', () => {
|
|
|
53
55
|
eventHandler = func;
|
|
54
56
|
},
|
|
55
57
|
locals: {
|
|
58
|
+
requestStartTime: 0n,
|
|
56
59
|
logger: {
|
|
57
60
|
info: (_message) => {
|
|
58
61
|
expected = 'ohoh!';
|
|
@@ -222,9 +222,52 @@ describe('Provider', () => {
|
|
|
222
222
|
]);
|
|
223
223
|
assert.deepEqual(actualResponse, { status: 204, headers: response.headers, body: undefined });
|
|
224
224
|
});
|
|
225
|
+
it('returns valid json response', async (context) => {
|
|
226
|
+
const response = new Response(`{ "validJson": true }`, {
|
|
227
|
+
status: 200,
|
|
228
|
+
headers: { 'Content-Type': 'application/json;charset=utf-8' },
|
|
229
|
+
});
|
|
230
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
231
|
+
const providerResponse = await provider.get('/endpoint/123', {
|
|
232
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
233
|
+
logger: logger,
|
|
234
|
+
signal: new AbortController().signal,
|
|
235
|
+
});
|
|
236
|
+
assert.ok(providerResponse);
|
|
237
|
+
assert.ok(providerResponse.body);
|
|
238
|
+
assert.equal(providerResponse.body.validJson, true);
|
|
239
|
+
});
|
|
240
|
+
it('returns successfully on missing Content-Type header', async (context) => {
|
|
241
|
+
const response = new Response(undefined, {
|
|
242
|
+
status: 201,
|
|
243
|
+
});
|
|
244
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
245
|
+
const providerResponse = await provider.get('/endpoint/123', {
|
|
246
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
247
|
+
logger: logger,
|
|
248
|
+
signal: new AbortController().signal,
|
|
249
|
+
});
|
|
250
|
+
assert.ok(providerResponse);
|
|
251
|
+
assert.equal(providerResponse.body, undefined);
|
|
252
|
+
});
|
|
253
|
+
it('returns streamable response on streaming get calls', async (context) => {
|
|
254
|
+
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
|
|
255
|
+
status: 200,
|
|
256
|
+
headers: { 'Content-Type': 'video/mp4' },
|
|
257
|
+
});
|
|
258
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
259
|
+
const providerResponse = await provider.streamingGet('/endpoint/123', {
|
|
260
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
261
|
+
logger: logger,
|
|
262
|
+
signal: new AbortController().signal,
|
|
263
|
+
});
|
|
264
|
+
assert.ok(providerResponse);
|
|
265
|
+
assert.ok(providerResponse.body instanceof ReadableStream);
|
|
266
|
+
});
|
|
225
267
|
it('throws on invalid json response', async (context) => {
|
|
226
268
|
const response = new Response('{invalidJSON}', {
|
|
227
269
|
status: 200,
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
271
|
});
|
|
229
272
|
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
230
273
|
let error;
|
|
@@ -238,9 +281,29 @@ describe('Provider', () => {
|
|
|
238
281
|
catch (e) {
|
|
239
282
|
error = e;
|
|
240
283
|
}
|
|
241
|
-
assert.ok(error instanceof HttpErrors.
|
|
284
|
+
assert.ok(error instanceof HttpErrors.HttpError);
|
|
242
285
|
assert.equal(error.message, 'Invalid JSON response');
|
|
243
286
|
});
|
|
287
|
+
it('throws on unexpected content-type response', async (context) => {
|
|
288
|
+
const response = new Response('text', {
|
|
289
|
+
status: 200,
|
|
290
|
+
headers: { 'Content-Type': 'application/text' },
|
|
291
|
+
});
|
|
292
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
293
|
+
let error;
|
|
294
|
+
try {
|
|
295
|
+
await provider.get('/endpoint/123', {
|
|
296
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
297
|
+
logger: logger,
|
|
298
|
+
signal: new AbortController().signal,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
error = e;
|
|
303
|
+
}
|
|
304
|
+
assert.ok(error instanceof HttpErrors.HttpError);
|
|
305
|
+
assert.equal(error.message, `Unsupported content-type. Expected 'application/json', got 'application/text'`);
|
|
306
|
+
});
|
|
244
307
|
it('throws on status 400', async (context) => {
|
|
245
308
|
const response = new Response('response body', {
|
|
246
309
|
status: 400,
|
package/package.json
CHANGED
package/src/integration.ts
CHANGED
|
@@ -11,6 +11,7 @@ import selectsMiddleware from './middlewares/selects.js';
|
|
|
11
11
|
import errorsMiddleware from './middlewares/errors.js';
|
|
12
12
|
import finishMiddleware from './middlewares/finish.js';
|
|
13
13
|
import notFoundMiddleware from './middlewares/notFound.js';
|
|
14
|
+
import requestStartTimeMiddleware from './middlewares/requestStartTime.js';
|
|
14
15
|
import { HandlersInput, Handler } from './handler.js';
|
|
15
16
|
|
|
16
17
|
function printErrorMessage(message: string) {
|
|
@@ -128,6 +129,7 @@ export default class Integration {
|
|
|
128
129
|
// Must be one of the first handlers (to catch all the errors).
|
|
129
130
|
app.use(finishMiddleware);
|
|
130
131
|
|
|
132
|
+
app.use(requestStartTimeMiddleware);
|
|
131
133
|
app.use(correlationIdMiddleware);
|
|
132
134
|
app.use(loggerMiddleware);
|
|
133
135
|
app.use(credentialsMiddleware);
|
|
@@ -8,6 +8,7 @@ declare global {
|
|
|
8
8
|
interface Locals {
|
|
9
9
|
logger: Logger;
|
|
10
10
|
error?: ApiError;
|
|
11
|
+
requestStartTime: bigint;
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
}
|
|
@@ -17,8 +18,12 @@ const middleware = (req: Request, res: Response, next: NextFunction) => {
|
|
|
17
18
|
res.on('finish', function () {
|
|
18
19
|
const error = res.locals.error;
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
+
const durationInNs = Number(process.hrtime.bigint() - res.locals.requestStartTime);
|
|
22
|
+
const durationInMs = (durationInNs / 1_000_000) | 0;
|
|
23
|
+
|
|
24
|
+
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${durationInMs} ms`;
|
|
21
25
|
const metadata: Metadata = {
|
|
26
|
+
duration: durationInNs,
|
|
22
27
|
// Use reserved and standard attributes of Datadog
|
|
23
28
|
// https://app.datadoghq.com/logs/pipelines/standard-attributes
|
|
24
29
|
http: { method: req.method, status_code: res.statusCode, url_details: { path: req.originalUrl } },
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
5
|
+
namespace Express {
|
|
6
|
+
interface Locals {
|
|
7
|
+
requestStartTime: bigint;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const middleware = (_: Request, res: Response, next: NextFunction) => {
|
|
13
|
+
res.locals.requestStartTime = process.hrtime.bigint();
|
|
14
|
+
|
|
15
|
+
next();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default middleware;
|
|
@@ -102,6 +102,27 @@ export class Provider {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Performs a GET request to the provider and return the response as a ReadableStream.
|
|
107
|
+
*
|
|
108
|
+
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
|
|
109
|
+
* adds the following headers:
|
|
110
|
+
* - Accept: application/octet-stream
|
|
111
|
+
*
|
|
112
|
+
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
|
|
113
|
+
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
|
|
114
|
+
* @returns The streaming {@link Response} extracted from the provider.
|
|
115
|
+
*/
|
|
116
|
+
public async streamingGet(endpoint: string, options: RequestOptions): Promise<Response<ReadableStream<Uint8Array>>> {
|
|
117
|
+
return this.fetchWrapper<ReadableStream<Uint8Array>>(endpoint, null, {
|
|
118
|
+
...options,
|
|
119
|
+
method: 'GET',
|
|
120
|
+
defaultHeaders: {
|
|
121
|
+
Accept: 'application/octet-stream',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
/**
|
|
106
127
|
* Performs a POST request to the provider.
|
|
107
128
|
*
|
|
@@ -249,12 +270,33 @@ export class Provider {
|
|
|
249
270
|
throw buildHttpError(response.status, textResult);
|
|
250
271
|
}
|
|
251
272
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
const responseContentType = response.headers.get('content-type');
|
|
274
|
+
let body: T;
|
|
275
|
+
|
|
276
|
+
if (headers.Accept === 'application/json') {
|
|
277
|
+
// Validate that the response content type is at least similar to what we expect
|
|
278
|
+
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
279
|
+
// Default to application/json if no Content-Type header is provided
|
|
280
|
+
if (responseContentType && !responseContentType.includes('application/json')) {
|
|
281
|
+
throw buildHttpError(
|
|
282
|
+
500,
|
|
283
|
+
`Unsupported content-type. Expected 'application/json', got '${responseContentType}'`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
body = response.body ? await response.json() : undefined;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
throw buildHttpError(500, `Invalid JSON response`);
|
|
291
|
+
}
|
|
292
|
+
} else if (headers.Accept == 'application/octet-stream') {
|
|
293
|
+
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it.
|
|
294
|
+
body = response.body as T;
|
|
295
|
+
} else {
|
|
296
|
+
throw buildHttpError(500, 'Unsupported accept header');
|
|
257
297
|
}
|
|
298
|
+
|
|
299
|
+
return { status: response.status, headers: response.headers, body };
|
|
258
300
|
};
|
|
259
301
|
|
|
260
302
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
package/test/handler.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
|
4
4
|
import {
|
|
@@ -80,7 +80,7 @@ describe('Handler', () => {
|
|
|
80
80
|
|
|
81
81
|
describe('generate', () => {
|
|
82
82
|
async function executeHandler(
|
|
83
|
-
handler: (req: Request, res: Response) => Promise<void>,
|
|
83
|
+
handler: (req: Request, res: Response, callback: NextFunction) => Promise<void>,
|
|
84
84
|
request: Record<string, unknown> = {},
|
|
85
85
|
) {
|
|
86
86
|
let body: Record<string, unknown> = {};
|
|
@@ -99,7 +99,7 @@ describe('Handler', () => {
|
|
|
99
99
|
},
|
|
100
100
|
} as unknown as Response;
|
|
101
101
|
|
|
102
|
-
await handler(request as unknown as Request, response);
|
|
102
|
+
await handler(request as unknown as Request, response, () => {});
|
|
103
103
|
|
|
104
104
|
return { body, statusCode };
|
|
105
105
|
}
|
|
@@ -117,43 +117,43 @@ describe('Handler', () => {
|
|
|
117
117
|
const routes = router.stack;
|
|
118
118
|
|
|
119
119
|
assert.equal(routes.length, 5);
|
|
120
|
-
assert.equal(routes[0]
|
|
121
|
-
assert.equal(routes[0]
|
|
122
|
-
assert.equal(routes[1]
|
|
123
|
-
assert.equal(routes[1]
|
|
124
|
-
assert.equal(routes[2]
|
|
125
|
-
assert.equal(routes[2]
|
|
126
|
-
assert.equal(routes[3]
|
|
127
|
-
assert.equal(routes[3]
|
|
128
|
-
assert.equal(routes[4]
|
|
129
|
-
assert.equal(routes[4]
|
|
120
|
+
assert.equal(routes[0]!.route!.path, '/foo');
|
|
121
|
+
assert.equal((routes[0]!.route! as any).methods.get, true);
|
|
122
|
+
assert.equal(routes[1]!.route!.path, '/foo');
|
|
123
|
+
assert.equal((routes[1]!.route! as any).methods.post, true);
|
|
124
|
+
assert.equal(routes[2]!.route!.path, '/foo/:bar');
|
|
125
|
+
assert.equal((routes[2]!.route! as any).methods.get, true);
|
|
126
|
+
assert.equal(routes[3]!.route!.path, '/foo/:bar');
|
|
127
|
+
assert.equal((routes[3]!.route! as any).methods.patch, true);
|
|
128
|
+
assert.equal(routes[4]!.route!.path, '/foo/:bar');
|
|
129
|
+
assert.equal((routes[4]!.route! as any).methods.delete, true);
|
|
130
130
|
|
|
131
131
|
// GetCollection.
|
|
132
|
-
assert.deepEqual(await executeHandler(routes[0]
|
|
132
|
+
assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), {
|
|
133
133
|
body: { info: {}, data: [] },
|
|
134
134
|
statusCode: 200,
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
// CreateItem.
|
|
138
|
-
assert.deepEqual(await executeHandler(routes[1]
|
|
138
|
+
assert.deepEqual(await executeHandler(routes[1]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), {
|
|
139
139
|
body: { fields: {}, path: '/' },
|
|
140
140
|
statusCode: 201,
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
// GetItem.
|
|
144
|
-
assert.deepEqual(await executeHandler(routes[2]
|
|
144
|
+
assert.deepEqual(await executeHandler(routes[2]!.route!.stack[0]!.handle), {
|
|
145
145
|
body: { fields: {}, relations: [] },
|
|
146
146
|
statusCode: 200,
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
// UpdateItem.
|
|
150
|
-
assert.deepEqual(await executeHandler(routes[3]
|
|
150
|
+
assert.deepEqual(await executeHandler(routes[3]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), {
|
|
151
151
|
body: { fields: {}, relations: [] },
|
|
152
152
|
statusCode: 200,
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
// DeleteItem.
|
|
156
|
-
assert.deepEqual(await executeHandler(routes[4]
|
|
156
|
+
assert.deepEqual(await executeHandler(routes[4]!.route!.stack[0]!.handle), {
|
|
157
157
|
body: null,
|
|
158
158
|
statusCode: 204,
|
|
159
159
|
});
|
|
@@ -168,11 +168,11 @@ describe('Handler', () => {
|
|
|
168
168
|
const routes = router.stack;
|
|
169
169
|
|
|
170
170
|
assert.equal(routes.length, 1);
|
|
171
|
-
assert.equal(routes[0]
|
|
172
|
-
assert.equal(routes[0]
|
|
171
|
+
assert.equal(routes[0]!.route!.path, '/foo/:bar/baz');
|
|
172
|
+
assert.equal((routes[0]!.route as any).methods.get, true);
|
|
173
173
|
|
|
174
174
|
// GetCollection.
|
|
175
|
-
assert.deepEqual(await executeHandler(routes[0]
|
|
175
|
+
assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), {
|
|
176
176
|
body: { info: {}, data: [] },
|
|
177
177
|
statusCode: 200,
|
|
178
178
|
});
|
|
@@ -197,13 +197,13 @@ describe('Handler', () => {
|
|
|
197
197
|
const routes = router.stack;
|
|
198
198
|
|
|
199
199
|
// CreateItemRequestPayload.
|
|
200
|
-
const createHandler = routes[0]
|
|
200
|
+
const createHandler = routes[0]!.route!.stack[0]!.handle;
|
|
201
201
|
await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
|
|
202
202
|
await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
|
|
203
203
|
await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
|
|
204
204
|
|
|
205
205
|
// UpdateItemRequestPayload.
|
|
206
|
-
const updateHandler = routes[0]
|
|
206
|
+
const updateHandler = routes[0]!.route!.stack[0]!.handle;
|
|
207
207
|
await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
|
|
208
208
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
|
|
209
209
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
|
|
@@ -14,6 +14,7 @@ describe('finish middleware', () => {
|
|
|
14
14
|
eventHandler = func;
|
|
15
15
|
},
|
|
16
16
|
locals: {
|
|
17
|
+
requestStartTime: 0n,
|
|
17
18
|
logger: {
|
|
18
19
|
info: (_message: string) => {
|
|
19
20
|
expected = 'works!';
|
|
@@ -39,6 +40,7 @@ describe('finish middleware', () => {
|
|
|
39
40
|
eventHandler = func;
|
|
40
41
|
},
|
|
41
42
|
locals: {
|
|
43
|
+
requestStartTime: 0n,
|
|
42
44
|
logger: {
|
|
43
45
|
error: (_message: string) => {
|
|
44
46
|
expected = 'works!';
|
|
@@ -64,6 +66,7 @@ describe('finish middleware', () => {
|
|
|
64
66
|
eventHandler = func;
|
|
65
67
|
},
|
|
66
68
|
locals: {
|
|
69
|
+
requestStartTime: 0n,
|
|
67
70
|
logger: {
|
|
68
71
|
info: (_message: string) => {
|
|
69
72
|
expected = 'ohoh!';
|
|
@@ -266,9 +266,64 @@ describe('Provider', () => {
|
|
|
266
266
|
assert.deepEqual(actualResponse, { status: 204, headers: response.headers, body: undefined });
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
it('returns valid json response', async context => {
|
|
270
|
+
const response = new Response(`{ "validJson": true }`, {
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: { 'Content-Type': 'application/json;charset=utf-8' },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
276
|
+
|
|
277
|
+
const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
|
|
278
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
279
|
+
logger: logger,
|
|
280
|
+
signal: new AbortController().signal,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
assert.ok(providerResponse);
|
|
284
|
+
assert.ok(providerResponse.body);
|
|
285
|
+
assert.equal(providerResponse.body.validJson, true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('returns successfully on missing Content-Type header', async context => {
|
|
289
|
+
const response = new Response(undefined, {
|
|
290
|
+
status: 201,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
294
|
+
|
|
295
|
+
const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
|
|
296
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
297
|
+
logger: logger,
|
|
298
|
+
signal: new AbortController().signal,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
assert.ok(providerResponse);
|
|
302
|
+
assert.equal(providerResponse.body, undefined);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('returns streamable response on streaming get calls', async context => {
|
|
306
|
+
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
|
|
307
|
+
status: 200,
|
|
308
|
+
headers: { 'Content-Type': 'video/mp4' },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
312
|
+
|
|
313
|
+
const providerResponse = await provider.streamingGet('/endpoint/123', {
|
|
314
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
315
|
+
logger: logger,
|
|
316
|
+
signal: new AbortController().signal,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
assert.ok(providerResponse);
|
|
320
|
+
assert.ok(providerResponse.body instanceof ReadableStream);
|
|
321
|
+
});
|
|
322
|
+
|
|
269
323
|
it('throws on invalid json response', async context => {
|
|
270
324
|
const response = new Response('{invalidJSON}', {
|
|
271
325
|
status: 200,
|
|
326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
272
327
|
});
|
|
273
328
|
|
|
274
329
|
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
@@ -285,10 +340,34 @@ describe('Provider', () => {
|
|
|
285
340
|
error = e;
|
|
286
341
|
}
|
|
287
342
|
|
|
288
|
-
assert.ok(error instanceof HttpErrors.
|
|
343
|
+
assert.ok(error instanceof HttpErrors.HttpError);
|
|
289
344
|
assert.equal(error.message, 'Invalid JSON response');
|
|
290
345
|
});
|
|
291
346
|
|
|
347
|
+
it('throws on unexpected content-type response', async context => {
|
|
348
|
+
const response = new Response('text', {
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: { 'Content-Type': 'application/text' },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
354
|
+
|
|
355
|
+
let error;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await provider.get('/endpoint/123', {
|
|
359
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
360
|
+
logger: logger,
|
|
361
|
+
signal: new AbortController().signal,
|
|
362
|
+
});
|
|
363
|
+
} catch (e) {
|
|
364
|
+
error = e;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
assert.ok(error instanceof HttpErrors.HttpError);
|
|
368
|
+
assert.equal(error.message, `Unsupported content-type. Expected 'application/json', got 'application/text'`);
|
|
369
|
+
});
|
|
370
|
+
|
|
292
371
|
it('throws on status 400', async context => {
|
|
293
372
|
const response = new Response('response body', {
|
|
294
373
|
status: 400,
|