@unito/integration-sdk 1.0.4 → 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 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
- "@typescript-eslint/no-unused-vars": 'off',
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
  };
@@ -0,0 +1,15 @@
1
+ import { FieldSchema } from '@unito/integration-api';
2
+ import { Filter } from './index.js';
3
+ /**
4
+ * Use this helper function to retrieve the applicable filters from the context object. While using filters
5
+ * directly from context might work, it doesn't offer any guarantees about the shape of the filters nor the
6
+ * validity of the fields against which the filters are applied. On the other hand, this function ensures that
7
+ * all filters are valid and that the fields against which the filters are applied are present in the schema.
8
+ *
9
+ * @param context The object containing the raw filters
10
+ * @param fields The schema of the item against which the filters are applied
11
+ * @returns The validated filters
12
+ */
13
+ export declare const getApplicableFilters: (context: {
14
+ filters: Filter[];
15
+ }, fields: FieldSchema[]) => Filter[];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Use this helper function to retrieve the applicable filters from the context object. While using filters
3
+ * directly from context might work, it doesn't offer any guarantees about the shape of the filters nor the
4
+ * validity of the fields against which the filters are applied. On the other hand, this function ensures that
5
+ * all filters are valid and that the fields against which the filters are applied are present in the schema.
6
+ *
7
+ * @param context The object containing the raw filters
8
+ * @param fields The schema of the item against which the filters are applied
9
+ * @returns The validated filters
10
+ */
11
+ export const getApplicableFilters = (context, fields) => {
12
+ const applicableFilters = [];
13
+ for (const filter of context.filters) {
14
+ let field = undefined;
15
+ const filterFieldParts = filter.field.split(':', 2);
16
+ switch (filterFieldParts[0]) {
17
+ case 'semantic':
18
+ field = fields.find(f => f.semantic === filterFieldParts[1]);
19
+ break;
20
+ default:
21
+ field = fields.find(f => f.name === filterFieldParts[0]);
22
+ }
23
+ if (field) {
24
+ applicableFilters.push({ ...filter, field: field.name });
25
+ }
26
+ }
27
+ return applicableFilters;
28
+ };
@@ -353,13 +353,13 @@ function buildHttpError(responseStatus, message) {
353
353
  return httpError;
354
354
  }
355
355
 
356
- const middleware$8 = (req, res, next) => {
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$7 = (req, res, next) => {
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$6 = (req, res, next) => {
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$5 = (req, res, next) => {
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$4 = (req, res, next) => {
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$3 = (req, res, next) => {
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$2 = (err, _req, res, next) => {
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$1 = (req, res, next) => {
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 message = `${req.method} ${req.originalUrl} ${res.statusCode}`;
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$1);
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$2);
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,21 +1132,68 @@ class Provider {
1103
1132
  const textResult = await response.text();
1104
1133
  throw buildHttpError(response.status, textResult);
1105
1134
  }
1106
- try {
1107
- const body = response.body ? await response.json() : undefined;
1108
- return { status: response.status, headers: response.headers, body };
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
- catch {
1111
- throw buildHttpError(400, 'Invalid JSON response');
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;
1112
1154
  }
1155
+ else {
1156
+ throw buildHttpError(500, 'Unsupported accept header');
1157
+ }
1158
+ return { status: response.status, headers: response.headers, body };
1113
1159
  };
1114
1160
  return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
1115
1161
  }
1116
1162
  }
1117
1163
 
1164
+ /**
1165
+ * Use this helper function to retrieve the applicable filters from the context object. While using filters
1166
+ * directly from context might work, it doesn't offer any guarantees about the shape of the filters nor the
1167
+ * validity of the fields against which the filters are applied. On the other hand, this function ensures that
1168
+ * all filters are valid and that the fields against which the filters are applied are present in the schema.
1169
+ *
1170
+ * @param context The object containing the raw filters
1171
+ * @param fields The schema of the item against which the filters are applied
1172
+ * @returns The validated filters
1173
+ */
1174
+ const getApplicableFilters = (context, fields) => {
1175
+ const applicableFilters = [];
1176
+ for (const filter of context.filters) {
1177
+ let field = undefined;
1178
+ const filterFieldParts = filter.field.split(':', 2);
1179
+ switch (filterFieldParts[0]) {
1180
+ case 'semantic':
1181
+ field = fields.find(f => f.semantic === filterFieldParts[1]);
1182
+ break;
1183
+ default:
1184
+ field = fields.find(f => f.name === filterFieldParts[0]);
1185
+ }
1186
+ if (field) {
1187
+ applicableFilters.push({ ...filter, field: field.name });
1188
+ }
1189
+ }
1190
+ return applicableFilters;
1191
+ };
1192
+
1118
1193
  exports.Api = integrationApi__namespace;
1119
1194
  exports.Cache = Cache;
1120
1195
  exports.Handler = Handler;
1121
1196
  exports.HttpErrors = httpErrors;
1122
1197
  exports.Integration = Integration;
1123
1198
  exports.Provider = Provider;
1199
+ exports.getApplicableFilters = getApplicableFilters;
@@ -5,6 +5,8 @@ export * from './handler.js';
5
5
  export { Provider, type Response as ProviderResponse, type RequestOptions as ProviderRequestOptions, type RateLimiter, } from './resources/provider.js';
6
6
  export type { Secrets } from './middlewares/secrets.js';
7
7
  export type { Credentials } from './middlewares/credentials.js';
8
+ export type { Filter } from './middlewares/filters.js';
8
9
  export * as HttpErrors from './httpErrors.js';
10
+ export { getApplicableFilters } from './helpers.js';
9
11
  export * from './resources/context.js';
10
12
  export { type default as Logger } from './resources/logger.js';
package/dist/src/index.js CHANGED
@@ -5,5 +5,6 @@ export { default as Integration } from './integration.js';
5
5
  export * from './handler.js';
6
6
  export { Provider, } from './resources/provider.js';
7
7
  export * as HttpErrors from './httpErrors.js';
8
+ export { getApplicableFilters } from './helpers.js';
8
9
  export * from './resources/context.js';
9
10
  /* c8 ignore stop */
@@ -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);
@@ -6,6 +6,7 @@ declare global {
6
6
  interface Locals {
7
7
  logger: Logger;
8
8
  error?: ApiError;
9
+ requestStartTime: bigint;
9
10
  }
10
11
  }
11
12
  }
@@ -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 message = `${req.method} ${req.originalUrl} ${res.statusCode}`;
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 } },
@@ -3,8 +3,8 @@ import Logger from '../resources/logger.js';
3
3
  declare global {
4
4
  namespace Express {
5
5
  interface Locals {
6
- correlationId: string;
7
6
  logger: Logger;
7
+ correlationId: string;
8
8
  }
9
9
  }
10
10
  }
@@ -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;
@@ -0,0 +1,5 @@
1
+ const middleware = (_, res, next) => {
2
+ res.locals.requestStartTime = process.hrtime.bigint();
3
+ next();
4
+ };
5
+ 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
- try {
173
- const body = response.body ? await response.json() : undefined;
174
- return { status: response.status, headers: response.headers, body };
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
- catch {
177
- throw buildHttpError(400, 'Invalid JSON response');
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
  }
@@ -77,7 +77,7 @@ describe('Handler', () => {
77
77
  credentials: { foo: 'bar' },
78
78
  },
79
79
  };
80
- await handler(request, response);
80
+ await handler(request, response, () => { });
81
81
  return { body, statusCode };
82
82
  }
83
83
  it('returns a router', async () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { FieldValueType, OperatorType, Semantic } from '@unito/integration-api';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+ import { getApplicableFilters } from '../src/helpers.js';
5
+ describe('Helpers', () => {
6
+ describe('getApplicableFilters', () => {
7
+ it('returns only filters for defined fields', () => {
8
+ const actual = getApplicableFilters({
9
+ filters: [
10
+ { field: 'status', operator: OperatorType.EQUAL, values: ['active', 'pending'] },
11
+ { field: 'email', operator: OperatorType.IS_NOT_NULL, values: [] },
12
+ ],
13
+ }, [
14
+ {
15
+ name: 'status',
16
+ label: 'Status',
17
+ type: FieldValueType.STRING,
18
+ },
19
+ ]);
20
+ const expected = [{ field: 'status', operator: OperatorType.EQUAL, values: ['active', 'pending'] }];
21
+ assert.deepEqual(actual, expected);
22
+ });
23
+ it('translates semantics into field names', () => {
24
+ const actual = getApplicableFilters({
25
+ filters: [
26
+ { field: 'semantic:displayName', operator: OperatorType.START_WITH, values: ['Bob'] },
27
+ { field: 'semantic:createdAt', operator: OperatorType.EQUAL, values: ['2021-01-01'] },
28
+ ],
29
+ }, [
30
+ {
31
+ name: 'name',
32
+ label: 'Name',
33
+ type: FieldValueType.STRING,
34
+ semantic: Semantic.DISPLAY_NAME,
35
+ },
36
+ ]);
37
+ const expected = [{ field: 'name', operator: OperatorType.START_WITH, values: ['Bob'] }];
38
+ assert.deepEqual(actual, expected);
39
+ });
40
+ it('gracefully handle garbage', () => {
41
+ const actual = getApplicableFilters({
42
+ filters: [
43
+ { field: '...', operator: OperatorType.EQUAL, values: [] },
44
+ { field: ':', operator: OperatorType.EQUAL, values: [] },
45
+ { field: '', operator: OperatorType.EQUAL, values: [] },
46
+ ],
47
+ }, [
48
+ {
49
+ name: 'status',
50
+ label: 'Status',
51
+ type: FieldValueType.STRING,
52
+ },
53
+ ]);
54
+ assert.deepEqual(actual, []);
55
+ });
56
+ });
57
+ });
@@ -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.BadRequestError);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
package/src/helpers.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { FieldSchema } from '@unito/integration-api';
2
+
3
+ import { Filter } from './index.js';
4
+
5
+ /**
6
+ * Use this helper function to retrieve the applicable filters from the context object. While using filters
7
+ * directly from context might work, it doesn't offer any guarantees about the shape of the filters nor the
8
+ * validity of the fields against which the filters are applied. On the other hand, this function ensures that
9
+ * all filters are valid and that the fields against which the filters are applied are present in the schema.
10
+ *
11
+ * @param context The object containing the raw filters
12
+ * @param fields The schema of the item against which the filters are applied
13
+ * @returns The validated filters
14
+ */
15
+ export const getApplicableFilters = (context: { filters: Filter[] }, fields: FieldSchema[]): Filter[] => {
16
+ const applicableFilters: Filter[] = [];
17
+
18
+ for (const filter of context.filters) {
19
+ let field: FieldSchema | undefined = undefined;
20
+
21
+ const filterFieldParts = filter.field.split(':', 2);
22
+
23
+ switch (filterFieldParts[0]) {
24
+ case 'semantic':
25
+ field = fields.find(f => f.semantic === filterFieldParts[1]);
26
+ break;
27
+ default:
28
+ field = fields.find(f => f.name === filterFieldParts[0]);
29
+ }
30
+
31
+ if (field) {
32
+ applicableFilters.push({ ...filter, field: field.name });
33
+ }
34
+ }
35
+
36
+ return applicableFilters;
37
+ };
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ export {
11
11
  } from './resources/provider.js';
12
12
  export type { Secrets } from './middlewares/secrets.js';
13
13
  export type { Credentials } from './middlewares/credentials.js';
14
+ export type { Filter } from './middlewares/filters.js';
14
15
  export * as HttpErrors from './httpErrors.js';
16
+ export { getApplicableFilters } from './helpers.js';
15
17
  export * from './resources/context.js';
16
18
  export { type default as Logger } from './resources/logger.js';
17
19
  /* c8 ignore stop */
@@ -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 message = `${req.method} ${req.originalUrl} ${res.statusCode}`;
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 } },
@@ -5,8 +5,8 @@ declare global {
5
5
  // eslint-disable-next-line @typescript-eslint/no-namespace
6
6
  namespace Express {
7
7
  interface Locals {
8
- correlationId: string;
9
8
  logger: Logger;
9
+ correlationId: string;
10
10
  }
11
11
  }
12
12
  }
@@ -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
- try {
253
- const body: T = response.body ? await response.json() : undefined;
254
- return { status: response.status, headers: response.headers, body };
255
- } catch {
256
- throw buildHttpError(400, 'Invalid JSON response');
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();
@@ -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].route.path, '/foo');
121
- assert.equal(routes[0].route.methods.get, true);
122
- assert.equal(routes[1].route.path, '/foo');
123
- assert.equal(routes[1].route.methods.post, true);
124
- assert.equal(routes[2].route.path, '/foo/:bar');
125
- assert.equal(routes[2].route.methods.get, true);
126
- assert.equal(routes[3].route.path, '/foo/:bar');
127
- assert.equal(routes[3].route.methods.patch, true);
128
- assert.equal(routes[4].route.path, '/foo/:bar');
129
- assert.equal(routes[4].route.methods.delete, true);
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].route.stack[0].handle), {
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].route.stack[0].handle, { body: { foo: 'bar' } }), {
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].route.stack[0].handle), {
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].route.stack[0].handle, { body: { foo: 'bar' } }), {
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].route.stack[0].handle), {
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].route.path, '/foo/:bar/baz');
172
- assert.equal(routes[0].route.methods.get, true);
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].route.stack[0].handle), {
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].route.stack[0].handle;
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].route.stack[0].handle;
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);
@@ -0,0 +1,75 @@
1
+ import { FieldValueType, OperatorType, Semantic } from '@unito/integration-api';
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { describe, it } from 'node:test';
5
+ import { getApplicableFilters } from '../src/helpers.js';
6
+
7
+ describe('Helpers', () => {
8
+ describe('getApplicableFilters', () => {
9
+ it('returns only filters for defined fields', () => {
10
+ const actual = getApplicableFilters(
11
+ {
12
+ filters: [
13
+ { field: 'status', operator: OperatorType.EQUAL, values: ['active', 'pending'] },
14
+ { field: 'email', operator: OperatorType.IS_NOT_NULL, values: [] },
15
+ ],
16
+ },
17
+ [
18
+ {
19
+ name: 'status',
20
+ label: 'Status',
21
+ type: FieldValueType.STRING,
22
+ },
23
+ ],
24
+ );
25
+
26
+ const expected = [{ field: 'status', operator: OperatorType.EQUAL, values: ['active', 'pending'] }];
27
+
28
+ assert.deepEqual(actual, expected);
29
+ });
30
+
31
+ it('translates semantics into field names', () => {
32
+ const actual = getApplicableFilters(
33
+ {
34
+ filters: [
35
+ { field: 'semantic:displayName', operator: OperatorType.START_WITH, values: ['Bob'] },
36
+ { field: 'semantic:createdAt', operator: OperatorType.EQUAL, values: ['2021-01-01'] },
37
+ ],
38
+ },
39
+ [
40
+ {
41
+ name: 'name',
42
+ label: 'Name',
43
+ type: FieldValueType.STRING,
44
+ semantic: Semantic.DISPLAY_NAME,
45
+ },
46
+ ],
47
+ );
48
+
49
+ const expected = [{ field: 'name', operator: OperatorType.START_WITH, values: ['Bob'] }];
50
+
51
+ assert.deepEqual(actual, expected);
52
+ });
53
+
54
+ it('gracefully handle garbage', () => {
55
+ const actual = getApplicableFilters(
56
+ {
57
+ filters: [
58
+ { field: '...', operator: OperatorType.EQUAL, values: [] },
59
+ { field: ':', operator: OperatorType.EQUAL, values: [] },
60
+ { field: '', operator: OperatorType.EQUAL, values: [] },
61
+ ],
62
+ },
63
+ [
64
+ {
65
+ name: 'status',
66
+ label: 'Status',
67
+ type: FieldValueType.STRING,
68
+ },
69
+ ],
70
+ );
71
+
72
+ assert.deepEqual(actual, []);
73
+ });
74
+ });
75
+ });
@@ -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.BadRequestError);
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,