apiveritas
Version:
Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.
336 lines (286 loc) • 12.3 kB
text/typescript
/**
* @file PayloadComparer.ts
* @author Mario Galea
* @description
* The PayloadComparer class compares JSON payload files between two snapshot folders
* representing different states of API responses. It supports schema generation,
* structural comparison, JSON Schema validation, and reports differences.
* The comparison results are logged and an HTML report is generated.
*/
import fs from 'fs';
import path from 'path';
import generateSchema from 'generate-schema';
import { HtmlReporter } from './core/reporters/HtmlReporter';
import { BasicComparator } from './core/comparators/BasicComparator';
import { SchemaValidator } from './core/schema/SchemaValidator';
import { SchemaStrictifier } from './core/schema/SchemaStrictifier';
import { IComparerOptions } from './interfaces/IComparerOptions';
import { IFileComparisonResult } from './interfaces/IFileComparisonResult';
import { Logger } from './core/utils/Logger';
import chalk from 'chalk';
import { ConfigLoader } from './core/config/ConfigLoader';
/**
* Compares JSON payloads between two folders and reports differences.
* Supports strict schema validation, value strictness, empty response tolerance,
* and mock server overrides via config.
*/
export class PayloadComparer {
private payloadsDir: string;
private comparator: BasicComparator;
private options: IComparerOptions;
private logger: Logger;
private config: any;
private configLoader: ConfigLoader;
/**
* Creates a new PayloadComparer instance.
* @param {IComparerOptions} [options] - Options to control comparison behavior.
* @param {boolean} [options.strictSchema=true] - Enforce strict JSON schema validation.
* @param {boolean} [options.strictValues=true] - Enforce strict value matching.
* @param {boolean} [options.tolerateEmptyResponses=false] - Allow empty or missing payloads without failing.
* @param {Logger} [logger] - Optional logger instance to capture logs.
*/
constructor(
testSuiteName: string,
options: IComparerOptions = {
strictSchema: true,
strictValues: true,
tolerateEmptyResponses: false,
},
logger: Logger = new Logger()
) {
this.configLoader = new ConfigLoader();
this.config = this.configLoader.loadConfig();
// Use custom or default payloads path
const basePayloadsDir = this.config.payloadsPath || path.join(process.cwd(), 'apiveritas', 'payloads');
// Include test suite in the path
this.payloadsDir = path.join(basePayloadsDir, testSuiteName);
this.options = options;
this.comparator = new BasicComparator(this.options.strictValues);
this.logger = logger;
logger.info(chalk.white.bold.underline('Payload Comparison:\n'));
logger.info(`${chalk.white('Test Suite Folder:')} ${chalk.white(testSuiteName)}`);
logger.info(`${chalk.white('Payload Directory:')} ${chalk.white(this.payloadsDir)}\n`);
}
/**
* Retrieves the two most recent payload snapshot folders for comparison.
* Folders are sorted by name descending (newest assumed last).
* @returns {[string, string] | null} - Tuple of [previousFolder, latestFolder] or null if insufficient data.
getLatestTwoPayloadFolders(): [string, string] | null {
if (!fs.existsSync(this.payloadsDir)) {
this.logger.warn('No payloads directory found.');
return null;
}
/**
* Returns the names of the latest two timestamp folders which contain the given testSuite folder.
* If no testSuite specified, returns the latest two timestamp folders.
*/
getLatestTwoPayloadFolders(testSuite?: string): [string, string] | null {
if (!fs.existsSync(this.payloadsDir)) {
this.logger.warn('No payloads directory found.');
return null;
}
// Read all timestamp folders sorted newest first
const timestampFolders = fs
.readdirSync(this.payloadsDir)
.filter((file) => fs.statSync(path.join(this.payloadsDir, file)).isDirectory())
.sort()
.reverse();
if (timestampFolders.length < 2) {
this.logger.warn('Not enough payload folders to compare.');
return null;
}
// No testSuite specified, just return latest two timestamp folders
return [timestampFolders[0], timestampFolders[1]];
}
/**
* Compares JSON payload files in two folders (optionally within a test suite subfolder).
* Generates detailed logs, counts matched and differing files, validates schema,
* and generates an HTML report.
* @param {string} oldFolder - The folder name of the baseline payload snapshot.
* @param {string} newFolder - The folder name of the new payload snapshot.
* @param {string} [testSuite] - Optional test suite folder name within each snapshot folder.
* If mock server is enabled in config, this will be overridden to 'mock'.
* @returns {void}
*/
compareFolders( newFolder: string, oldFolder: string, testSuite?: string): boolean {
if (this.config.enableMockServer) {
testSuite = 'mock';
}
const startTime = Date.now();
this.logger.info(`${chalk.white('Present Prod :')} ${chalk.white(oldFolder)}`);
this.logger.info(`${chalk.white('Future Prod :')} ${chalk.white(newFolder)}`);
if (testSuite) {
this.logger.info(`${chalk.white('Test Suite :')} ${chalk.white(testSuite)}\n`);
} else {
this.logger.info(chalk.yellowBright('! No test suite specified. Comparing top-level payload files.\n'));
}
if (!testSuite) {
this.logger.warn('No test suite specified. Cannot compare folders.');
return false;
}
const oldPath = path.join(this.payloadsDir, oldFolder);
const newPath = path.join(this.payloadsDir, newFolder);
if (!fs.existsSync(oldPath)) {
this.logger.warn(`Old folder path does not exist: ${oldPath}`);
return false;
}
if (!fs.existsSync(newPath)) {
this.logger.warn(`New folder path does not exist: ${newPath}`);
return false;
}
const files = fs.readdirSync(oldPath).filter((file) => file.endsWith('.json'));
if (files.length === 0) {
this.logger.warn('No payload files found in the old folder to compare.');
console.log();
this.logger.info(chalk.bgYellow.black('WARNING - NO FILES TO COMPARE'));
return false;
}
let isDifferent = false;
let matchedCount = 0;
let diffCount = 0;
const results: IFileComparisonResult[] = [];
const validator = new SchemaValidator();
files.forEach((file) => {
const oldFilePath = path.join(oldPath, file);
const newFilePath = path.join(newPath, file);
if (!fs.existsSync(newFilePath)) {
isDifferent = true;
diffCount++;
results.push({
fileName: file,
matched: false,
differences: ['X File missing in new version'],
oldContent: this.loadAndParseJson(oldFilePath),
});
return;
}
const oldData = this.loadAndParseJson(oldFilePath);
const newData = this.loadAndParseJson(newFilePath);
const oldIsEmpty = oldData === null;
const newIsEmpty = newData === null;
if (this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) {
matchedCount++;
const msg = `! One or both payloads in ${file} are empty or missing. Ignored due to tolerateEmptyResponses=true.`;
results.push({
fileName: file,
matched: true,
differences: [msg],
oldContent: oldData,
newContent: newData,
});
return;
}
if (!this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) {
isDifferent = true;
diffCount++;
const diffs = [
...(oldIsEmpty ? ['X Old data is not a valid object. Got empty or missing.'] : []),
...(newIsEmpty ? ['X New data is not a valid object. Got empty or missing.'] : []),
];
results.push({
fileName: file,
matched: false,
differences: diffs,
oldContent: oldData,
newContent: newData,
});
return;
}
const structuralDiffs = this.comparator.compare(oldData, newData);
const ignoredDiffs = structuralDiffs.filter((d: string) => d.startsWith('IGNORED::'));
const realDiffs = structuralDiffs.filter((d: string) => !d.startsWith('IGNORED::'));
const schema = generateSchema.json('Response', oldData);
if (this.options.strictSchema) {
SchemaStrictifier.enforceNoAdditionalProperties(schema);
}
schema.$schema = 'http://json-schema.org/draft-07/schema#';
const isValid = validator.validate(schema, newData);
const schemaErrors = isValid ? [] : validator.getErrors();
const actualDiffs = [...realDiffs, ...schemaErrors];
const matched = actualDiffs.length === 0;
if (matched) {
matchedCount++;
} else {
isDifferent = true;
diffCount++;
}
results.push({
fileName: file,
matched,
differences: [...actualDiffs, ...ignoredDiffs],
oldContent: oldData,
newContent: newData,
});
});
results.forEach((result) => {
if (result.matched) {
this.logger.info(chalk.green(`✓ ${result.fileName} matches`));
result.differences?.forEach((diff) => {
if (diff.startsWith('IGNORED::')) {
this.logger.warn(diff.replace(/^IGNORED::\s*/, ''));
}
});
} else {
this.logger.error(`X Differences found in ${result.fileName}`);
result.differences?.forEach((diff) => this.logger.error(diff));
}
});
this.configLoader = new ConfigLoader(); // create ConfigLoader instance
this.config = this.configLoader.loadConfig();
const maxKeyLength = Math.max(...Object.keys(this.config).map(key => key.length));
console.log();
if (!isDifferent) {
this.logger.info(chalk.bgGreen.bold('SUCCESS - ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG'));
} else {
this.logger.info(chalk.bgRed('FAILURE - NOT ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG'));
}
Object.entries(this.config).forEach(([key, value]) => {
const paddedKey = key.padEnd(maxKeyLength, ' ');
this.logger.info(`${chalk.white(paddedKey)} : ${chalk.white.bold(String(value))}`);
});
const endTime = Date.now();
const elapsedSeconds = ((endTime - startTime) / 1000).toFixed(2);
const summaryParts = [
`${matchedCount} matched`,
diffCount > 0 ? `${diffCount} differed` : null,
`${files.length} total files`,
`in ${elapsedSeconds}s`,
].filter(Boolean);
this.logger.info(chalk.white('\n ' + summaryParts.join(' | ') + '\n'));
const reporter = new HtmlReporter();
reporter.generateReport(oldFolder, newFolder, results);
return isDifferent;
}
/**
* Loads and parses a JSON file safely.
* Returns null if the file is empty, null, or invalid JSON.
* @param {string} filePath - Path to the JSON file.
* @returns {any | null} Parsed JSON object or null if empty/invalid.
*/
private loadAndParseJson(filePath: string): any | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8').trim();
if (!raw || raw === '""' || raw === 'null') {
return null;
}
return JSON.parse(raw);
} catch (err: unknown) {
const error = err as Error;
this.logger.warn(`! Failed to parse JSON at ${filePath}: ${error.message}`);
return null;
}
}
/**
* Logs a formatted header block with a title, a separator line, and a message.
* @param {string} title - Title text for the header.
* @param {string} message - Message text under the title.
*/
private logHeader(title: string, message: string): void {
const separator = '-'.repeat(72);
this.logger.info('');
this.logger.info(chalk.cyan(title));
this.logger.info(separator);
this.logger.info(message);
this.logger.info('');
}
}