UNPKG

@stackql/provider-utils

Version:

Utilities for building StackQL providers from OpenAPI specifications.

569 lines (483 loc) 17.9 kB
// @stackql/provider-utils/src/providerdev/split.js import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; import logger from '../logger.js'; import { camelToSnake, createDestDir } from '../utils.js'; // Constants const OPERATIONS = ["get", "post", "put", "delete", "patch", "options", "head", "trace"]; const COMPONENTS_CHILDREN = ["schemas", "responses", "parameters", "examples", "requestBodies", "headers", "securitySchemes", "links", "callbacks"]; /** * Check if operation should be excluded * @param {string[]} exclude - List of exclusion criteria * @param {Object} opItem - Operation item from OpenAPI doc * @param {string} svcDiscriminator - Service discriminator * @returns {boolean} - Whether operation should be excluded */ function isOperationExcluded(exclude, opItem) { if (!exclude || exclude.length === 0) { return false; } // Example: exclude based on tags or other criteria if (opItem.tags && opItem.tags.some(tag => exclude.includes(tag))) { return true; } return false; } /** * Determine service name and description using discriminator * @param {string} providerName - Provider name * @param {Object} opItem - Operation item * @param {string} pathKey - Path key * @param {string} svcDiscriminator - Service discriminator * @param {Object[]} allTags - All tags from API doc * @param {boolean} debug - Debug flag * @param {Object} svcNameOverrides - Service name overrides * @returns {[string, string]} - [service name, service description] */ function retServiceNameAndDesc(providerName, opItem, pathKey, svcDiscriminator, allTags, debug, svcNameOverrides) { let service = "default"; let serviceDesc = `${providerName} API`; // Use tags if discriminator is "tag" if (svcDiscriminator === "tag" && opItem.tags && opItem.tags.length > 0) { service = opItem.tags[0].toLowerCase().replace(/-/g, '_').replace(/ /g, '_'); // Find description in all_tags for (const tag of allTags) { if (tag.name === service) { serviceDesc = tag.description || serviceDesc; break; } } } // Use first significant path segment if discriminator is "path" else if (svcDiscriminator === "path") { const pathParts = pathKey.replace(/^\//, '').split('/'); if (pathParts.length > 0) { // Find the first path segment that is not 'api', 'v{number}', or '{apiVersion}' for (const part of pathParts) { const lowerPart = part.toLowerCase(); // Skip if it's 'api', matches version pattern 'v1', 'v2', etc., or is '{apiVersion}' if (lowerPart === 'api' || /^v\d+$/.test(lowerPart) || /^\{.*version\}$/i.test(part)) { continue; } service = lowerPart.replace(/-/g, '_').replace(/ /g, '_').replace(/\./g, '_'); break; } serviceDesc = `${providerName} ${service} API`; } } // Check if service should be skipped if (service === "skip") { return ["skip", ""]; } // Apply service name overrides if present if (svcNameOverrides && svcNameOverrides[service]) { const newName = svcNameOverrides[service]; if (debug) { logger.debug(`Overriding service name: ${service} -> ${newName}`); } // Update service description for path-based services if (svcDiscriminator === "path") { serviceDesc = `${providerName} ${newName} API`; } service = newName; } return [service, serviceDesc]; } /** * Initialize service map * @param {Object} services - Services map * @param {string[]} componentsChildren - Components children * @param {string} service - Service name * @param {string} serviceDesc - Service description * @param {Object} apiDoc - API doc * @returns {Object} - Updated services map */ function initService(services, componentsChildren, service, serviceDesc, apiDoc) { services[service] = { openapi: apiDoc.openapi || "3.0.0", info: { title: `${service} API`, description: serviceDesc, version: apiDoc.info?.version || "1.0.0" }, paths: {}, components: {} }; // Initialize components sections services[service].components = {}; for (const child of componentsChildren) { services[service].components[child] = {}; } // Copy servers if present if (apiDoc.servers) { services[service].servers = apiDoc.servers; } return services; } /** * Extract all $ref values from an object recursively * @param {any} obj - Object to extract refs from * @returns {Set<string>} - Set of refs */ function getAllRefs(obj) { const refs = new Set(); if (typeof obj === 'object' && obj !== null) { if (Array.isArray(obj)) { for (const item of obj) { for (const ref of getAllRefs(item)) { refs.add(ref); } } } else { for (const [key, value] of Object.entries(obj)) { if (key === "$ref" && typeof value === 'string') { refs.add(value); } else if (typeof value === 'object' && value !== null) { for (const ref of getAllRefs(value)) { refs.add(ref); } } } } } return refs; } /** * Extract all $ref values from path-level parameters and non-operation elements * @param {Object} pathItem - Path item from OpenAPI doc * @returns {Set<string>} - Set of refs */ function getPathLevelRefs(pathItem) { const refs = new Set(); // Check for path-level parameters if (pathItem.parameters) { for (const param of pathItem.parameters) { if (param.$ref) { refs.add(param.$ref); } else if (typeof param === 'object') { // Extract refs from schema if present if (param.schema && param.schema.$ref) { refs.add(param.schema.$ref); } // Also get all nested refs in the parameter object for (const ref of getAllRefs(param)) { refs.add(ref); } } } } // Also check other non-operation properties for refs for (const key in pathItem) { if (!OPERATIONS.includes(key)) { for (const ref of getAllRefs(pathItem[key])) { refs.add(ref); } } } return refs; } /** * Add missing type: object to schema objects * @param {any} obj - Object to process * @returns {any} - Processed object */ function addMissingObjectTypes(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(item => addMissingObjectTypes(item)); } // If it has properties but no type, add type: object if (obj.properties && !obj.type) { obj.type = "object"; } // Process nested objects for (const [key, value] of Object.entries(obj)) { if (typeof value === 'object' && value !== null) { obj[key] = addMissingObjectTypes(value); } } return obj; } /** * Recursively resolve and add all references to service components * @param {Set<string>} refs - Set of references to resolve * @param {Object} service - Service object to add components to * @param {Object} components - Source components from API doc * @param {boolean} debug - Debug flag * @param {Set<string>} processed - Set of already processed refs (to prevent infinite recursion) */ function resolveReferences(refs, service, components, debug, processed = new Set()) { let newRefs = new Set(); for (const ref of refs) { // Skip if already processed if (processed.has(ref)) { continue; } processed.add(ref); const parts = ref.split('/'); // Only process refs that point to components if (parts.length >= 4 && parts[1] === "components") { const componentType = parts[2]; const componentName = parts[3]; // Check if component type exists in service if (!service.components[componentType]) { service.components[componentType] = {}; } // Skip if component already added if (service.components[componentType][componentName]) { continue; } // Add component if it exists in source document if (components[componentType] && components[componentType][componentName]) { service.components[componentType][componentName] = JSON.parse(JSON.stringify(components[componentType][componentName])); if (debug) { logger.debug(`Added component ${componentType}/${componentName}`); } // Find all refs in the newly added component const componentRefs = getAllRefs(service.components[componentType][componentName]); for (const cRef of componentRefs) { if (!processed.has(cRef)) { newRefs.add(cRef); } } } else if (debug) { logger.debug(`WARNING: Could not find component ${componentType}/${componentName}`); } } } // If we found new refs, resolve them too (recursively) if (newRefs.size > 0) { if (debug) { logger.debug(`Found ${newRefs.size} additional refs to resolve`); } resolveReferences(newRefs, service, components, debug, processed); } } /** * Split OpenAPI document into service-specific files * @param {Object} options - Options for splitting * @returns {Promise<boolean>} - Success status */ export async function split(options) { const { apiDoc, providerName, outputDir, svcDiscriminator = "tag", exclude = null, overwrite = true, verbose = false, svcNameOverrides = {} // Add this new parameter with default empty object } = options; // Setup logging based on verbosity if (verbose) { logger.level = 'debug'; } logger.info(`🔄 Splitting OpenAPI doc for ${providerName}`); logger.info(`API Doc: ${apiDoc}`); logger.info(`Output: ${outputDir}`); logger.info(`Service Discriminator: ${svcDiscriminator}`); if (Object.keys(svcNameOverrides).length > 0) { logger.info(`Using ${Object.keys(svcNameOverrides).length} service name overrides`); if (verbose) { logger.debug(`Service name overrides: ${JSON.stringify(svcNameOverrides, null, 2)}`); } } // Process exclude list const excludeList = exclude ? exclude.split(",") : []; // Read the OpenAPI document let apiDocObj; try { const apiDocContent = fs.readFileSync(apiDoc, 'utf8'); apiDocObj = yaml.load(apiDocContent); } catch (e) { logger.error(`❌ Failed to parse ${apiDoc}: ${e.message}`); return false; } // Create destination directory if (!createDestDir(outputDir, overwrite)) { return false; } // Get API paths const apiPaths = apiDocObj.paths || {}; logger.info(`🔑 Iterating over ${Object.keys(apiPaths).length} paths`); const services = {}; let opCounter = 0; // First pass: identify all services and collect operations for (const [pathKey, pathItem] of Object.entries(apiPaths)) { if (verbose) { logger.debug(`Processing path ${pathKey}`); } if (!pathItem) { continue; } // Collect all services that use this path const pathServices = new Set(); // Process each operation (HTTP verb) for (const [verbKey, opItem] of Object.entries(pathItem)) { if (!OPERATIONS.includes(verbKey) || !opItem) { continue; } opCounter += 1; if (opCounter % 100 === 0) { logger.info(`⚙️ Operations processed: ${opCounter}`); } if (verbose) { logger.debug(`Processing operation ${pathKey}:${verbKey}`); } // Skip excluded operations if (isOperationExcluded(excludeList, opItem)) { continue; } const [service, serviceDesc] = retServiceNameAndDesc( providerName, opItem, pathKey, svcDiscriminator, apiDocObj.tags || [], verbose, svcNameOverrides ); // Skip if service is marked to skip if (service === 'skip') { logger.warn(`⭐️ Skipping service: ${service}`); continue; } pathServices.add(service); if (verbose) { logger.debug(`Service name: ${service}`); logger.debug(`Service desc: ${serviceDesc}`); } // Initialize service if first occurrence if (!services[service]) { if (verbose) { logger.debug(`First occurrence of ${service}`); } initService(services, COMPONENTS_CHILDREN, service, serviceDesc, apiDocObj); } // Add operation to service if (!services[service].paths[pathKey]) { if (verbose) { logger.debug(`First occurrence of ${pathKey}`); } services[service].paths[pathKey] = {}; } services[service].paths[pathKey][verbKey] = opItem; // Special case for GitHub if (providerName === 'github' && opItem['x-github'] && opItem['x-github'].subcategory) { services[service].paths[pathKey][verbKey]['x-stackQL-resource'] = camelToSnake( opItem['x-github'].subcategory ); } } // For each service that uses this path, add path-level parameters for (const service of pathServices) { // Copy non-operation elements (like parameters) to service paths for (const key in pathItem) { if (!OPERATIONS.includes(key)) { if (!services[service].paths[pathKey]) { services[service].paths[pathKey] = {}; } services[service].paths[pathKey][key] = pathItem[key]; } } } } // Second pass: collect all references for each service for (const service in services) { if (verbose) { logger.debug(`Collecting references for service ${service}`); } // Get all refs from all operations in this service const allRefs = new Set(); // Collect refs from paths for (const pathKey in services[service].paths) { const pathItem = services[service].paths[pathKey]; // Get refs from path-level parameters const pathRefs = getPathLevelRefs(pathItem); for (const ref of pathRefs) { allRefs.add(ref); } // Get refs from operations for (const verbKey in pathItem) { if (OPERATIONS.includes(verbKey)) { const opRefs = getAllRefs(pathItem[verbKey]); for (const ref of opRefs) { allRefs.add(ref); } } } } if (verbose) { logger.debug(`Found ${allRefs.size} total refs for service ${service}`); } // Resolve all references recursively resolveReferences(allRefs, services[service], apiDocObj.components || {}, verbose); } // Update path param names (replace hyphens with underscores) for (const service in services) { if (services[service].paths) { const pathKeys = Object.keys(services[service].paths); for (const pathKey of pathKeys) { if (verbose) { logger.debug(`Renaming path params in ${service} for path ${pathKey}`); } // Replace hyphens with underscores in path parameters const updatedPathKey = pathKey.replace(/(?<=\{)([^}]+?)-([^}]+?)(?=\})/g, '$1_$2'); if (updatedPathKey !== pathKey) { if (verbose) { logger.debug(`Updated path key from ${pathKey} to ${updatedPathKey}`); } services[service].paths[updatedPathKey] = services[service].paths[pathKey]; delete services[service].paths[pathKey]; // Also update parameter names in operations for (const verbKey in services[service].paths[updatedPathKey]) { const operation = services[service].paths[updatedPathKey][verbKey]; if (operation.parameters) { for (const param of operation.parameters) { if (param.in === 'path' && param.name.includes('-')) { const originalName = param.name; param.name = param.name.replace(/-/g, '_'); if (verbose) { logger.debug(`Updated parameter name from ${originalName} to ${param.name} in path ${updatedPathKey}`); } } } } } } } } } // Fix missing type: object for (const service in services) { if (verbose) { logger.debug(`Updating paths for ${service}`); } services[service].paths = addMissingObjectTypes(services[service].paths); services[service].components = addMissingObjectTypes(services[service].components); } // Cleanup empty components for (const service in services) { for (const componentType in services[service].components) { if (Object.keys(services[service].components[componentType]).length === 0) { delete services[service].components[componentType]; } } } // Write out service docs for (const service in services) { logger.info(`✅ Writing out OpenAPI doc for [${service}]`); const outputFile = path.join(outputDir, `${service}.yaml`); fs.writeFileSync(outputFile, yaml.dump(services[service], { noRefs: true, sortKeys: false })); } logger.info(`🎉 Successfully split OpenAPI doc into ${Object.keys(services).length} services`); return true; }