@unito/integration-sdk 1.0.15 → 1.0.18

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);
@@ -667,6 +669,39 @@ class Handler {
667
669
  res.status(201).send(createItemSummary);
668
670
  });
669
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
+ }
670
705
  if (this.handlers.getItem) {
671
706
  const handler = this.handlers.getItem;
672
707
  console.debug(` Enabling getItem at GET ${this.pathWithIdentifier}`);
@@ -1080,6 +1115,52 @@ class Provider {
1080
1115
  },
1081
1116
  });
1082
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
+ }
1083
1164
  /**
1084
1165
  * Performs a PUT request to the provider.
1085
1166
  *
@@ -1144,8 +1225,7 @@ class Provider {
1144
1225
  },
1145
1226
  });
1146
1227
  }
1147
- async fetchWrapper(endpoint, body, options) {
1148
- const { url: providerUrl, headers: providerHeaders } = this.prepareRequest(options);
1228
+ generateAbsoluteUrl(providerUrl, endpoint, queryParams) {
1149
1229
  let absoluteUrl;
1150
1230
  if (/^https?:\/\//.test(endpoint)) {
1151
1231
  absoluteUrl = endpoint;
@@ -1153,9 +1233,14 @@ class Provider {
1153
1233
  else {
1154
1234
  absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
1155
1235
  }
1156
- if (options.queryParams) {
1157
- absoluteUrl = `${absoluteUrl}?${new URLSearchParams(options.queryParams)}`;
1236
+ if (queryParams) {
1237
+ absoluteUrl = `${absoluteUrl}?${new URLSearchParams(queryParams)}`;
1158
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);
1159
1244
  const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
1160
1245
  let stringifiedBody = null;
1161
1246
  if (body) {
@@ -1198,7 +1283,9 @@ class Provider {
1198
1283
  // (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
1199
1284
  // Default to application/json if no Content-Type header is provided
1200
1285
  if (responseContentType && !responseContentType.includes('application/json')) {
1201
- throw this.handleError(500, `Unsupported content-type. Expected 'application/json', got '${responseContentType}'`);
1286
+ const textResult = await response.text();
1287
+ throw this.handleError(500, `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
1288
+ Original response (${response.status}): ${textResult}`);
1202
1289
  }
1203
1290
  try {
1204
1291
  body = response.body ? await response.json() : undefined;
@@ -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) {
@@ -226,7 +277,9 @@ export class Provider {
226
277
  // (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
227
278
  // Default to application/json if no Content-Type header is provided
228
279
  if (responseContentType && !responseContentType.includes('application/json')) {
229
- throw this.handleError(500, `Unsupported content-type. Expected 'application/json', got '${responseContentType}'`);
280
+ const textResult = await response.text();
281
+ throw this.handleError(500, `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
282
+ Original response (${response.status}): ${textResult}`);
230
283
  }
231
284
  try {
232
285
  body = response.body ? await response.json() : undefined;
@@ -401,7 +401,7 @@ describe('Provider', () => {
401
401
  error = e;
402
402
  }
403
403
  assert.ok(error instanceof HttpErrors.HttpError);
404
- assert.equal(error.message, `Unsupported content-type. Expected 'application/json', got 'application/text'`);
404
+ assert.equal(error.status, 500);
405
405
  });
406
406
  it('throws on status 400', async (context) => {
407
407
  const response = new Response('response body', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "1.0.15",
3
+ "version": "1.0.18",
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
 
@@ -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;
@@ -315,9 +372,11 @@ export class Provider {
315
372
  // (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
316
373
  // Default to application/json if no Content-Type header is provided
317
374
  if (responseContentType && !responseContentType.includes('application/json')) {
375
+ const textResult = await response.text();
318
376
  throw this.handleError(
319
377
  500,
320
- `Unsupported content-type. Expected 'application/json', got '${responseContentType}'`,
378
+ `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
379
+ Original response (${response.status}): ${textResult}`,
321
380
  );
322
381
  }
323
382
 
@@ -479,7 +479,7 @@ describe('Provider', () => {
479
479
  }
480
480
 
481
481
  assert.ok(error instanceof HttpErrors.HttpError);
482
- assert.equal(error.message, `Unsupported content-type. Expected 'application/json', got 'application/text'`);
482
+ assert.equal(error.status, 500);
483
483
  });
484
484
 
485
485
  it('throws on status 400', async context => {