@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.
- package/dist/src/handler.d.ts +10 -1
- package/dist/src/handler.js +34 -0
- package/dist/src/index.cjs +121 -11
- package/dist/src/integration.js +2 -0
- package/dist/src/resources/context.d.ts +4 -0
- package/dist/src/resources/provider.d.ts +3 -0
- package/dist/src/resources/provider.js +55 -4
- package/package.json +5 -2
- package/src/handler.ts +49 -0
- package/src/integration.ts +2 -0
- package/src/resources/context.ts +7 -1
- package/src/resources/provider.ts +66 -9
package/dist/src/handler.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/handler.js
CHANGED
|
@@ -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}`);
|
package/dist/src/index.cjs
CHANGED
|
@@ -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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
977
|
+
app.use(middleware$6);
|
|
918
978
|
app.use(middleware$5);
|
|
919
979
|
app.use(middleware$4);
|
|
920
|
-
app.use(middleware$
|
|
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
|
-
|
|
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 (
|
|
1132
|
-
absoluteUrl = `${absoluteUrl}?${new URLSearchParams(
|
|
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) {
|
package/dist/src/integration.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
185
|
-
absoluteUrl = `${absoluteUrl}?${new URLSearchParams(
|
|
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.
|
|
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
|
|
package/src/integration.ts
CHANGED
|
@@ -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
|
|
package/src/resources/context.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
267
|
-
absoluteUrl = `${absoluteUrl}?${new URLSearchParams(
|
|
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;
|