@unito/integration-sdk
Version:
Integration SDK
1,278 lines (1,250 loc) • 65.6 kB
JavaScript
'use strict';
var integrationApi = require('@unito/integration-api');
var cachette = require('cachette');
var crypto = require('crypto');
var util = require('util');
var express = require('express');
var busboy = require('busboy');
var https = require('https');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var integrationApi__namespace = /*#__PURE__*/_interopNamespaceDefault(integrationApi);
var LogLevel;
(function (LogLevel) {
LogLevel["ERROR"] = "error";
LogLevel["WARN"] = "warn";
LogLevel["INFO"] = "info";
LogLevel["LOG"] = "log";
LogLevel["DEBUG"] = "debug";
})(LogLevel || (LogLevel = {}));
/**
* See https://docs.datadoghq.com/logs/log_collection/?tab=host#custom-log-forwarding
* - Datadog Agent splits at 256kB (256000 bytes)...
* - ... but the same docs say that "for optimal performance, it is
* recommended that an individual log be no greater than 25kB"
* -> Truncating at 25kB - a bit of wiggle room for metadata = 20kB.
*/
const MAX_LOG_MESSAGE_SIZE = parseInt(process.env.MAX_LOG_MESSAGE_SIZE ?? '20000', 10);
const LOG_LINE_TRUNCATED_SUFFIX = ' - LOG LINE TRUNCATED';
/**
* For *LogMeta* sanitization, we let in anything that was passed, except for clearly-problematic keys
*/
const LOGMETA_BLACKLIST = [
// Security
'access_token',
'bot_auth_code',
'client_secret',
'jwt',
'oauth_token',
'password',
'refresh_token',
'shared_secret',
'token',
// Privacy
'billing_email',
'email',
'first_name',
'last_name',
];
/**
* Logger class that can be configured with metadata add creation and when logging to add additional context to your logs.
*/
class Logger {
isDisabled;
metadata;
constructor(metadata = {}, isDisabled = false) {
this.isDisabled = isDisabled;
this.metadata = structuredClone(metadata);
}
/**
* Logs a message with the 'log' log level.
* @param message The message to be logged.
* @param metadata Optional metadata to be associated with the log message.
*/
log(message, metadata) {
this.send(LogLevel.LOG, message, metadata);
}
/**
* Logs an error message with the 'error' log level.
* @param message The error message to be logged.
* @param metadata Optional metadata to be associated with the log message.
*/
error(message, metadata) {
this.send(LogLevel.ERROR, message, metadata);
}
/**
* Logs a warning message with the 'warn' log level.
* @param message The warning message to be logged.
* @param metadata Optional metadata to be associated with the log message.
*/
warn(message, metadata) {
this.send(LogLevel.WARN, message, metadata);
}
/**
* Logs an informational message with the 'info' log level.
* @param message The informational message to be logged.
* @param metadata Optional metadata to be associated with the log message.
*/
info(message, metadata) {
this.send(LogLevel.INFO, message, metadata);
}
/**
* Logs a debug message with the 'debug' log level.
* @param message The debug message to be logged.
* @param metadata Optional metadata to be associated with the log message.
*/
debug(message, metadata) {
this.send(LogLevel.DEBUG, message, metadata);
}
/**
* Decorates the logger with additional metadata.
* @param metadata Additional metadata to be added to the logger.
*/
decorate(metadata) {
this.metadata = { ...this.metadata, ...metadata };
}
/**
* Return a copy of the Logger's metadata.
* @returns The {@link Metadata} associated with the logger.
*/
getMetadata() {
return structuredClone(this.metadata);
}
/**
* Sets a key-value pair in the metadata. If the key already exists, it will be overwritten.
*
* @param key Key of the metadata to be set.
* May be any string other than 'message', which is reserved for the actual message logged.
* @param value Value of the metadata to be set.
*/
setMetadata(key, value) {
this.metadata[key] = value;
}
/**
* Clears the Logger's metadata.
*/
clearMetadata() {
this.metadata = {};
}
send(logLevel, message, metadata) {
if (this.isDisabled) {
return;
}
// We need to provide the date to Datadog. Otherwise, the date is set to when they receive the log.
const date = Date.now();
if (message.length > MAX_LOG_MESSAGE_SIZE) {
message = `${message.substring(0, MAX_LOG_MESSAGE_SIZE)}${LOG_LINE_TRUNCATED_SUFFIX}`;
}
let processedMetadata = Logger.snakifyKeys({ ...this.metadata, ...metadata, logMessageSize: message.length });
processedMetadata = Logger.pruneSensitiveMetadata(processedMetadata);
const processedLogs = {
...processedMetadata,
message,
date,
status: logLevel,
};
if (process.env.NODE_ENV === 'development') {
const coloredMessage = Logger.colorize(message, processedLogs, logLevel);
const metadata = {
date: new Date(processedLogs.date).toISOString(),
...(processedMetadata.error && { error: processedMetadata.error }),
};
const metadataString = Object.keys(metadata).length > 1 ? ` ${JSON.stringify(metadata, null, 2)}` : ` ${JSON.stringify(metadata)}`;
console[logLevel](`${coloredMessage}${metadataString}`);
}
else {
console[logLevel](JSON.stringify(processedLogs));
}
}
static snakifyKeys(value) {
const result = {};
for (const key in value) {
let deepValue;
if (Array.isArray(value[key])) {
deepValue = value[key].map(item => this.snakifyKeys(item));
}
else if (typeof value[key] === 'object' && value[key] !== null) {
deepValue = this.snakifyKeys(value[key]);
}
else {
deepValue = value[key];
}
const snakifiedKey = key.replace(/[\w](?<!_)([A-Z])/g, k => `${k[0]}_${k[1]}`).toLowerCase();
result[snakifiedKey] = deepValue;
}
return result;
}
static pruneSensitiveMetadata(metadata, topLevelMeta) {
const prunedMetadata = {};
for (const key in metadata) {
if (LOGMETA_BLACKLIST.includes(key)) {
prunedMetadata[key] = '[REDACTED]';
(topLevelMeta ?? prunedMetadata).has_sensitive_attribute = true;
}
else if (Array.isArray(metadata[key])) {
prunedMetadata[key] = metadata[key].map(value => Logger.pruneSensitiveMetadata(value, topLevelMeta ?? prunedMetadata));
}
else if (typeof metadata[key] === 'object' && metadata[key] !== null) {
prunedMetadata[key] = Logger.pruneSensitiveMetadata(metadata[key], topLevelMeta ?? prunedMetadata);
}
else {
prunedMetadata[key] = metadata[key];
}
}
return prunedMetadata;
}
/**
* Colorizes the log message based on the log level and status codes.
* @param message The message to colorize.
* @param metadata The metadata associated with the log.
* @param logLevel The log level of the message.
* @returns The colorized output string.
*/
static colorize(message, metadata, logLevel) {
if (!process.stdout.isTTY) {
return `${logLevel}: ${message}`;
}
// Extract status code from logs
let statusCode;
if (metadata.http && typeof metadata.http === 'object' && !Array.isArray(metadata.http)) {
const statusCodeValue = metadata.http.status_code;
if (typeof statusCodeValue === 'number') {
statusCode = statusCodeValue;
}
else if (typeof statusCodeValue === 'string') {
statusCode = parseInt(statusCodeValue, 10);
}
}
// Color based on status code first
if (statusCode) {
if (statusCode >= 400) {
return `${util.styleText('red', logLevel)}: ${util.styleText('red', message)}`;
}
else if (statusCode >= 300) {
return `${util.styleText('yellow', logLevel)}: ${util.styleText('yellow', message)}`;
}
else if (statusCode >= 200) {
return `${util.styleText('green', logLevel)}: ${util.styleText('green', message)}`;
}
}
// Fall back to log level if no status code found
switch (logLevel) {
case LogLevel.ERROR:
return `${util.styleText('red', logLevel)}: ${util.styleText('red', message)}`;
case LogLevel.WARN:
return `${util.styleText('yellow', logLevel)}: ${util.styleText('yellow', message)}`;
case LogLevel.INFO:
case LogLevel.LOG:
return `${util.styleText('green', logLevel)}: ${util.styleText('green', message)}`;
case LogLevel.DEBUG:
return `${util.styleText('cyan', logLevel)}: ${util.styleText('cyan', message)}`;
default:
return `${logLevel}: ${message}`;
}
}
}
const NULL_LOGGER = new Logger({}, true);
/**
* The Cache class provides caching capabilities that can be used across your integration.
* It can be backed by a Redis instance (by passing it a URL to the instance) or a local cache.
*
* @see {@link Cache.create}
*/
class Cache {
cacheInstance;
constructor(cacheInstance) {
this.cacheInstance = cacheInstance;
}
/**
* Get or fetch a value
*
* @param key The key of the value to get
* @param ttl The time to live of the value in seconds.
* @param fetchFn The function that can retrieve the original value
* @param lockTtl Global distributed lock TTL (in seconds) protecting fetching.
* If undefined, 0 or falsy, locking is not preformed
* @param shouldCacheError A callback being passed errors, controlling whether
* to cache or not errors. Defaults to never cache.
*
* @returns The cached or fetched value
*/
getOrFetchValue(key, ttl, fetcher, lockTtl, shouldCacheError) {
return this.cacheInstance.getOrFetchValue(key, ttl, fetcher, lockTtl, shouldCacheError);
}
/**
* Get a value from the cache.
*
* @param key The key of the value to get.
*
* @return The value associated with the key, or undefined if
* no such value exists.
*/
getValue(key) {
return this.cacheInstance.getValue(key);
}
/**
* Set a value in the cache.
*
* @param key The key of the value to set.
* @param value The value to set.
* @param ttl The time to live of the value in seconds.
* By default, the value will not expire
*
* @return true if the value was stored, false otherwise.
*/
setValue(key, value, ttl) {
return this.cacheInstance.setValue(key, value, ttl);
}
/**
* Delete a value from the cache.
* @param key — The key of the value to set.
*/
delValue(key) {
return this.cacheInstance.delValue(key);
}
/**
* Get the TTL of an entry, in ms
*
* @param key The key of the entry whose ttl to retrieve
*
* @return The remaining TTL on the entry, in ms.
* undefined if the entry does not exist.
* 0 if the entry does not expire.
*/
getTtl(key) {
return this.cacheInstance.getTtl(key);
}
/**
* Initializes a Cache backed by the Redis instance at the provided url if present, or a LocalCache otherwise.
*
* @param redisUrl - The redis url to connect to (optional).
* @returns A cache instance.
*/
static create(redisUrl) {
const cacheInstance = redisUrl ? new cachette.RedisCache(redisUrl) : new cachette.LocalCache();
// Intended: the correlation id will be the same for all logs of Cachette.
const correlationId = crypto.randomUUID();
const logger = new Logger({ correlation_id: correlationId });
cacheInstance
.on('info', message => {
logger.info(message);
})
.on('warn', message => {
logger.warn(message);
})
.on('error', message => {
logger.error(message);
});
return new Cache(cacheInstance);
}
}
/**
* Error class meant to be returned by integrations in case of exceptions. These errors will be caught and handled
* appropriately. Any other error would result in an unhandled server error accompanied by a 500 status code.
*
* @field message - The error message
* @field status - The HTTP status code to return
*/
class HttpError extends Error {
status;
constructor(message, status) {
super(message);
this.status = status;
this.name = this.constructor.name;
}
}
/**
* Used to generate a 400 Bad Request. Usually used when something is missing to properly handle the request.
*/
class BadRequestError extends HttpError {
constructor(message) {
super(message || 'Bad request', 400);
}
}
/**
* Used to generate a 401 Unauthorized. Usually used when the credentials are missing or invalid.
*/
class UnauthorizedError extends HttpError {
constructor(message) {
super(message || 'Unauthorized', 401);
}
}
/**
* Used to generate a 403 Forbidden. Usually used when user lacks sufficient permission to access a ressource.
*/
class ForbiddenError extends HttpError {
constructor(message) {
super(message || 'Forbidden', 403);
}
}
/**
* Used to generate a 404 Not Found. Usually used when the requested `Item` is not found.
*/
class NotFoundError extends HttpError {
constructor(message) {
super(message || 'Not found', 404);
}
}
/**
* Used to generate a 408 Timeout Error. Usually used when the call length exceeds the received Operation Deadline.
*/
class TimeoutError extends HttpError {
constructor(message) {
super(message || 'Not found', 408);
}
}
/**
* Used to generate a 410 Resource Gone.
*
* @deprecated Use ProviderInstanceLockedError instead when the provider instance is locked or unavailable.
*/
class ResourceGoneError extends HttpError {
constructor(message) {
super(message || 'Resource Gone', 410);
}
}
/**
* Used to generate a 422 Unprocessable Entity. Usually used when an operation is invalid.
*/
class UnprocessableEntityError extends HttpError {
constructor(message) {
super(message || 'Unprocessable Entity', 422);
}
}
/**
* Used to generate a 423 Provider Instance Locked.
*/
class ProviderInstanceLockedError extends HttpError {
constructor(message) {
super(message || 'Provider instance locked or unavailable', 423);
}
}
/**
* Used to generate a 429 Rate Limit Exceeded. Usually used when an operation triggers or would trigger a rate limit
* error on the provider's side.
*/
class RateLimitExceededError extends HttpError {
constructor(message) {
super(message || 'Rate Limit Exceeded', 429);
}
}
var httpErrors = /*#__PURE__*/Object.freeze({
__proto__: null,
BadRequestError: BadRequestError,
ForbiddenError: ForbiddenError,
HttpError: HttpError,
NotFoundError: NotFoundError,
ProviderInstanceLockedError: ProviderInstanceLockedError,
RateLimitExceededError: RateLimitExceededError,
ResourceGoneError: ResourceGoneError,
TimeoutError: TimeoutError,
UnauthorizedError: UnauthorizedError,
UnprocessableEntityError: UnprocessableEntityError
});
class InvalidHandler extends Error {
}
/**
* Processes provider response codes and returns the corresponding errors to be translated further in our responses
*
* @param responseStatus the reponseStatus of the request. Any HTTP response code passed here will result in an error!
* @param message The message returned by the provider
*/
// Keep in errors.ts instead of httpErrors.ts because we do not need to export it outside of the sdk
function buildHttpError(responseStatus, message) {
let httpError;
if (responseStatus === 400) {
httpError = new BadRequestError(message);
}
else if (responseStatus === 401) {
httpError = new UnauthorizedError(message);
}
else if (responseStatus === 403) {
httpError = new ForbiddenError(message);
}
else if (responseStatus === 404) {
httpError = new NotFoundError(message);
}
else if (responseStatus === 408) {
httpError = new TimeoutError(message);
}
else if (responseStatus === 410) {
httpError = new ResourceGoneError(message);
}
else if (responseStatus === 422) {
httpError = new UnprocessableEntityError(message);
}
else if (responseStatus === 423) {
httpError = new ProviderInstanceLockedError(message);
}
else if (responseStatus === 429) {
httpError = new RateLimitExceededError(message);
}
else {
httpError = new HttpError(message, responseStatus);
}
return httpError;
}
function assertValidPath(path) {
if (!path.startsWith('/')) {
throw new InvalidHandler(`The provided path '${path}' is invalid. All paths must start with a '/'.`);
}
if (path.length > 1 && path.endsWith('/')) {
throw new InvalidHandler(`The provided path '${path}' is invalid. Paths must not end with a '/'.`);
}
}
function assertValidConfiguration(path, pathWithIdentifier, handlers) {
if (path === pathWithIdentifier) {
const individualHandlers = ['getItem', 'updateItem', 'deleteItem'];
const collectionHandlers = ['getCollection', 'createItem'];
const hasIndividualHandlers = individualHandlers.some(handler => handler in handlers);
const hasCollectionHandlers = collectionHandlers.some(handler => handler in handlers);
if (hasIndividualHandlers && hasCollectionHandlers) {
throw new InvalidHandler(`The provided path '${path}' doesn't differentiate between individual and collection level operation, so you cannot define both.`);
}
}
}
function parsePath(path) {
const pathParts = path.split('/');
const lastPart = pathParts.at(-1);
if (pathParts.length > 1 && lastPart && lastPart.startsWith(':')) {
pathParts.pop();
}
return { pathWithIdentifier: path, path: pathParts.join('/') };
}
function assertCreateItemRequestPayload(body) {
if (typeof body !== 'object' || body === null) {
throw new BadRequestError('Invalid CreateItemRequestPayload');
}
}
function assertUpdateItemRequestPayload(body) {
if (typeof body !== 'object' || body === null) {
throw new BadRequestError('Invalid UpdateItemRequestPayload');
}
}
function assertWebhookParseRequestPayload(body) {
if (typeof body !== 'object' || body === null) {
throw new BadRequestError('Invalid WebhookParseRequestPayload');
}
if (!('payload' in body) || typeof body.payload !== 'string') {
throw new BadRequestError("Missing required 'payload' property in WebhookParseRequestPayload");
}
if (!('url' in body) || typeof body.url !== 'string') {
throw new BadRequestError("Missing required 'url' property in WebhookParseRequestPayload");
}
}
function assertWebhookSubscriptionRequestPayload(body) {
if (typeof body !== 'object' || body === null) {
throw new BadRequestError('Invalid WebhookSubscriptionRequestPayload');
}
if (!('itemPath' in body) || typeof body.itemPath !== 'string') {
throw new BadRequestError("Missing required 'itemPath' property in WebhookSubscriptionRequestPayload");
}
if (!('targetUrl' in body) || typeof body.targetUrl !== 'string') {
throw new BadRequestError("Missing required 'targetUrl' property in WebhookSubscriptionRequestPayload");
}
if (!('action' in body) || typeof body.action !== 'string') {
throw new BadRequestError("Missing required 'action' property in WebhookSubscriptionRequestPayload");
}
if (!['start', 'stop'].includes(body.action)) {
throw new BadRequestError("Invalid value for 'action' property in WebhookSubscriptionRequestPayload");
}
}
class Handler {
path;
pathWithIdentifier;
handlers;
constructor(inputPath, handlers) {
assertValidPath(inputPath);
const { pathWithIdentifier, path } = parsePath(inputPath);
assertValidConfiguration(path, pathWithIdentifier, handlers);
this.pathWithIdentifier = pathWithIdentifier;
this.path = path;
this.handlers = handlers;
}
generate() {
const router = express.Router({ caseSensitive: true });
console.debug(`\x1b[33mMounting handler at path ${this.pathWithIdentifier}`);
if (this.handlers.getCollection) {
const handler = this.handlers.getCollection;
console.debug(` Enabling getCollection at GET ${this.path}`);
router.get(this.path, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
const collection = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
search: res.locals.search,
selects: res.locals.selects,
filters: res.locals.filters,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
relations: res.locals.relations,
});
res.status(200).send(collection);
});
}
if (this.handlers.createItem) {
const handler = this.handlers.createItem;
console.debug(` Enabling createItem at POST ${this.path}`);
router.post(this.path, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
assertCreateItemRequestPayload(req.body);
const createItemSummary = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
body: req.body,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(201).send(createItemSummary);
});
}
if (this.handlers.createBlob) {
const handler = this.handlers.createBlob;
console.debug(` Enabling createBlob at POST ${this.pathWithIdentifier}`);
router.post(this.path, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
/**
* Some of the integrations, servicenow for example,
* will need to add more information to the form data that is being passed to the upload attachment handler.
* 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.
*/
const bb = busboy({ headers: req.headers });
bb.on('file', async (_name, file, info) => {
try {
const createdBlob = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
body: {
file: file,
mimeType: info.mimeType,
encoding: info.encoding,
filename: info.filename,
},
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(201).send(createdBlob);
}
catch (error) {
if (error instanceof HttpError) {
res.status(error.status).send(error);
}
else {
res.status(500).send({ message: `Error creating the blob: ${error}` });
}
}
});
bb.on('error', error => {
res.status(500).send({ message: `Error parsing the form data: ${error}` });
});
req.pipe(bb);
});
}
if (this.handlers.getItem) {
const handler = this.handlers.getItem;
console.debug(` Enabling getItem at GET ${this.pathWithIdentifier}`);
router.get(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
const item = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(200).send(item);
});
}
if (this.handlers.updateItem) {
const handler = this.handlers.updateItem;
console.debug(` Enabling updateItem at PATCH ${this.pathWithIdentifier}`);
router.patch(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
assertUpdateItemRequestPayload(req.body);
const item = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
body: req.body,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(200).send(item);
});
}
if (this.handlers.deleteItem) {
const handler = this.handlers.deleteItem;
console.debug(` Enabling deleteItem at DELETE ${this.pathWithIdentifier}`);
router.delete(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(204).send(null);
});
}
if (this.handlers.getBlob) {
console.debug(` Enabling getBlob at GET ${this.pathWithIdentifier}`);
const handler = this.handlers.getBlob;
router.get(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
const blob = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
});
const reader = blob.getReader();
let isDone = false;
try {
while (!isDone) {
const chunk = await reader.read();
isDone = chunk.done;
if (chunk.value) {
res.write(chunk.value);
}
}
}
finally {
reader.releaseLock();
}
res.end();
});
}
if (this.handlers.getCredentialAccount) {
const handler = this.handlers.getCredentialAccount;
console.debug(` Enabling getCredentialAccount at GET ${this.pathWithIdentifier}`);
router.get(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
const credentialAccount = await handler({
credentials: res.locals.credentials,
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(200).send(credentialAccount);
});
}
if (this.handlers.acknowledgeWebhooks) {
const handler = this.handlers.acknowledgeWebhooks;
console.debug(` Enabling acknowledgeWebhooks at POST ${this.pathWithIdentifier}`);
router.post(this.pathWithIdentifier, async (req, res) => {
assertWebhookParseRequestPayload(req.body);
const response = await handler({
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
body: req.body,
});
res.status(200).send(response);
});
}
if (this.handlers.parseWebhooks) {
const handler = this.handlers.parseWebhooks;
console.debug(` Enabling parseWebhooks at POST ${this.pathWithIdentifier}`);
router.post(this.pathWithIdentifier, async (req, res) => {
assertWebhookParseRequestPayload(req.body);
const response = await handler({
secrets: res.locals.secrets,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
body: req.body,
});
res.status(200).send(response);
});
}
if (this.handlers.updateWebhookSubscriptions) {
const handler = this.handlers.updateWebhookSubscriptions;
console.debug(` Enabling updateWebhookSubscriptions at PUT ${this.pathWithIdentifier}`);
router.put(this.pathWithIdentifier, async (req, res) => {
if (!res.locals.credentials) {
throw new UnauthorizedError();
}
assertWebhookSubscriptionRequestPayload(req.body);
const response = await handler({
secrets: res.locals.secrets,
credentials: res.locals.credentials,
body: req.body,
logger: res.locals.logger,
signal: res.locals.signal,
params: req.params,
query: req.query,
});
res.status(204).send(response);
});
}
console.debug(`\x1b[0m`);
return router;
}
}
function extractCorrelationId(req, res, next) {
res.locals.correlationId = req.header('X-Unito-Correlation-Id') ?? crypto.randomUUID();
next();
}
const CREDENTIALS_HEADER = 'X-Unito-Credentials';
function extractCredentials(req, res, next) {
const credentialsHeader = req.header(CREDENTIALS_HEADER);
if (credentialsHeader) {
let credentials;
try {
credentials = JSON.parse(Buffer.from(credentialsHeader, 'base64').toString('utf8'));
}
catch {
throw new BadRequestError(`Malformed HTTP header ${CREDENTIALS_HEADER}`);
}
res.locals.credentials = credentials;
}
next();
}
function onError(err, _req, res, next) {
if (res.headersSent) {
return next(err);
}
let error;
if (err instanceof HttpError) {
error = {
code: err.status.toString(),
message: err.message,
details: {
stack: err.stack,
},
};
}
else {
error = {
code: '500',
message: 'Oops! Something went wrong',
originalError: {
code: err.name,
message: err.message,
details: {
stack: err.stack,
},
},
};
}
res.locals.error = structuredClone(error);
// Keep the stack details in development for the Debugger
if (process.env.NODE_ENV !== 'development') {
delete error.details;
delete error.originalError?.details;
}
res.status(Number(error.code)).json(error);
}
// The operators are ordered by their symbol length, in descending order.
// This is necessary because the symbol of an operator can be
// a subset of the symbol of another operator.
//
// For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
const ORDERED_OPERATORS = Object.values(integrationApi.OperatorTypes).sort((o1, o2) => o2.length - o1.length);
function extractFilters(req, res, next) {
const rawFilters = req.query.filter;
res.locals.filters = [];
if (typeof rawFilters === 'string') {
for (const rawFilter of rawFilters.split(',')) {
for (const operator of ORDERED_OPERATORS) {
if (rawFilter.includes(operator)) {
const [field, valuesRaw] = rawFilter.split(operator, 2);
const values = valuesRaw ? valuesRaw.split('|').map(decodeURIComponent) : [];
res.locals.filters.push({ field: field, operator, values });
break;
}
}
}
}
next();
}
function onFinish(req, res, next) {
res.on('finish', function () {
const logger = res.locals.logger ?? new Logger();
const error = res.locals.error;
const durationInNs = Number(process.hrtime.bigint() - res.locals.requestStartTime);
const durationInMs = (durationInNs / 1_000_000) | 0;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${durationInMs} ms`;
const metadata = {
duration: durationInNs,
// Use reserved and standard attributes of Datadog
// https://app.datadoghq.com/logs/pipelines/standard-attributes
http: { method: req.method, status_code: res.statusCode, url_details: { path: req.originalUrl } },
...(error
? {
error: {
kind: error.message,
stack: (error.originalError?.details?.stack ?? error.details?.stack),
message: error.originalError?.message ?? error.message,
},
}
: {}),
};
if ([404, 429].includes(res.statusCode)) {
logger.warn(message, metadata);
}
else if (res.statusCode >= 400) {
logger.error(message, metadata);
}
else if (req.originalUrl !== '/health') {
// Don't log successful health check requests
logger.info(message, metadata);
}
});
next();
}
function notFound(req, res, _next) {
const error = {
code: '404',
message: `Path ${req.path} not found.`,
};
res.status(404).json(error);
}
const ADDITIONAL_CONTEXT_HEADER = 'X-Unito-Additional-Logging-Context';
function injectLogger(req, res, next) {
const logger = new Logger({ correlation_id: res.locals.correlationId });
res.locals.logger = logger;
const rawAdditionalContext = req.header(ADDITIONAL_CONTEXT_HEADER);
if (typeof rawAdditionalContext === 'string') {
try {
const additionalContext = JSON.parse(rawAdditionalContext);
logger.decorate(additionalContext);
}
catch (error) {
logger.warn(`Failed parsing header ${ADDITIONAL_CONTEXT_HEADER}: ${rawAdditionalContext}`);
}
}
next();
}
function start(_, res, next) {
res.locals.requestStartTime = process.hrtime.bigint();
next();
}
const SECRETS_HEADER = 'X-Unito-Secrets';
function extractSecrets(req, res, next) {
const secretsHeader = req.header(SECRETS_HEADER);
if (secretsHeader) {
let secrets;
try {
secrets = JSON.parse(Buffer.from(secretsHeader, 'base64').toString('utf8'));
}
catch {
throw new BadRequestError(`Malformed HTTP header ${SECRETS_HEADER}`);
}
res.locals.secrets = secrets;
}
next();
}
function extractSearch(req, res, next) {
const rawSearch = req.query.search;
if (typeof rawSearch === 'string') {
res.locals.search = rawSearch;
}
else {
res.locals.search = null;
}
next();
}
function extractSelects(req, res, next) {
const rawSelect = req.query.select;
if (typeof rawSelect === 'string') {
res.locals.selects = rawSelect.split(',').map(select => decodeURIComponent(select));
}
else {
res.locals.selects = [];
}
next();
}
function extractRelations(req, res, next) {
const rawRelations = req.query.relations;
res.locals.relations = typeof rawRelations === 'string' ? rawRelations.split(',') : [];
next();
}
const OPERATION_DEADLINE_HEADER = 'X-Unito-Operation-Deadline';
function extractOperationDeadline(req, res, next) {
const operationDeadlineHeader = Number(req.header(OPERATION_DEADLINE_HEADER));
if (operationDeadlineHeader) {
// `operationDeadlineHeader` represents a timestamp in the future, in seconds.
// We need to convert it to a number of milliseconds.
const deadline = operationDeadlineHeader * 1000 - Date.now();
if (deadline > 0) {
res.locals.signal = AbortSignal.timeout(deadline);
}
else {
throw new TimeoutError('Request already timed out upon reception');
}
}
next();
}
function onHealthCheck(_req, res) {
res.status(200).json({});
}
function printErrorMessage(message) {
console.error();
console.error(`\x1b[31m Oops! Something went wrong! \x1b[0m`);
console.error(message);
}
/**
* Main class for the Integration SDK providing an abstraction layer between the Integration's Graph definition
* and the underlying HTTP server.
*
* An `Integration` instance can have multiple handlers configured to handle different routes. Upon receiving a request,
* the Integration will parse the request to extract meaninful information, match the request to the appropriate handler
* method and forward that information in the form a {@link Context} object.
* The Integration also offer standardized error handling and logging to help you build a robust
* and reliable Integration.
*
* See our {@link https://dev.unito.io/docs/ | documentation} for more examples on how to build an integration.
*/
class Integration {
handlers;
instance = undefined;
port;
/**
* Creates a new Integration instance with default port set to 9200.
*
* @param options The {@link Options} to configure the Integration instance. Can be used to override the default port.
*/
constructor(options = {}) {
this.port = options.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 9200);
this.handlers = [];
}
/**
* Adds a group of common handlers to the integration.
*
* Handlers added to the integration can be one of the following:
* - `ItemHandlers`: A group of handlers defining the implementation of the Operations available for a given item.
* - `CredentialAccountHandlers`: A handler returning the CredentialAccount linked to the caller's credentials.
* - `ParseWebhookHandlers`: A handler parsing the content of an incoming webhook.
* - `WebhookSubscriptionHandlers`: A handler subscribing or unsubscribing to a particular webhook.
* - `AcknowledgeWebhookHandlers`: A handler acknowledging the reception of a webhook.
*
* To accomodate the fact that ItemHandlers may specify multiple operations, some at the collection level, some at the
* item level, we need a way to define the route for each of these operations.
* To achieve this, we assume that if the last part of the path is a variable, then it is the item identifier.
*
* @example The following path: `/trainer/:trainerId/pokemons/:pokemonId` will lead to the following
* routes:
* - getCollection will be called for `GET /trainer/:trainerId/pokemons/` requests
* - getItem will be called for `GET /trainer/:trainerId/pokemons/:pokemonId` requests
* - createItem will be called for `POST /trainer/:trainerId/pokemons/` requests
* - updateItem will be called for `PATCH /trainer/:trainerId/pokemons/:pokemonId` requests
* - deleteItem will be called for `DELETE /trainer/:trainerId/pokemons/:pokemonId` requests
*
* @param path The path to be used as Route for the handlers.
* @param handlers The Handlers definition.
*/
addHandler(path, handlers) {
if (this.instance) {
printErrorMessage(`
It seems like you're trying to add a handler after the server has already started. This is probably
a mistake as calling the start() function essentially starts the server and ignore any further change.
To fix this error, move all your addHandler() calls before the start() function.
`);
process.exit(1);
}
try {
this.handlers.push(new Handler(path, handlers));
}
catch (error) {
if (error instanceof InvalidHandler) {
printErrorMessage(`
It seems like you're trying to add an invalid handler. The exact error message is:
> ${error.message}
You must address this issue before trying again.
`);
}
else {
printErrorMessage(`
An unexpected error happened as we were trying to add your handler. The exact error message is;
> ${error.message}
`);
}
process.exit(1);
}
}
/**
* Starts the server and listens on the specified port (default to 9200).
*
* @remarks
* This function should be called after all the handlers have been added to the integration
* and any other configuration is completed.
*/
start() {
// Express Server initialization
const app = express();
// Parse query strings with https://github.com/ljharb/qs.
app.set('query parser', 'simple');
app.use(express.json());
// Must be one of the first handlers (to catch all the errors).
app.use(onFinish);
// Instantiate internal middlewares.
app.use(start);
app.use(extractCorrelationId);
app.use(injectLogger);
// Making sure we log all incoming requests (except to '/health'), prior any processing.
app.use((req, res, next) => {
if (req.originalUrl !== '/health') {
res.locals.logger.info(`Initializing request for ${req.originalUrl}`);
}
next();
});
// Instantiate application middlewares. These can throw, so they have an implicit dependency on the internal
// middlewares such as the logger, the correlationId, and the error handling.
app.get('/health', onHealthCheck);
app.use(extractCredentials);
app.use(extractSecrets);
app.use(extractFilters);
app.use(extractSearch);
app.use(extractSelects);
app.use(extractRelations);
app.use(extractOperationDeadline);
// Load handlers as needed.
if (this.handlers.length) {
for (const handler of this.handlers) {
app.use(handler.generate());
}
}
else {
printErrorMessage(`
It seems like you're trying to start the server without any handler. This is probably a mistake as the
server wouldn't expose any route. To fix this error, add at least one handler before calling the start()
function.
`);
process.exit(1);
}
// Must be the (last - 1) handler.
app.use(onError);
// Must be the last handler.
app.use(notFound);
// Start the server.
this.instance = app.listen(this.port, () => console.info(`Server started on port ${this.port}.`));
}
}
/**
* The Provider class is a wrapper around the fetch function to call a provider's HTTP API.
*
* Defines methods for the following HTTP methods: GET, POST, PUT, PATCH, DELETE.
*
* Needs to be initialized with a prepareRequest function to define the Provider's base URL and any specific headers to
* add to the requests, can also be configured to use a provided rate limiting function, and custom error handler.
*
* Multiple `Provider` instances can be created, with different configurations to call different providers APIs with
* different rateLimiting functions, as needed.
* @see {@link RateLimiter}
* @see {@link prepareRequest}
* @see {@link customErrorHandler}
*/
class Provider {
/**
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
*/
rateLimiter = undefined;
/**
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
*
* This is applied at large to all requests made to the provider. If you need to add specific headers to a single request,
* pass it through the RequestOptions object when calling the Provider's methods.
*/
prepareRequest;
/**
* (Optional) Custom error handler to handle specific errors returned by the provider.
*
* If provided, this method should only care about custom errors returned by the provider and return the corresponding
* HttpError from the SDK. If the error encountered is a standard error, it should return undefined and let the SDK handle it.
*
* @see buildHttpError for the list of standard errors the SDK can handle.
*/
customErrorHandler;
/**
* Initializes a Provider with the given options.
*
* @property {@link prepareRequest} - function to define the Provider's base URL and specific headers to add to the request.
* @property {@link RateLimiter} - function to limit the rate of calls to the provider based on the caller's credentials.
* @property {@link customErrorHandler} - function to handle specific errors returned by the provider.
*/
constructor(options) {
this.prepareRequest = options.prepareRequest;
this.rateLimiter = options.rateLimiter;
this.customErrorHandler = options.customErrorHandler;
}
/**
* Performs a GET request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async get(endpoint, options) {
return this.fetchWrapper(endpoint, null, {
...options,
method: 'GET',
defaultHeaders: {
Accept: 'application/json',
},
});
}
/**
* Performs a GET request to the provider and return the response as a ReadableStream.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Accept: application/octet-stream
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
* @returns The streaming {@link Respo