@boltic/cli
Version:
A powerful CLI tool for managing Boltic Workflow integrations - create, sync, test, and publish integrations with ease
555 lines (495 loc) • 15.6 kB
JavaScript
import fs from "fs";
import isEmpty from "lodash.isempty";
import path from "path";
import * as componentSchemas from "../templates/component-schemas.js";
const validateOptionObject = (options, fieldName, fileLabel, errors) => {
options.forEach((opt, index) => {
const missingKeys = ["label", "value", "description"].filter(
(key) => !(key in opt)
);
if (missingKeys.length > 0) {
errors.add(
`"${fieldName}" field in "${fileLabel}" has an option at index ${index} missing keys: ${missingKeys.join(", ")}.`
);
}
});
};
const readAndParseJson = (filePath, fileLabel, errors) => {
try {
const content = fs.readFileSync(filePath, "utf8");
if (!content.trim()) {
errors.add(`"${fileLabel}" is empty.`);
return null;
}
return JSON.parse(content);
} catch (e) {
errors.add(`Failed to read or parse "${fileLabel}": ${e.message}`);
return null;
}
};
const findResourceFieldsWithOptions = (schema, fileLabel, errors) => {
const resourceFields = [];
if (Array.isArray(schema?.parameters)) {
schema.parameters.forEach((param) => {
if (
param.name === "resource" &&
Array.isArray(param.meta?.options)
) {
validateOptionObject(
param.meta.options,
"resource",
fileLabel,
errors
);
resourceFields.push(
...param.meta.options.map((opt) => opt.value)
);
}
});
}
return resourceFields;
};
const findOperationFieldsWithOptions = (
schema,
fileLabel,
errors,
expectedResourceName
) => {
const operationFields = [];
if (Array.isArray(schema?.parameters)) {
schema.parameters.forEach((param) => {
if (
param.name === "operation" &&
Array.isArray(param.meta?.options)
) {
validateOptionObject(
param.meta.options,
"operation",
fileLabel,
errors
);
param.meta.options.forEach((opt, index) => {
if (typeof opt.value === "string") {
const parts = opt.value.split(".");
if (parts.length < 2) {
errors.add(
`"operation" field in "${fileLabel}" has an invalid option at index ${index} with value "${opt.value}". Expected format "resource.operation".`
);
} else {
const resource = parts[0];
if (resource !== expectedResourceName) {
errors.add(
`"operation" field in "${fileLabel}" has an inconsistent resource prefix at index ${index}. Found "${resource}" but expected "${expectedResourceName}".`
);
}
operationFields.push(opt.value);
}
}
});
}
});
}
return operationFields;
};
// ─────────────────────────────────────────────────────────────────────────────
// VALIDATE COMPONENT SCHEMAS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Extract all possible keys from a component schema structure recursively
* @param {Object} obj - The object to extract keys from
* @param {string} prefix - Current key prefix for nested objects
* @returns {Set<string>} Set of all allowed keys with dot notation for nested paths
*/
const extractAllowedKeys = (obj, prefix = "") => {
const allowedKeys = new Set();
Object.keys(obj).forEach((key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
allowedKeys.add(fullKey);
if (
typeof obj[key] === "object" &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
const nestedKeys = extractAllowedKeys(obj[key], fullKey);
nestedKeys.forEach((nestedKey) => allowedKeys.add(nestedKey));
}
});
return allowedKeys;
};
/**
* Validate that a schema object doesn't contain any extra keys
* @param {Object} schemaObj - The schema object to validate
* @param {Set<string>} allowedKeys - Set of allowed keys
* @param {string} schemaName - Name of the schema for error messages
* @param {string} displayType - The display type for error messages
* @param {Set} errors - Error collection
* @param {string} prefix - Current key prefix for nested objects
*/
const validateSchemaKeys = (
schemaObj,
allowedKeys,
schemaName,
displayType,
errors,
prefix = "",
filename = ""
) => {
const fileLabel = filename ? ` in "${filename}"` : "";
Object.keys(schemaObj).forEach((key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
// Special handling for config.body - skip validation for select, autocomplete, and multiselect
if (
fullKey === "config.body" &&
(displayType === "select" ||
displayType === "autocomplete" ||
displayType === "multiselect")
) {
// For these display types, config.body can contain any keys, so we skip validation
return;
}
if (!allowedKeys.has(fullKey)) {
errors.add(
`"${schemaName}" has an invalid key "${fullKey}" for displayType "${displayType}"${fileLabel}.`
);
}
if (
typeof schemaObj[key] === "object" &&
schemaObj[key] !== null &&
!Array.isArray(schemaObj[key]) &&
// Skip recursion for config.body of select/autocomplete/multiselect
!(
fullKey === "config.body" &&
(displayType === "select" ||
displayType === "autocomplete" ||
displayType === "multiselect")
)
) {
validateSchemaKeys(
schemaObj[key],
allowedKeys,
schemaName,
displayType,
errors,
fullKey,
filename
);
}
});
};
/**
* Validate a single component schema against its component type definition
* @param {Object} schema - The schema to validate
* @param {string} displayType - The display type to validate against
* @param {Set} errors - Error collection
* @param {string} filename - The filename for error messages
*/
const validateComponentByType = (
schema,
displayType,
errors,
filename = ""
) => {
const fileLabel = filename ? ` in "${filename}"` : "";
// Get the component schema definition for this display type
const componentSchema = componentSchemas[displayType];
if (!componentSchema) {
errors.add(
`"${schema.name}" has an unsupported displayType "${displayType}"${fileLabel}.`
);
return;
}
if (!componentSchema.meta) {
errors.add(
`Component schema for "${displayType}" is missing meta definition${fileLabel}.`
);
return;
}
// Extract allowed keys from the component schema
const allowedKeys = extractAllowedKeys(componentSchema.meta);
// Validate the schema meta object (excluding displayType which we already handled)
const { displayType: currentDisplayType, ...restMeta } = schema.meta;
validateSchemaKeys(
restMeta,
allowedKeys,
schema.name,
currentDisplayType,
errors,
"",
filename
);
};
const validateComponentSchemas = (schemas, errors, filename = "") => {
const fileLabel = filename ? ` in "${filename}"` : "";
schemas.forEach((schema) => {
// Basic required field validation
if (!schema.name) {
errors.add(`Schema is missing a name${fileLabel}.`);
return; // Can't continue without a name
}
if (!schema.meta) {
errors.add(
`"${schema.name}" is missing a meta object${fileLabel}.`
);
return; // Can't continue without meta
}
if (!schema.meta.displayType) {
errors.add(
`"${schema.name}" is missing a displayType${fileLabel}.`
);
return; // Can't continue without displayType
}
// Optional field validation (these are warnings, not blocking)
if (!schema.meta.displayName) {
errors.add(
`"${schema.name}" is missing a displayName${fileLabel}.`
);
}
// Only require placeholder if the component schema defines it
const componentSchema = componentSchemas[schema.meta.displayType];
if (
componentSchema &&
componentSchema.meta &&
"placeholder" in componentSchema.meta &&
!schema.meta.placeholder
) {
errors.add(
`"${schema.name}" is missing a placeholder${fileLabel}.`
);
}
// Only require description if the component schema defines it
if (
componentSchema &&
componentSchema.meta &&
"description" in componentSchema.meta &&
!schema.meta.description
) {
errors.add(
`"${schema.name}" is missing a description${fileLabel}.`
);
}
// 🚨 Validate for duplicate options with same label and value
if (Array.isArray(schema.meta.options)) {
const seen = new Set();
schema.meta.options.forEach((option, index) => {
if (option && typeof option === "object") {
const key = `${option.label}::${option.value}`;
if (seen.has(key)) {
errors.add(
`"${schema.name}" contains duplicate option at index ${index} with label "${option.label}" and value "${option.value}"${fileLabel}.`
);
} else {
seen.add(key);
}
}
});
}
// Validate against the specific component type schema
validateComponentByType(
schema,
schema.meta.displayType,
errors,
filename
);
});
};
// ─────────────────────────────────────────────────────────────────────────────
// INDIVIDUAL VALIDATORS
// ─────────────────────────────────────────────────────────────────────────────
const validateDocumentation = (docPath, errors) => {
if (!fs.existsSync(docPath)) {
errors.add(`"Documentation.mdx" not found in the root directory.`);
}
};
const validateSpec = (specPath, errors) => {
if (!fs.existsSync(specPath)) {
errors.add(`"spec.json" not found in the root directory.`);
return null;
}
const spec = readAndParseJson(specPath, "spec.json", errors);
return spec;
};
const validateWebhook = (webhookPath, spec, errors) => {
const hasTrigger = spec && !isEmpty(spec.trigger_type);
const hasWebhook = fs.existsSync(webhookPath);
if (hasTrigger && !hasWebhook) {
errors.add(
`"webhook.json" not found, but trigger_type is defined in spec.json.`
);
}
if (!hasTrigger && hasWebhook) {
errors.add(
`"webhook.json" exists but trigger_type is not defined in spec.json.`
);
}
// Validate webhook schema parameters if webhook exists
if (hasWebhook) {
const webhookSchema = readAndParseJson(
webhookPath,
"webhook.json",
errors
);
if (webhookSchema && Array.isArray(webhookSchema.parameters)) {
validateComponentSchemas(
webhookSchema.parameters,
errors,
"webhook.json"
);
}
}
};
const validateBaseSchema = (baseSchemaPath, errors) => {
if (!fs.existsSync(baseSchemaPath)) {
errors.add(`"base.json" not found in the "schemas" directory.`);
return null;
}
const baseSchema = readAndParseJson(baseSchemaPath, "base.json", errors);
// Validate base schema parameters
if (baseSchema && Array.isArray(baseSchema.parameters)) {
validateComponentSchemas(baseSchema.parameters, errors, "base.json");
}
return baseSchema;
};
const validateAuthentication = (authPath, errors) => {
// Authentication is optional, so only validate if it exists
if (fs.existsSync(authPath)) {
const authSchema = readAndParseJson(
authPath,
"authentication.json",
errors
);
// Validate authentication schema parameters
if (authSchema && Array.isArray(authSchema.parameters)) {
validateComponentSchemas(
authSchema.parameters,
errors,
"authentication.json"
);
}
// Validate authentication type-specific parameters (like api_key, oauth, etc.)
if (authSchema) {
Object.keys(authSchema).forEach((key) => {
if (
key !== "parameters" &&
typeof authSchema[key] === "object" &&
authSchema[key] !== null
) {
if (Array.isArray(authSchema[key].parameters)) {
validateComponentSchemas(
authSchema[key].parameters,
errors,
"authentication.json"
);
}
}
});
}
}
};
const validateResources = (resourcesDir, resourceFields, errors) => {
if (!fs.existsSync(resourcesDir)) {
errors.add(`"resources" directory not found in "schemas".`);
return;
}
const resourceFiles = fs
.readdirSync(resourcesDir)
.filter((f) => f.endsWith(".json"))
.map((f) => path.basename(f, ".json"));
// Check for missing resource files
resourceFields.forEach((field) => {
if (!resourceFiles.includes(field)) {
errors.add(`Resource file: "${field}.json" is missing.`);
}
});
// Validate each resource file
resourceFiles.forEach((resourceFile) => {
const filePath = path.join(resourcesDir, `${resourceFile}.json`);
const schema = readAndParseJson(
filePath,
`${resourceFile}.json`,
errors
);
if (!schema) return;
// Validate resource file parameters
if (Array.isArray(schema.parameters)) {
validateComponentSchemas(
schema.parameters,
errors,
`${resourceFile}.json`
);
}
const operationFields = findOperationFieldsWithOptions(
schema,
`${resourceFile}.json`,
errors,
resourceFile
);
operationFields.forEach((operation) => {
const operationMethod = operation.split(".")[1];
if (!operationMethod) {
errors.add(
`Invalid format for operation "${operation}" in "${resourceFile}.json". Use "resource.operation".`
);
return;
}
const methodDef = schema[operationMethod];
if (!methodDef) {
errors.add(
`Operation "${operationMethod}" missing in "${resourceFile}.json".`
);
return;
}
if (!methodDef.parameters) {
errors.add(
`Operation "${operationMethod}" in "${resourceFile}.json" is missing parameters.`
);
} else {
// Validate operation parameters using component schemas
if (Array.isArray(methodDef.parameters)) {
validateComponentSchemas(
methodDef.parameters,
errors,
`${resourceFile}.json`
);
}
}
if (!methodDef.definition) {
errors.add(
`Operation "${operationMethod}" in "${resourceFile}.json" is missing definition.`
);
}
});
});
};
// ─────────────────────────────────────────────────────────────────────────────
// MAIN FUNCTION
// ─────────────────────────────────────────────────────────────────────────────
export const validateIntegrationSchemas = (currentDir) => {
const errors = new Set();
// Define file paths
const paths = {
base: path.join(currentDir, "schemas", "base.json"),
resources: path.join(currentDir, "schemas", "resources"),
spec: path.join(currentDir, "spec.json"),
webhook: path.join(currentDir, "schemas", "webhook.json"),
authentication: path.join(currentDir, "schemas", "authentication.json"),
documentation: path.join(currentDir, "Documentation.mdx"),
};
// Step-by-step validation
validateDocumentation(paths.documentation, errors);
const spec = validateSpec(paths.spec, errors);
validateWebhook(paths.webhook, spec, errors);
validateAuthentication(paths.authentication, errors);
const baseSchema = validateBaseSchema(paths.base, errors);
const resourceFields = baseSchema
? findResourceFieldsWithOptions(baseSchema, "base.json", errors)
: [];
validateResources(paths.resources, resourceFields, errors);
// Return results
if (errors.size > 0) {
return {
success: false,
errors: Array.from(errors),
};
}
return { success: true };
};