UNPKG

@unito/integration-sdk

Version:

Integration SDK

1,278 lines (1,250 loc) 65.6 kB
'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