@unito/integration-sdk
Version:
Integration SDK
601 lines (487 loc) • 18.8 kB
text/typescript
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;
}
}