@unito/integration-sdk 1.0.14 → 1.0.16

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.
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import * as API from '@unito/integration-api';
3
- import { GetBlobContext, GetItemContext, GetCollectionContext, CreateItemContext, UpdateItemContext, DeleteItemContext, GetCredentialAccountContext, ParseWebhooksContext, UpdateWebhookSubscriptionsContext, AcknowledgeWebhooksContext } from './resources/context.js';
3
+ import { GetBlobContext, GetItemContext, GetCollectionContext, CreateBlobContext, CreateItemContext, UpdateItemContext, DeleteItemContext, GetCredentialAccountContext, ParseWebhooksContext, UpdateWebhookSubscriptionsContext, AcknowledgeWebhooksContext } from './resources/context.js';
4
4
  /**
5
5
  * Handler called to get an individual item.
6
6
  *
@@ -15,6 +15,13 @@ export type GetItemHandler = (context: GetItemContext<any, any>) => Promise<API.
15
15
  * @return An {@link API.Collection} containing requested items and a link to the next page, if applicable.
16
16
  */
17
17
  export type GetCollectionHandler = (context: GetCollectionContext<any, any>) => Promise<API.Collection>;
18
+ /**
19
+ * Handler called to create an item.
20
+ *
21
+ * @param context {@link CreateBlobContext}
22
+ * @returns An {@link API.Item} containing a path to the created item.
23
+ */
24
+ export type CreateBlobHandler = (context: CreateBlobContext<any, any>) => Promise<API.Item>;
18
25
  /**
19
26
  * Handler called to create an item.
20
27
  *
@@ -86,12 +93,14 @@ export type AcknowledgeWebhooksHandler = (context: AcknowledgeWebhooksContext<an
86
93
  export type ItemHandlers = {
87
94
  getItem?: GetItemHandler;
88
95
  getCollection?: GetCollectionHandler;
96
+ createBlob?: CreateBlobHandler;
89
97
  createItem?: CreateItemHandler;
90
98
  updateItem?: UpdateItemHandler;
91
99
  deleteItem?: DeleteItemHandler;
92
100
  };
93
101
  export type BlobHandlers = {
94
102
  getBlob: GetBlobHandler;
103
+ createBlob?: CreateBlobHandler;
95
104
  };
96
105
  export type CredentialAccountHandlers = {
97
106
  getCredentialAccount: GetCredentialAccountHandler;
@@ -1,6 +1,7 @@
1
1
  import { Router } from 'express';
2
2
  import { InvalidHandler } from './errors.js';
3
3
  import { UnauthorizedError, BadRequestError } from './httpErrors.js';
4
+ import busboy from 'busboy';
4
5
  function assertValidPath(path) {
5
6
  if (!path.startsWith('/')) {
6
7
  throw new InvalidHandler(`The provided path '${path}' is invalid. All paths must start with a '/'.`);
@@ -121,6 +122,39 @@ export class Handler {
121
122
  res.status(201).send(createItemSummary);
122
123
  });
123
124
  }
125
+ if (this.handlers.createBlob) {
126
+ const handler = this.handlers.createBlob;
127
+ console.debug(` Enabling createBlob at POST ${this.pathWithIdentifier}`);
128
+ router.post(this.path, async (req, res) => {
129
+ if (!res.locals.credentials) {
130
+ throw new UnauthorizedError();
131
+ }
132
+ /**
133
+ * Some of the integrations, servicenow for example,
134
+ * will need to add more information to the form data that is being passed to the upload attachment handler.
135
+ * This is why we need to use busboy to parse the form data, extract the information about the file and pass it to the handler.
136
+ */
137
+ const bb = busboy({ headers: req.headers });
138
+ bb.on('file', async (_name, file, info) => {
139
+ const createdBlob = await handler({
140
+ credentials: res.locals.credentials,
141
+ secrets: res.locals.secrets,
142
+ body: {
143
+ file: file,
144
+ mimeType: info.mimeType,
145
+ encoding: info.encoding,
146
+ filename: info.filename,
147
+ },
148
+ logger: res.locals.logger,
149
+ signal: res.locals.signal,
150
+ params: req.params,
151
+ query: req.query,
152
+ });
153
+ res.status(201).send(createdBlob);
154
+ });
155
+ req.pipe(bb);
156
+ });
157
+ }
124
158
  if (this.handlers.getItem) {
125
159
  const handler = this.handlers.getItem;
126
160
  console.debug(` Enabling getItem at GET ${this.pathWithIdentifier}`);
@@ -4,6 +4,8 @@ var integrationApi = require('@unito/integration-api');
4
4
  var cachette = require('cachette');
5
5
  var crypto = require('crypto');
6
6
  var express = require('express');
7
+ var busboy = require('busboy');
8
+ var https = require('https');
7
9
 
8
10
  function _interopNamespaceDefault(e) {
9
11
  var n = Object.create(null);
@@ -352,13 +354,13 @@ function buildHttpError(responseStatus, message) {
352
354
  return httpError;
353
355
  }
354
356
 
355
- const middleware$9 = (req, res, next) => {
357
+ const middleware$a = (req, res, next) => {
356
358
  res.locals.correlationId = req.header('X-Unito-Correlation-Id') ?? crypto.randomUUID();
357
359
  next();
358
360
  };
359
361
 
360
362
  const ADDITIONAL_CONTEXT_HEADER = 'X-Unito-Additional-Logging-Context';
361
- const middleware$8 = (req, res, next) => {
363
+ const middleware$9 = (req, res, next) => {
362
364
  const logger = new Logger({ correlation_id: res.locals.correlationId });
363
365
  res.locals.logger = logger;
364
366
  const rawAdditionalContext = req.header(ADDITIONAL_CONTEXT_HEADER);
@@ -375,7 +377,7 @@ const middleware$8 = (req, res, next) => {
375
377
  };
376
378
 
377
379
  const CREDENTIALS_HEADER = 'X-Unito-Credentials';
378
- const middleware$7 = (req, res, next) => {
380
+ const middleware$8 = (req, res, next) => {
379
381
  const credentialsHeader = req.header(CREDENTIALS_HEADER);
380
382
  if (credentialsHeader) {
381
383
  let credentials;
@@ -391,7 +393,7 @@ const middleware$7 = (req, res, next) => {
391
393
  };
392
394
 
393
395
  const OPERATION_DEADLINE_HEADER = 'X-Unito-Operation-Deadline';
394
- const middleware$6 = (req, res, next) => {
396
+ const middleware$7 = (req, res, next) => {
395
397
  const operationDeadlineHeader = Number(req.header(OPERATION_DEADLINE_HEADER));
396
398
  if (operationDeadlineHeader) {
397
399
  // `operationDeadlineHeader` represents a timestamp in the future, in seconds.
@@ -412,7 +414,7 @@ const middleware$6 = (req, res, next) => {
412
414
  };
413
415
 
414
416
  const SECRETS_HEADER = 'X-Unito-Secrets';
415
- const middleware$5 = (req, res, next) => {
417
+ const middleware$6 = (req, res, next) => {
416
418
  const secretsHeader = req.header(SECRETS_HEADER);
417
419
  if (secretsHeader) {
418
420
  let secrets;
@@ -427,6 +429,30 @@ const middleware$5 = (req, res, next) => {
427
429
  next();
428
430
  };
429
431
 
432
+ // The operators are ordered by their symbol length, in descending order.
433
+ // This is necessary because the symbol of an operator can be
434
+ // a subset of the symbol of another operator.
435
+ //
436
+ // For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
437
+ const ORDERED_OPERATORS = Object.values(integrationApi.OperatorType).sort((o1, o2) => o1.length - o2.length);
438
+ const middleware$5 = (req, res, next) => {
439
+ const rawFilters = req.query.filter;
440
+ res.locals.filters = [];
441
+ if (typeof rawFilters === 'string') {
442
+ for (const rawFilter of rawFilters.split(',')) {
443
+ for (const operator of ORDERED_OPERATORS) {
444
+ if (rawFilter.includes(operator)) {
445
+ const [field, valuesRaw] = rawFilter.split(operator, 2);
446
+ const values = valuesRaw ? valuesRaw.split('|').map(decodeURIComponent) : [];
447
+ res.locals.filters.push({ field: field, operator, values });
448
+ break;
449
+ }
450
+ }
451
+ }
452
+ }
453
+ next();
454
+ };
455
+
430
456
  const middleware$4 = (req, res, next) => {
431
457
  const rawSelect = req.query.select;
432
458
  if (typeof rawSelect === 'string') {
@@ -643,6 +669,39 @@ class Handler {
643
669
  res.status(201).send(createItemSummary);
644
670
  });
645
671
  }
672
+ if (this.handlers.createBlob) {
673
+ const handler = this.handlers.createBlob;
674
+ console.debug(` Enabling createBlob at POST ${this.pathWithIdentifier}`);
675
+ router.post(this.path, async (req, res) => {
676
+ if (!res.locals.credentials) {
677
+ throw new UnauthorizedError();
678
+ }
679
+ /**
680
+ * Some of the integrations, servicenow for example,
681
+ * will need to add more information to the form data that is being passed to the upload attachment handler.
682
+ * This is why we need to use busboy to parse the form data, extract the information about the file and pass it to the handler.
683
+ */
684
+ const bb = busboy({ headers: req.headers });
685
+ bb.on('file', async (_name, file, info) => {
686
+ const createdBlob = await handler({
687
+ credentials: res.locals.credentials,
688
+ secrets: res.locals.secrets,
689
+ body: {
690
+ file: file,
691
+ mimeType: info.mimeType,
692
+ encoding: info.encoding,
693
+ filename: info.filename,
694
+ },
695
+ logger: res.locals.logger,
696
+ signal: res.locals.signal,
697
+ params: req.params,
698
+ query: req.query,
699
+ });
700
+ res.status(201).send(createdBlob);
701
+ });
702
+ req.pipe(bb);
703
+ });
704
+ }
646
705
  if (this.handlers.getItem) {
647
706
  const handler = this.handlers.getItem;
648
707
  console.debug(` Enabling getItem at GET ${this.pathWithIdentifier}`);
@@ -912,12 +971,13 @@ class Integration {
912
971
  // Must be one of the first handlers (to catch all the errors).
913
972
  app.use(middleware$2);
914
973
  app.use(middleware);
974
+ app.use(middleware$a);
915
975
  app.use(middleware$9);
916
976
  app.use(middleware$8);
917
- app.use(middleware$7);
977
+ app.use(middleware$6);
918
978
  app.use(middleware$5);
919
979
  app.use(middleware$4);
920
- app.use(middleware$6);
980
+ app.use(middleware$7);
921
981
  // Making sure we log all incomming requests, prior to any processing.
922
982
  app.use((req, res, next) => {
923
983
  res.locals.logger.info(`Initializing request for ${req.originalUrl}`);
@@ -1055,6 +1115,52 @@ class Provider {
1055
1115
  },
1056
1116
  });
1057
1117
  }
1118
+ async postForm(endpoint, form, options) {
1119
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
1120
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
1121
+ const headers = { ...form.getHeaders(), ...providerHeaders, ...options.additionnalheaders };
1122
+ const reqOptions = {
1123
+ method: 'POST',
1124
+ headers,
1125
+ };
1126
+ /**
1127
+ * For some obscure reason we can't use the fetch API to send a form data, so we have to use the native https module
1128
+ * It seems that there is a miscalculation of the Content-Length headers that generates an error :
1129
+ * --> headers length is different from the actual body length
1130
+ * The goto solution recommended across the internet for this, is to simply drop the header.
1131
+ * However, some integrations like Servicenow, will not accept the request if it doesn't contain that header
1132
+ */
1133
+ const callToProvider = async () => {
1134
+ return new Promise((resolve, reject) => {
1135
+ try {
1136
+ const request = https.request(absoluteUrl, reqOptions, response => {
1137
+ response.setEncoding('utf8');
1138
+ let responseBody = '';
1139
+ response.on('data', chunk => {
1140
+ responseBody += chunk;
1141
+ });
1142
+ response.on('end', () => {
1143
+ try {
1144
+ const body = JSON.parse(responseBody);
1145
+ resolve({ status: 201, headers: response.headers, body });
1146
+ }
1147
+ catch (error) {
1148
+ reject(this.handleError(500, `Failed to parse response body: "${error}"`));
1149
+ }
1150
+ });
1151
+ });
1152
+ request.on('error', error => {
1153
+ reject(this.handleError(400, `Error while calling the provider: "${error}"`));
1154
+ });
1155
+ form.pipe(request);
1156
+ }
1157
+ catch (error) {
1158
+ reject(this.handleError(500, `Unexpected error while calling the provider: "${error}"`));
1159
+ }
1160
+ });
1161
+ };
1162
+ return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
1163
+ }
1058
1164
  /**
1059
1165
  * Performs a PUT request to the provider.
1060
1166
  *
@@ -1119,8 +1225,7 @@ class Provider {
1119
1225
  },
1120
1226
  });
1121
1227
  }
1122
- async fetchWrapper(endpoint, body, options) {
1123
- const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
1228
+ generateAbsoluteUrl(providerUrl, endpoint, queryParams) {
1124
1229
  let absoluteUrl;
1125
1230
  if (/^https?:\/\//.test(endpoint)) {
1126
1231
  absoluteUrl = endpoint;
@@ -1128,9 +1233,14 @@ class Provider {
1128
1233
  else {
1129
1234
  absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
1130
1235
  }
1131
- if (options.queryParams) {
1132
- absoluteUrl = `${absoluteUrl}?${new URLSearchParams(options.queryParams)}`;
1236
+ if (queryParams) {
1237
+ absoluteUrl = `${absoluteUrl}?${new URLSearchParams(queryParams)}`;
1133
1238
  }
1239
+ return absoluteUrl;
1240
+ }
1241
+ async fetchWrapper(endpoint, body, options) {
1242
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
1243
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
1134
1244
  const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
1135
1245
  let stringifiedBody = null;
1136
1246
  if (body) {
@@ -5,6 +5,7 @@ import loggerMiddleware from './middlewares/logger.js';
5
5
  import credentialsMiddleware from './middlewares/credentials.js';
6
6
  import signalMiddleware from './middlewares/signal.js';
7
7
  import secretsMiddleware from './middlewares/secrets.js';
8
+ import filtersMiddleware from './middlewares/filters.js';
8
9
  import selectsMiddleware from './middlewares/selects.js';
9
10
  import errorsMiddleware from './middlewares/errors.js';
10
11
  import finishMiddleware from './middlewares/finish.js';
@@ -118,6 +119,7 @@ export default class Integration {
118
119
  app.use(loggerMiddleware);
119
120
  app.use(credentialsMiddleware);
120
121
  app.use(secretsMiddleware);
122
+ app.use(filtersMiddleware);
121
123
  app.use(selectsMiddleware);
122
124
  app.use(signalMiddleware);
123
125
  // Making sure we log all incomming requests, prior to any processing.
@@ -10,6 +10,7 @@ type Query = {
10
10
  [key: string]: undefined | string | string[] | Query | Query[];
11
11
  };
12
12
  type CreateItemBody = API.CreateItemRequestPayload;
13
+ type CreateBlobBody = API.CreateBlobRequestPayload;
13
14
  type UpdateItemBody = API.UpdateItemRequestPayload;
14
15
  /**
15
16
  * The base context object is passed to every handler function.
@@ -103,6 +104,9 @@ export type GetCollectionContext<P extends Maybe<Params> = Empty, Q extends Quer
103
104
  export type CreateItemContext<P extends Maybe<Params> = Empty, Q extends Maybe<Query> = Empty, B extends CreateItemBody = API.CreateItemRequestPayload> = Context<P, Q> & {
104
105
  body: B;
105
106
  };
107
+ export type CreateBlobContext<P extends Maybe<Params> = Empty, Q extends Maybe<Query> = Empty, B extends CreateBlobBody = API.CreateBlobRequestPayload> = Context<P, Q> & {
108
+ body: B;
109
+ };
106
110
  export type UpdateItemContext<P extends Maybe<Params> = Empty, Q extends Maybe<Query> = Empty, B extends UpdateItemBody = API.UpdateItemRequestPayload> = Context<P, Q> & {
107
111
  body: B;
108
112
  };
@@ -1,3 +1,4 @@
1
+ import FormData from 'form-data';
1
2
  import * as HttpErrors from '../httpErrors.js';
2
3
  import { Credentials } from '../middlewares/credentials.js';
3
4
  import Logger from '../resources/logger.js';
@@ -143,6 +144,7 @@ export declare class Provider {
143
144
  * @returns The {@link Response} extracted from the provider.
144
145
  */
145
146
  post<T>(endpoint: string, body: Record<string, unknown>, options: RequestOptions): Promise<Response<T>>;
147
+ postForm<T>(endpoint: string, form: FormData, options: RequestOptions): Promise<Response<T>>;
146
148
  /**
147
149
  * Performs a PUT request to the provider.
148
150
  *
@@ -181,6 +183,7 @@ export declare class Provider {
181
183
  * @returns The {@link Response} extracted from the provider.
182
184
  */
183
185
  delete<T = undefined>(endpoint: string, options: RequestOptions): Promise<Response<T>>;
186
+ private generateAbsoluteUrl;
184
187
  private fetchWrapper;
185
188
  private handleError;
186
189
  }
@@ -1,3 +1,4 @@
1
+ import https from 'https';
1
2
  import { buildHttpError } from '../errors.js';
2
3
  /**
3
4
  * The Provider class is a wrapper around the fetch function to call a provider's HTTP API.
@@ -108,6 +109,52 @@ export class Provider {
108
109
  },
109
110
  });
110
111
  }
112
+ async postForm(endpoint, form, options) {
113
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
114
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
115
+ const headers = { ...form.getHeaders(), ...providerHeaders, ...options.additionnalheaders };
116
+ const reqOptions = {
117
+ method: 'POST',
118
+ headers,
119
+ };
120
+ /**
121
+ * For some obscure reason we can't use the fetch API to send a form data, so we have to use the native https module
122
+ * It seems that there is a miscalculation of the Content-Length headers that generates an error :
123
+ * --> headers length is different from the actual body length
124
+ * The goto solution recommended across the internet for this, is to simply drop the header.
125
+ * However, some integrations like Servicenow, will not accept the request if it doesn't contain that header
126
+ */
127
+ const callToProvider = async () => {
128
+ return new Promise((resolve, reject) => {
129
+ try {
130
+ const request = https.request(absoluteUrl, reqOptions, response => {
131
+ response.setEncoding('utf8');
132
+ let responseBody = '';
133
+ response.on('data', chunk => {
134
+ responseBody += chunk;
135
+ });
136
+ response.on('end', () => {
137
+ try {
138
+ const body = JSON.parse(responseBody);
139
+ resolve({ status: 201, headers: response.headers, body });
140
+ }
141
+ catch (error) {
142
+ reject(this.handleError(500, `Failed to parse response body: "${error}"`));
143
+ }
144
+ });
145
+ });
146
+ request.on('error', error => {
147
+ reject(this.handleError(400, `Error while calling the provider: "${error}"`));
148
+ });
149
+ form.pipe(request);
150
+ }
151
+ catch (error) {
152
+ reject(this.handleError(500, `Unexpected error while calling the provider: "${error}"`));
153
+ }
154
+ });
155
+ };
156
+ return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
157
+ }
111
158
  /**
112
159
  * Performs a PUT request to the provider.
113
160
  *
@@ -172,8 +219,7 @@ export class Provider {
172
219
  },
173
220
  });
174
221
  }
175
- async fetchWrapper(endpoint, body, options) {
176
- const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
222
+ generateAbsoluteUrl(providerUrl, endpoint, queryParams) {
177
223
  let absoluteUrl;
178
224
  if (/^https?:\/\//.test(endpoint)) {
179
225
  absoluteUrl = endpoint;
@@ -181,9 +227,14 @@ export class Provider {
181
227
  else {
182
228
  absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
183
229
  }
184
- if (options.queryParams) {
185
- absoluteUrl = `${absoluteUrl}?${new URLSearchParams(options.queryParams)}`;
230
+ if (queryParams) {
231
+ absoluteUrl = `${absoluteUrl}?${new URLSearchParams(queryParams)}`;
186
232
  }
233
+ return absoluteUrl;
234
+ }
235
+ async fetchWrapper(endpoint, body, options) {
236
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
237
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
187
238
  const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
188
239
  let stringifiedBody = null;
189
240
  if (body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
@@ -33,6 +33,7 @@
33
33
  "ci:test": "npm run test"
34
34
  },
35
35
  "devDependencies": {
36
+ "@types/busboy": "^1.5.4",
36
37
  "@types/express": "4.x",
37
38
  "@types/node": "20.x",
38
39
  "@typescript-eslint/eslint-plugin": "7.x",
@@ -46,8 +47,10 @@
46
47
  },
47
48
  "dependencies": {
48
49
  "@unito/integration-api": "0.x",
50
+ "busboy": "^1.6.0",
49
51
  "cachette": "2.x",
50
- "express": "^5.0.0-beta.3"
52
+ "express": "^5.0.0-beta.3",
53
+ "form-data": "^4.0.0"
51
54
  },
52
55
  "keywords": [
53
56
  "typescript",
package/src/handler.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  GetBlobContext,
7
7
  GetItemContext,
8
8
  GetCollectionContext,
9
+ CreateBlobContext,
9
10
  CreateItemContext,
10
11
  UpdateItemContext,
11
12
  DeleteItemContext,
@@ -15,6 +16,7 @@ import {
15
16
  AcknowledgeWebhooksContext,
16
17
  } from './resources/context.js';
17
18
 
19
+ import busboy, { FileInfo } from 'busboy';
18
20
  /**
19
21
  * Handler called to get an individual item.
20
22
  *
@@ -31,6 +33,14 @@ export type GetItemHandler = (context: GetItemContext<any, any>) => Promise<API.
31
33
  */
32
34
  export type GetCollectionHandler = (context: GetCollectionContext<any, any>) => Promise<API.Collection>;
33
35
 
36
+ /**
37
+ * Handler called to create an item.
38
+ *
39
+ * @param context {@link CreateBlobContext}
40
+ * @returns An {@link API.Item} containing a path to the created item.
41
+ */
42
+ export type CreateBlobHandler = (context: CreateBlobContext<any, any>) => Promise<API.Item>;
43
+
34
44
  /**
35
45
  * Handler called to create an item.
36
46
  *
@@ -118,6 +128,7 @@ export type AcknowledgeWebhooksHandler = (
118
128
  export type ItemHandlers = {
119
129
  getItem?: GetItemHandler;
120
130
  getCollection?: GetCollectionHandler;
131
+ createBlob?: CreateBlobHandler;
121
132
  createItem?: CreateItemHandler;
122
133
  updateItem?: UpdateItemHandler;
123
134
  deleteItem?: DeleteItemHandler;
@@ -125,6 +136,7 @@ export type ItemHandlers = {
125
136
 
126
137
  export type BlobHandlers = {
127
138
  getBlob: GetBlobHandler;
139
+ createBlob?: CreateBlobHandler;
128
140
  };
129
141
 
130
142
  export type CredentialAccountHandlers = {
@@ -323,6 +335,43 @@ export class Handler {
323
335
  });
324
336
  }
325
337
 
338
+ if (this.handlers.createBlob) {
339
+ const handler = this.handlers.createBlob;
340
+
341
+ console.debug(` Enabling createBlob at POST ${this.pathWithIdentifier}`);
342
+
343
+ router.post(this.path, async (req, res) => {
344
+ if (!res.locals.credentials) {
345
+ throw new UnauthorizedError();
346
+ }
347
+ /**
348
+ * Some of the integrations, servicenow for example,
349
+ * will need to add more information to the form data that is being passed to the upload attachment handler.
350
+ * This is why we need to use busboy to parse the form data, extract the information about the file and pass it to the handler.
351
+ */
352
+ const bb = busboy({ headers: req.headers });
353
+ bb.on('file', async (_name, file, info: FileInfo) => {
354
+ const createdBlob = await handler({
355
+ credentials: res.locals.credentials,
356
+ secrets: res.locals.secrets,
357
+ body: {
358
+ file: file,
359
+ mimeType: info.mimeType,
360
+ encoding: info.encoding,
361
+ filename: info.filename,
362
+ },
363
+ logger: res.locals.logger,
364
+ signal: res.locals.signal,
365
+ params: req.params,
366
+ query: req.query,
367
+ });
368
+ res.status(201).send(createdBlob);
369
+ });
370
+
371
+ req.pipe(bb);
372
+ });
373
+ }
374
+
326
375
  if (this.handlers.getItem) {
327
376
  const handler = this.handlers.getItem;
328
377
 
@@ -7,6 +7,7 @@ import loggerMiddleware from './middlewares/logger.js';
7
7
  import credentialsMiddleware from './middlewares/credentials.js';
8
8
  import signalMiddleware from './middlewares/signal.js';
9
9
  import secretsMiddleware from './middlewares/secrets.js';
10
+ import filtersMiddleware from './middlewares/filters.js';
10
11
  import selectsMiddleware from './middlewares/selects.js';
11
12
  import errorsMiddleware from './middlewares/errors.js';
12
13
  import finishMiddleware from './middlewares/finish.js';
@@ -134,6 +135,7 @@ export default class Integration {
134
135
  app.use(loggerMiddleware);
135
136
  app.use(credentialsMiddleware);
136
137
  app.use(secretsMiddleware);
138
+ app.use(filtersMiddleware);
137
139
  app.use(selectsMiddleware);
138
140
  app.use(signalMiddleware);
139
141
 
@@ -1,6 +1,5 @@
1
1
  /* c8 ignore start */
2
2
  import * as API from '@unito/integration-api';
3
-
4
3
  import Logger from './logger.js';
5
4
  import { Credentials } from '../middlewares/credentials.js';
6
5
  import { Secrets } from 'src/middlewares/secrets.js';
@@ -13,6 +12,7 @@ type Query = {
13
12
  [key: string]: undefined | string | string[] | Query | Query[];
14
13
  };
15
14
  type CreateItemBody = API.CreateItemRequestPayload;
15
+ type CreateBlobBody = API.CreateBlobRequestPayload;
16
16
  type UpdateItemBody = API.UpdateItemRequestPayload;
17
17
 
18
18
  /**
@@ -114,6 +114,12 @@ export type CreateItemContext<
114
114
  B extends CreateItemBody = API.CreateItemRequestPayload,
115
115
  > = Context<P, Q> & { body: B };
116
116
 
117
+ export type CreateBlobContext<
118
+ P extends Maybe<Params> = Empty,
119
+ Q extends Maybe<Query> = Empty,
120
+ B extends CreateBlobBody = API.CreateBlobRequestPayload,
121
+ > = Context<P, Q> & { body: B };
122
+
117
123
  export type UpdateItemContext<
118
124
  P extends Maybe<Params> = Empty,
119
125
  Q extends Maybe<Query> = Empty,
@@ -1,3 +1,6 @@
1
+ import https from 'https';
2
+ import FormData from 'form-data';
3
+
1
4
  import { buildHttpError } from '../errors.js';
2
5
  import * as HttpErrors from '../httpErrors.js';
3
6
  import { Credentials } from '../middlewares/credentials.js';
@@ -177,6 +180,57 @@ export class Provider {
177
180
  });
178
181
  }
179
182
 
183
+ public async postForm<T>(endpoint: string, form: FormData, options: RequestOptions): Promise<Response<T>> {
184
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
185
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
186
+ const headers = { ...form.getHeaders(), ...providerHeaders, ...options.additionnalheaders };
187
+
188
+ const reqOptions = {
189
+ method: 'POST',
190
+ headers,
191
+ };
192
+ /**
193
+ * For some obscure reason we can't use the fetch API to send a form data, so we have to use the native https module
194
+ * It seems that there is a miscalculation of the Content-Length headers that generates an error :
195
+ * --> headers length is different from the actual body length
196
+ * The goto solution recommended across the internet for this, is to simply drop the header.
197
+ * However, some integrations like Servicenow, will not accept the request if it doesn't contain that header
198
+ */
199
+ const callToProvider = async (): Promise<Response<T>> => {
200
+ return new Promise((resolve, reject) => {
201
+ try {
202
+ const request = https.request(absoluteUrl, reqOptions, response => {
203
+ response.setEncoding('utf8');
204
+ let responseBody = '';
205
+
206
+ response.on('data', chunk => {
207
+ responseBody += chunk;
208
+ });
209
+
210
+ response.on('end', () => {
211
+ try {
212
+ const body = JSON.parse(responseBody) as T;
213
+ resolve({ status: 201, headers: response.headers as unknown as Headers, body });
214
+ } catch (error) {
215
+ reject(this.handleError(500, `Failed to parse response body: "${error}"`));
216
+ }
217
+ });
218
+ });
219
+
220
+ request.on('error', error => {
221
+ reject(this.handleError(400, `Error while calling the provider: "${error}"`));
222
+ });
223
+
224
+ form.pipe(request);
225
+ } catch (error) {
226
+ reject(this.handleError(500, `Unexpected error while calling the provider: "${error}"`));
227
+ }
228
+ });
229
+ };
230
+
231
+ return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
232
+ }
233
+
180
234
  /**
181
235
  * Performs a PUT request to the provider.
182
236
  *
@@ -248,13 +302,7 @@ export class Provider {
248
302
  });
249
303
  }
250
304
 
251
- private async fetchWrapper<T>(
252
- endpoint: string,
253
- body: Record<string, unknown> | null,
254
- options: RequestOptions & { defaultHeaders: { 'Content-Type'?: string; Accept?: string }; method: string },
255
- ): Promise<Response<T>> {
256
- const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
257
-
305
+ private generateAbsoluteUrl(providerUrl: string, endpoint: string, queryParams?: { [key: string]: string }): string {
258
306
  let absoluteUrl;
259
307
 
260
308
  if (/^https?:\/\//.test(endpoint)) {
@@ -263,10 +311,19 @@ export class Provider {
263
311
  absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
264
312
  }
265
313
 
266
- if (options.queryParams) {
267
- absoluteUrl = `${absoluteUrl}?${new URLSearchParams(options.queryParams)}`;
314
+ if (queryParams) {
315
+ absoluteUrl = `${absoluteUrl}?${new URLSearchParams(queryParams)}`;
268
316
  }
317
+ return absoluteUrl;
318
+ }
269
319
 
320
+ private async fetchWrapper<T>(
321
+ endpoint: string,
322
+ body: Record<string, unknown> | null,
323
+ options: RequestOptions & { defaultHeaders: { 'Content-Type'?: string; Accept?: string }; method: string },
324
+ ): Promise<Response<T>> {
325
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
326
+ const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
270
327
  const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
271
328
 
272
329
  let stringifiedBody: string | null = null;