@unito/integration-debugger
Version:
The Unito Integration Debugger
455 lines (454 loc) • 20.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Instance = exports.ReadOperations = exports.Operation = void 0;
exports.buildStepUniqueId = buildStepUniqueId;
exports.buildSchemaUniqueId = buildSchemaUniqueId;
exports.create = create;
const integration_api_1 = require("@unito/integration-api");
const Validator = __importStar(require("./validator"));
const headers_1 = __importDefault(require("../resources/headers"));
var Operation;
(function (Operation) {
Operation["GetCollection"] = "getCollection";
Operation["GetItem"] = "getItem";
Operation["GetBlob"] = "getBlob";
Operation["CreateItem"] = "createItem";
Operation["UpdateItem"] = "updateItem";
Operation["DeleteItem"] = "deleteItem";
Operation["SubscribeWebhook"] = "subscribeWebhook";
Operation["UnsubscribeWebhook"] = "unsubscribeWebhook";
Operation["GetCredentialAccount"] = "getCredentialAccount";
})(Operation || (exports.Operation = Operation = {}));
exports.ReadOperations = Object.freeze([
Operation.GetCollection,
Operation.GetItem,
Operation.GetCredentialAccount,
Operation.GetBlob,
]);
var ValidationType;
(function (ValidationType) {
ValidationType["SHAPE"] = "shape";
ValidationType["ITEM"] = "item";
})(ValidationType || (ValidationType = {}));
class IntegrationCallFailed extends Error {
payloadOut;
code;
constructor(message, code, payloadOut) {
super(message);
this.payloadOut = payloadOut;
this.code = code;
}
}
function buildStepUniqueId(stepOperation, stepPath) {
return [stepOperation, stepPath].join('.');
}
function buildSchemaUniqueId(stepPath, relationName, fieldName) {
return [stepPath, relationName, fieldName]
.filter(v => v)
.join('/')
.replaceAll('//', '/');
}
async function create(crawlerOptions) {
const validator = await Validator.create();
return new Instance(crawlerOptions, validator);
}
class Instance {
proxyCall;
validator;
timeout;
nextSteps;
visitedRelationSchemas;
visitedFieldSchemas;
constructor(crawlerOptions, validator) {
this.proxyCall = crawlerOptions.proxyCall;
this.validator = validator;
this.timeout = crawlerOptions.timeout;
this.visitedRelationSchemas = new Map();
this.visitedFieldSchemas = new Map();
this.nextSteps = [];
}
async next() {
// Fetch the next step to execute.
const stepToExecute = this.nextSteps.shift();
if (!stepToExecute) {
return undefined;
}
// Set the operation deadline header to 20 seconds from now, if not already set.
// Do nothing if no headers is present, happens only during unit tests.
if (this.timeout && stepToExecute.headersIn) {
stepToExecute.headersIn[headers_1.default.OPERATION_DEADLINE] ??= (Math.floor(Date.now() / 1000) + this.timeout).toString();
}
// Execute the step.
const currentStep = await this.execute(stepToExecute);
if (currentStep.errors.length > 0) {
return { step: currentStep, discoveredSteps: [] };
}
return { step: currentStep, discoveredSteps: this.discoverSteps(currentStep) };
}
discoverSteps(currentStep) {
const payloadOut = currentStep.payloadOut;
const discoveredSteps = [];
if (currentStep.operation === Operation.CreateItem) {
const itemSummary = payloadOut;
// Visit the created item.
discoveredSteps.push({
operation: Operation.GetItem,
path: itemSummary.path,
requestSchema: undefined,
schemaPath: currentStep.schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
if (payloadOut.relations) {
const item = payloadOut;
for (const relation of item.relations) {
const schemaPath = buildSchemaUniqueId(currentStep.path, relation.name);
this.visitedRelationSchemas.set(schemaPath, relation.schema);
this.visitedFieldSchemas.set(schemaPath, relation.schema.fields ?? []);
// Visit a relation.
discoveredSteps.push({
operation: Operation.GetCollection,
path: relation.path,
requestSchema: relation.requestSchema,
schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
for (const field of relation.schema.fields ?? []) {
// Visit a referenced collection.
if (field.type === integration_api_1.FieldValueTypes.REFERENCE) {
const referencePath = field.reference.path;
const referenceFields = field.reference.schema === '__self' ? relation.schema.fields : field.reference.schema.fields;
const schemaPath = buildSchemaUniqueId(currentStep.path, relation.name, field.name);
this.visitedFieldSchemas.set(schemaPath, referenceFields ?? []);
discoveredSteps.push({
operation: Operation.GetCollection,
path: referencePath,
requestSchema: undefined,
schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
}
}
const fields = this.visitedFieldSchemas.get(currentStep.schemaPath ?? '') ?? [];
for (const field of fields) {
// Visit a reference / an array of references.
// TODO: find references in nested objects.
if (field.type === integration_api_1.FieldValueTypes.REFERENCE && item.fields[field.name]) {
const schemaPath = buildSchemaUniqueId(currentStep.schemaPath, field.name);
const references = [item.fields[field.name]].flat(1);
for (const reference of references) {
discoveredSteps.push({
operation: Operation.GetItem,
path: reference.path,
requestSchema: undefined,
schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
}
if (field.type === integration_api_1.FieldValueTypes.BLOB && item.fields[field.name]) {
const blob = item.fields[field.name];
discoveredSteps.push({
operation: Operation.GetBlob,
path: blob.path,
requestSchema: undefined,
schemaPath: '',
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
}
}
else if (payloadOut.data) {
const collection = payloadOut;
// Visit an item.
for (const entry of collection.data) {
discoveredSteps.push({
operation: Operation.GetItem,
path: entry.path,
requestSchema: entry.requestSchema,
schemaPath: currentStep.schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
// Visit the next page.
if (collection.info.nextPage) {
discoveredSteps.push({
operation: Operation.GetCollection,
path: collection.info.nextPage,
requestSchema: undefined,
schemaPath: currentStep.schemaPath,
parentOperation: currentStep.operation,
parentPath: currentStep.path,
errors: [],
warnings: [],
});
}
}
return discoveredSteps;
}
prepend(step) {
this.nextSteps.unshift(this.copyStep(step));
}
append(step) {
this.nextSteps.push(this.copyStep(step));
}
remove(step) {
const uniqueId = buildStepUniqueId(step.operation, step.path);
let index;
do {
index = this.nextSteps.findIndex(s => buildStepUniqueId(s.operation, s.path) === uniqueId);
if (index !== -1) {
this.nextSteps.splice(index, 1);
}
} while (index !== -1);
}
remaining() {
return this.nextSteps;
}
getRelationSchema(schemaPath) {
return this.visitedRelationSchemas.get(schemaPath);
}
getFieldSchemas(schemaPath) {
return this.visitedFieldSchemas.get(schemaPath);
}
async execute(incomingStep) {
const step = JSON.parse(JSON.stringify(incomingStep));
try {
const response = await this.proxyCall(step);
step.headersOut = response.headers;
step.payloadOut = response.payload;
this.validateResponse(response, step);
}
catch (err) {
if (err instanceof IntegrationCallFailed) {
step.payloadOut = err.payloadOut;
step.errors.push({
message: `${err.message} (${err.code})`,
detailedMessage: undefined,
keyword: 'call',
instancePath: '/call',
schemaPath: '',
});
}
else if (err instanceof Validator.ValidationFailed) {
step.payloadOut = err.payload;
step.errors.push(...(err.details ?? []));
}
else {
step.errors.push({
message: err.message ?? err,
detailedMessage: undefined,
keyword: 'unknown',
instancePath: '/unknown',
schemaPath: '',
});
}
}
return step;
}
validateResponse(response, step) {
if (response.status >= 200 && response.status <= 299) {
const fields = this.visitedFieldSchemas.get(step.schemaPath ?? '');
if (step.operation === Operation.GetCollection) {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
for (const itemSummary of response.payload.data) {
this.validatePayload(step, itemSummary, ValidationType.ITEM, {
schemaPath: step.schemaPath,
fields,
partial: true,
requiredFields: [],
});
}
this.validateStatus(step, response, integration_api_1.StatusCodes.OK, `A successful '${Operation.GetCollection}' operation must return a 200 OK`);
}
else if (step.operation === Operation.GetItem) {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validatePayload(step, response.payload, ValidationType.ITEM, {
schemaPath: step.schemaPath,
fields,
partial: false,
requiredFields: fields?.map(field => field.name) ?? [],
});
this.validateStatus(step, response, integration_api_1.StatusCodes.OK, `A successful '${Operation.GetItem}' operation must return a 200 OK`);
}
else if (step.operation === Operation.CreateItem) {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validatePayload(step, response.payload, ValidationType.ITEM, {
schemaPath: step.schemaPath,
fields,
partial: true,
requiredFields: [],
});
this.validateStatus(step, response, integration_api_1.StatusCodes.CREATED, `A successful '${Operation.CreateItem}' operation must return a 201 Created`);
}
else if (step.operation === Operation.UpdateItem) {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validatePayload(step, response.payload, ValidationType.ITEM, {
schemaPath: step.schemaPath,
fields,
partial: false,
requiredFields: fields?.map(field => field.name) ?? [],
});
this.validateStatus(step, response, integration_api_1.StatusCodes.OK, `A successful '${Operation.UpdateItem}' operation must return a 200 OK`);
}
else if (step.operation === Operation.DeleteItem) {
this.validateStatus(step, response, integration_api_1.StatusCodes.NO_CONTENT, `A successful '${Operation.DeleteItem}' operation must return a 204 No Content`);
}
else if (step.operation === Operation.SubscribeWebhook) {
this.validateStatus(step, response, integration_api_1.StatusCodes.NO_CONTENT, `A successful '${Operation.SubscribeWebhook}' operation must return a 204 No Content`);
}
else if (step.operation === Operation.UnsubscribeWebhook) {
this.validateStatus(step, response, integration_api_1.StatusCodes.NO_CONTENT, `A successful '${Operation.UnsubscribeWebhook}' operation must return a 204 No Content`);
}
else if (step.operation === Operation.GetCredentialAccount) {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validateStatus(step, response, integration_api_1.StatusCodes.OK, `A successful '${Operation.GetCredentialAccount}' operation must return a 200 OK`);
}
else if (step.operation === Operation.GetBlob) {
// this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validateStatus(step, response, integration_api_1.StatusCodes.OK, `A successful '${Operation.GetBlob}' operation must return a 200 OK`);
}
else {
this.validatePayload(step, response.payload, ValidationType.SHAPE, {});
this.validatePayload(step, response.payload, ValidationType.ITEM, {
schemaPath: step.schemaPath,
fields,
partial: false,
requiredFields: fields?.map(field => field.name) ?? [],
});
}
}
else if (Object.values(integration_api_1.StatusCodes).includes(response.status)) {
// Validate handled and known status code
const validationResult = this.validator.validateShape(response.payload, 'error');
if (validationResult.errors.length) {
throw new Validator.ValidationFailed(response.payload, validationResult.errors);
}
throw new IntegrationCallFailed('The integration returned an error', response.status, response.payload);
}
else {
throw new IntegrationCallFailed('The integration returned an unsupported status code', response.status, response.payload);
}
}
validatePayload(step, payload, validationType, validateItemOptions) {
let validationResult;
if (validationType === ValidationType.SHAPE) {
const schema = this.getSchemaByOperation(step.operation);
validationResult = this.validator.validateShape(payload, schema);
}
else if (validationType === ValidationType.ITEM) {
validationResult = this.validator.validateItem(payload, validateItemOptions);
}
else {
throw new Error(`Hey fellow developer, the ValidationType ${validationType} must be handled here.`);
}
if (validationResult.warnings.length > 0) {
step.warnings = validationResult.warnings;
}
if (validationResult.errors.length > 0) {
throw new Validator.ValidationFailed(payload, validationResult.errors);
}
}
validateStatus(step, response, expected, errorMessage) {
if (response.status !== expected) {
const details = {
keyword: 'call',
message: 'The integration returned an unsupported status code',
detailedMessage: errorMessage,
instancePath: step.path,
schemaPath: step.schemaPath ?? '',
};
throw new Validator.ValidationFailed(response.payload, [details]);
}
}
getSchemaByOperation(operation) {
switch (operation) {
case Operation.CreateItem:
return 'itemSummary';
case Operation.GetCollection:
return 'collection';
case Operation.GetCredentialAccount:
return 'credentialAccount';
default:
return 'item';
}
}
copyStep(step) {
const stepCopy = {
operation: step.operation,
path: step.path,
requestSchema: step.requestSchema,
schemaPath: step.schemaPath,
parentOperation: step.parentOperation,
parentPath: step.parentPath,
errors: [],
warnings: [],
};
if (step.payloadIn) {
stepCopy.payloadIn = step.payloadIn;
}
if (step.headersIn) {
stepCopy.headersIn = step.headersIn;
}
if (step.context) {
stepCopy.context = { ...step.context };
}
return stepCopy;
}
}
exports.Instance = Instance;