UNPKG

apiveritas

Version:

Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.

481 lines (412 loc) 16.5 kB
/** * @file cli.ts * @author Mario Galea * @description * Entry point for the ApiVeritas CLI tool. * Provides a set of commands for API contract testing, comparison, reporting, and configuration. * */ import { Command } from 'commander'; import { ConfigLoader } from '../core/config/ConfigLoader'; import { ApiCaller } from '../core/services/ApiCaller'; import { PayloadComparer } from '../PayloadComparer'; import { Logger } from '../core/utils/Logger'; import path from 'path'; import { PackageInfo } from '../core/utils/PackageInfo'; import { TestSuiteLoader } from '../core/services/TestSuiteLoader'; import chalk from 'chalk'; import { MockServer } from '../core/services/MockServer'; import { ExitHandler, ExitCode } from '../core/services/ExitHandler'; import { IApiRequest } from '../interfaces/IApiRequest'; import { InitCommand } from '../commands/InitCommand'; const program = new Command(); const version = PackageInfo.getInstance().getVersion(); const logger = new Logger(); const exitHandler = new ExitHandler(logger); const configLoader = new ConfigLoader(); program .name('apiveritas') .description('\nA lightweight CLI tool for API contract testing') .version(version) .showHelpAfterError('(add --help for additional info)'); let config: any; try { config = configLoader.loadConfig(); } catch (err) { exitHandler.configError('❌ Failed to load configuration. Ensure config.json exists and is valid.'); } program .command('init') .description('Initialize ApiVeritas folder structure with template files') .option('--force', 'Overwrite existing files if present') .action(async (options) => { try { const initCmd = new InitCommand(logger); await initCmd.run(options.force === true); exitHandler.success(); } catch (error) { logger.error(`Init command failed: ${error instanceof Error ? error.message : error}`); process.exit(1); } }); /** * Run API tests by executing requests defined in a test suite. * Saves responses to timestamped payload folders. * Supports mock server mode. * * @example * // Run tests with a given test suite JSON file * $ apiveritas test --tests bookings.json * * @example * // Run tests in mock server mode (mock.json is forced) * $ apiveritas test --tests mock.json */ program .command('test') .description('Run all API requests and save responses') .requiredOption('--tests <file>', 'Specify the test suite JSON file') .action(async (options, command) => { let testFile = options.tests; if (config.enableMockServer) { logger.info('\n Mock server mode enabled.'); if (options.tests && options.tests !== 'mock.json') { logger.warn(`Ignoring passed test file "${options.tests}" — using "mock.json" due to enableMockServer=true`); } testFile = 'mock.json'; } else if (!testFile) { exitHandler.invalidArgs('❌ Missing required option: --tests <file>'); } let mockServer: MockServer | undefined; if (config.enableMockServer) { try { mockServer = new MockServer(); await mockServer.start(); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk.red(`❌ Failed to start mock server: ${message}`)); exitHandler.mockServerError(); } } let requests: IApiRequest[]; try { requests = TestSuiteLoader.loadSuite(testFile, config); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk.red(`❌ Failed to load test suite: ${testFile}`)); logger.error(chalk.red(`-> ${message}`)); if (mockServer) await mockServer.stop(); exitHandler.testSuiteLoadingError(); return; } const caller = new ApiCaller(requests, logger, config.baseUrl); try { await caller.callAll(); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk.red(`❌ Error during API execution: ${message}`)); if (mockServer) await mockServer.stop(); exitHandler.apiCallFailure(); } if (mockServer) await mockServer.stop(); exitHandler.success(); }); /** * List all JSON test suite files in the `tests/` directory. * * @example * $ apiveritas list-tests */ program .command('list-tests') .description('List all available JSON test files in the tests/ folder') .action(() => { try { const testFiles = TestSuiteLoader.listAvailableSuites(); if (testFiles.length === 0) { logger.info(chalk.yellow('⚠️ No test files found in the tests/ directory.\n')); exitHandler.testSuiteLoadingError(); } logger.info(chalk.green('✅ Available test suites:\n')); testFiles.forEach((file) => { logger.info(` - ${chalk.white(file)}`); }); console.log(); exitHandler.success(); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk.red(`❌ Failed to list test suites.`)); logger.error(chalk.red(`-> ${message}`)); exitHandler.generalError(); } }); /** * Show the current path where payloads are stored. * * @example * $ apiveritas payloads-path */ program .command('payloads-path') .description('Show where the payloads are stored') .action(() => { try { const payloadsPath = config.payloadsPath; if (!payloadsPath) { exitHandler.configError('❌ Payloads path is not defined in the configuration.'); } logger.info(chalk.cyan('\n Payloads storage: ') + chalk.white(payloadsPath) + '\n'); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while retrieving payloads path.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Show the current path where HTML reports are stored. * * @example * $ apiveritas reports-path */ program .command('reports-path') .description('Show where HTML reports are stored') .action(() => { try { const reportsPath = config.reportsPath; if (!reportsPath) { exitHandler.configError('❌ Reports path is not defined in the configuration.'); } logger.info(chalk.cyan('\n Reports storage: ') + chalk.white(reportsPath) + '\n'); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while retrieving reports path.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Display the current loaded configuration from `config.json`. * * @example * $ apiveritas config */ program .command('config') .description('Show current configuration loaded from config.json') .action(() => { try { // Create ConfigLoader instance (default folder 'apiveritas' in cwd) const configLoader = new ConfigLoader(); const config = configLoader.loadConfig(); if (!config || Object.keys(config).length === 0) { exitHandler.configError('❌ Configuration is empty or not loaded properly.'); } const configPath = path.join(process.cwd(), 'apiveritas', 'config.json'); logger.info(chalk.white('\n Configuration file: ') + chalk.white.bold(configPath) + '\n'); const maxKeyLength = Math.max(...Object.keys(config).map(key => key.length)); Object.entries(config).forEach(([key, value]) => { const paddedKey = key.padEnd(maxKeyLength, ' '); logger.info(`${chalk.white(paddedKey)} : ${chalk.green.bold(String(value))}`); }); console.log(); // For spacing exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while displaying configuration.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Update configuration values interactively. * * @example * // Enable strict schema validation and update base URL * $ apiveritas set-config --strictSchema true --baseUrl https://api.example.com * * @example * // Disable mock server mode * $ apiveritas set-config --enableMockServer false */ program .command('set-config') .description('Update one or more config values') .option('--strictSchema <boolean>', 'Enable or disable strict schema validation (true/false)') .option('--strictValues <boolean>', 'Enable or disable strict values validation (true/false)') .option('--tolerateEmptyResponses <boolean>', 'Enable or disable tolerance for empty responses (true/false)') .option('--payloadsPath <path>', 'Set a new path for payload storage') .option('--reportsPath <path>', 'Set a new path for reports') .option('--baseUrl <url>', 'Set the base URL for API calls') .option('--enableMockServer <boolean>', 'Run the Application in Mock Server Mode (tests/mock.json). All responses are sent to http://mockserver:3000') .action((options) => { try { const changes: Record<string, any> = {}; if (options.strictSchema !== undefined) { changes.strictSchema = options.strictSchema === 'true'; } if (options.strictValues !== undefined) { changes.strictValues = options.strictValues === 'true'; } if (options.payloadsPath !== undefined) { changes.payloadsPath = options.payloadsPath; } if (options.reportsPath !== undefined) { changes.reportsPath = options.reportsPath; } if (options.tolerateEmptyResponses !== undefined) { changes.tolerateEmptyResponses = options.tolerateEmptyResponses === 'true'; } if (options.baseUrl !== undefined) { changes.baseUrl = options.baseUrl; } if (options.enableMockServer !== undefined) { changes.enableMockServer = options.enableMockServer === 'true'; } if (Object.keys(changes).length === 0) { exitHandler.invalidArgs('⚠️ No configuration changes provided. Use set-config --help for available options.'); } const configLoader = new ConfigLoader(); configLoader.updateConfig(changes); logger.info(chalk.green('\n✅ Configuration updated successfully!\n')); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error while updating configuration.'; exitHandler.configError(`❌ ${message}`); } }); /** * Compare two latest payload folders for a given test suite. * Reports schema/value differences. * * @example * $ apiveritas compare --testSuite bookings */ program .command('compare') .description('Compare the two most recent payload folders and show test results') .requiredOption('--testSuite <name>', 'Name of the test suite folder to compare') .action((options) => { try { const testSuite = options.testSuite; if (!testSuite) { exitHandler.invalidArgs('❌ Missing required option: --testSuite <name>'); } const comparer = new PayloadComparer(testSuite, config, logger); const folders = comparer.getLatestTwoPayloadFolders(testSuite); if (!folders) { logger.error(chalk.red('❌ Could not find two payload folders to compare.\n')); logger.info(chalk.yellow('-> Make sure you have at least two payload runs saved in your payloads directory.\n')); exitHandler.comparisonFailure('Insufficient payload folders for comparison.'); return; } const [oldFolder, newFolder] = folders; const isDifferent = comparer.compareFolders(oldFolder, newFolder, testSuite); if (isDifferent) { exitHandler.comparisonFailure('❌ Differences detected in payload comparison.'); } else { exitHandler.success('✅ Payload comparison completed with no differences.'); } } catch (err) { const message = err instanceof Error ? err.message : 'Unknown comparison error occurred.'; exitHandler.comparisonFailure(`❌ ${message}`); } }); /** * Run a full workflow: * - Execute API calls from a test suite * - Save responses * - Compare latest payload folders for differences * * @example * $ apiveritas run --tests bookings.json --testSuite bookings */ program .command('run') .description('Run tests, compare payloads, and report results') .requiredOption('--tests <file>', 'Specify the test suite JSON file') .requiredOption('--testSuite <name>', 'Name of the test suite folder to compare') .action(async (options, command) => { let testFile = options.tests; const testSuite = options.testSuite; if (config.enableMockServer) { logger.info('\n Mock server mode enabled.'); if (options.tests && options.tests !== 'mock.json') { logger.warn(`Ignoring passed test file "${options.tests}" — using "mock.json" due to enableMockServer=true`); } testFile = 'mock.json'; } if (!testFile) { logger.error(chalk.red('❌ Missing required option: --tests <file>\n')); command.help({ error: true }); exitHandler.invalidArgs('Missing required --tests option'); } if (!testSuite) { logger.error(chalk.red('❌ Missing required option: --testSuite <name>\n')); command.help({ error: true }); exitHandler.invalidArgs('Missing required --testSuite option'); } logger.info(chalk.cyan('\nRunning full test and comparison...\n')); let mockServer: MockServer | undefined; if (config.enableMockServer) { mockServer = new MockServer(); await mockServer.start(); } let requests: IApiRequest[]; // force definite assignment later try { const loadedRequests = TestSuiteLoader.loadSuite(testFile, config); if (!loadedRequests) { throw new Error(`Test suite ${testFile} returned undefined or null`); } requests = loadedRequests; } catch (err) { logger.error(chalk.red(`❌ Failed to load test suite: ${testFile}\n`)); logger.error(chalk.red('-> Make sure the file exists and contains valid JSON.\n')); if (mockServer) await mockServer.stop(); exitHandler.testSuiteLoadingError(`Failed to load test suite file: ${testFile}`); return; // just for TS to know exitHandler probably exits } const caller = new ApiCaller(requests, logger, config.baseUrl); await caller.callAll(); const comparer = new PayloadComparer(testSuite, config, logger); const folders = comparer.getLatestTwoPayloadFolders(); if (!folders) { logger.error(chalk.red('❌ Could not find two payload folders to compare.\n')); logger.info(chalk.yellow('-> Make sure you have at least two payload runs saved in your payloads directory.\n')); if (mockServer) await mockServer.stop(); exitHandler.comparisonFailure('Insufficient payload folders for comparison.'); return; // TS safety } // At this point, folders is guaranteed non-null with two strings const [oldFolder, newFolder] = folders; comparer.compareFolders(oldFolder, newFolder, testSuite); if (mockServer) await mockServer.stop(); exitHandler.success('Run, compare, and report completed successfully.'); }); /** * A fun easter egg inspired by Pulp Fiction. * * @example * $ apiveritas notest */ program .command('notest') .description('A little surprise inspired by Pulp Fiction') .action(() => { console.log( chalk.bold.yellow( `\n"Say 'what' again. I dare you, I double dare you..."`, ), ); console.log(chalk.gray('— Jules Winnfield, Pulp Fiction\n')); console.log( chalk.cyanBright( 'Just remember, contractual integrity matters. Always.\n', ), ); }); /** * Handler for unknown commands. */ program.on('command:*', (operands) => { logger.error(chalk.red(`❌ Unknown command: ${operands.join(' ')}\n`)); program.help({ error: true }); }); program.parse(process.argv);