UNPKG

@unito/integration-sdk

Version:

Integration SDK

601 lines (487 loc) 18.8 kB
import { Router } from 'express'; import * as API from '@unito/integration-api'; import { InvalidHandler } from './errors.js'; import { HttpError, UnauthorizedError, BadRequestError } from './httpErrors.js'; import { GetBlobContext, GetItemContext, GetCollectionContext, CreateBlobContext, CreateItemContext, UpdateItemContext, DeleteItemContext, GetCredentialAccountContext, ParseWebhooksContext, UpdateWebhookSubscriptionsContext, AcknowledgeWebhooksContext, } from './resources/context.js'; import busboy, { FileInfo } from 'busboy'; /** * Handler called to get an individual item. * * @param context {@link GetItemContext} * @returns The requested {@link API.Item}. */ export type GetItemHandler = (context: GetItemContext<any, any>) => Promise<API.Item>; /** * Handler called to retrieve a collection of items. * * @param context {@link GetCollectionContext} * @return An {@link API.Collection} containing requested items and a link to the next page, if applicable. */ export type GetCollectionHandler = (context: GetCollectionContext<any, any>) => Promise<API.Collection>; /** * Handler called to create an item. * * @param context {@link CreateBlobContext} * @returns An {@link API.Item} containing a path to the created item. */ export type CreateBlobHandler = (context: CreateBlobContext<any, any>) => Promise<API.Item>; /** * Handler called to create an item. * * @param context {@link CreateItemContext} * @returns An {@link API.ItemSummary} containing a path to the created item. */ export type CreateItemHandler = (context: CreateItemContext<any, any, any>) => Promise<API.ItemSummary>; /** * Handler called to update an item. * * @param context {@link UpdateItemContext} * @returns The updated {@link API.Item}. */ export type UpdateItemHandler = (context: UpdateItemContext<any, any, any>) => Promise<API.Item>; /** * Handler called to delete an item. * * @param context {@link DeleteItemContext} */ export type DeleteItemHandler = (context: DeleteItemContext<any, any>) => Promise<void>; /** * Handler called to get a Binary Large Object. * * @param context {@link BlobItemContext} * @returns A {@link ReadableStream} of the Blob. */ export type GetBlobHandler = (context: GetBlobContext<any, any>) => Promise<ReadableStream<Uint8Array>>; /** * Handler called to retrieve the account details associated with the credentials. * * @param context {@link GetCredentialAccountContext} * @returns The {@link API.CredentialAccount} associated with the credentials. */ export type GetCredentialAccountHandler = ( context: GetCredentialAccountContext<any, any>, ) => Promise<API.CredentialAccount>; /** * Handler called to parse the content of an incoming webhook. * * @param context {@link ParseWebhooksContext} * @returns The parsed content of the webhook as a {@link API.WebhookParseResponsePayload}. */ export type ParseWebhooksHandler = ( context: ParseWebhooksContext<any, any, any>, ) => Promise<API.WebhookParseResponsePayload>; /** * Handler called to subscribe or unsubscribe to a particular webhook. * * @param context {@link UpdateWebhookSubscriptionsContext} */ export type UpdateWebhookSubscriptionsHandler = ( context: UpdateWebhookSubscriptionsContext<any, any, any>, ) => Promise<void>; /** * Handler called to acknowledge the reception of a webhook. * * @param context {@link AcknowledgeWebhooksContext} * @returns The {@link API.WebhookAcknowledgeResponsePayload} to be sent back to the webhook provider. */ export type AcknowledgeWebhooksHandler = ( context: AcknowledgeWebhooksContext<any, any, any>, ) => Promise<API.WebhookAcknowledgeResponsePayload>; /** * Defines the implementation of the operations available for a given `Item`. * - In some cases (e.g. defining the `root` or {@link https://dev.unito.io/docs/connectors/apiSpecification/credentialAccount | me} * handlers), only a {@link GetItemHandler | getItem} handler is necessary. * - In most cases, you will want to define {@link GetCollectionHandler | getCollection}, * most likely {@link GetItemHandler | getItem}, and any other handlers relevant to the item. * * The `ItemHandlers` object can contain any of the following: * - {@link GetItemHandler | getItem}: A handler called to get an individual item. * - {@link GetCollectionHandler | getCollection}: A handler called to retrieve a collection of items. * - {@link CreateItemHandler | createItem}: A handler called to create an item. * - {@link UpdateItemHandler | updateItem}: A handler called to update an item. * - {@link DeleteItemHandler | deleteItem}: A handler called to delete an item. */ export type ItemHandlers = { getItem?: GetItemHandler; getCollection?: GetCollectionHandler; createBlob?: CreateBlobHandler; createItem?: CreateItemHandler; updateItem?: UpdateItemHandler; deleteItem?: DeleteItemHandler; }; export type BlobHandlers = { getBlob: GetBlobHandler; createBlob?: CreateBlobHandler; }; export type CredentialAccountHandlers = { getCredentialAccount: GetCredentialAccountHandler; }; export type ParseWebhookHandlers = { parseWebhooks: ParseWebhooksHandler; }; export type WebhookSubscriptionHandlers = { updateWebhookSubscriptions: UpdateWebhookSubscriptionsHandler; }; export type AcknowledgeWebhookHandlers = { acknowledgeWebhooks: AcknowledgeWebhooksHandler; }; export type HandlersInput = | ItemHandlers | BlobHandlers | CredentialAccountHandlers | ParseWebhookHandlers | WebhookSubscriptionHandlers | AcknowledgeWebhookHandlers; type Handlers = Partial< ItemHandlers & BlobHandlers & CredentialAccountHandlers & ParseWebhookHandlers & WebhookSubscriptionHandlers & AcknowledgeWebhookHandlers >; type Path = string & { __brand: 'Path' }; function assertValidPath(path: string): asserts path is 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: Path, pathWithIdentifier: Path, handlers: 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: 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('/') as Path }; } function assertCreateItemRequestPayload(body: unknown): asserts body is API.CreateItemRequestPayload { if (typeof body !== 'object' || body === null) { throw new BadRequestError('Invalid CreateItemRequestPayload'); } } function assertUpdateItemRequestPayload(body: unknown): asserts body is API.UpdateItemRequestPayload { if (typeof body !== 'object' || body === null) { throw new BadRequestError('Invalid UpdateItemRequestPayload'); } } function assertWebhookParseRequestPayload(body: unknown): asserts body is API.WebhookParseRequestPayload { 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: unknown): asserts body is API.WebhookSubscriptionRequestPayload { 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"); } } export class Handler { private path: Path; private pathWithIdentifier: Path; private handlers: Handlers; constructor(inputPath: string, handlers: HandlersInput) { assertValidPath(inputPath); const { pathWithIdentifier, path } = parsePath(inputPath); assertValidConfiguration(path, pathWithIdentifier, handlers); this.pathWithIdentifier = pathWithIdentifier; this.path = path; this.handlers = handlers; } public generate(): Router { const router: Router = 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: FileInfo) => { 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 as HttpError).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; } }