@tryloop/oats
Version:
🌾 OATS - OpenAPI TypeScript Sync. The missing link between your OpenAPI specs and TypeScript applications. Automatically watch, generate, and sync TypeScript clients from your API definitions.
257 lines • 8.35 kB
JavaScript
/**
* OATS Swagger/OpenAPI Change Detection
*
* Intelligent detection of meaningful changes in API specifications
*
* @module @oatsjs/core/swagger-diff
*/
import crypto from 'crypto';
/**
* Detects meaningful changes in OpenAPI/Swagger specifications
*/
export class SwaggerChangeDetector {
lastState = null;
lastSpec = null;
currentState = null;
/**
* Check if API specification has significant changes
*/
hasSignificantChanges(currentSpec) {
const currentState = this.calculateSpecHash(currentSpec);
this.currentState = currentState;
if (this.lastState === null) {
// First time, consider it a change
this.lastState = currentState;
this.lastSpec = currentSpec;
return true;
}
if (this.lastState === currentState) {
// No changes at all
return false;
}
// Analyze the actual changes
const changes = this.detectChanges(this.lastSpec, currentSpec);
// Update state
this.lastState = currentState;
this.lastSpec = currentSpec;
// Consider changes significant if they affect the API contract
return this.areChangesSignificant(changes);
}
/**
* Get the current spec hash
*/
getCurrentHash() {
return this.currentState;
}
/**
* Get detailed change analysis
*/
analyzeChanges(previousSpec, currentSpec) {
const changes = this.detectChanges(previousSpec, currentSpec);
const summary = {
major: changes.filter((c) => c.severity === 'major').length,
minor: changes.filter((c) => c.severity === 'minor').length,
patch: changes.filter((c) => c.severity === 'patch').length,
};
return {
hasChanges: changes.length > 0,
changes,
summary,
};
}
/**
* Calculate hash of API specification
*/
calculateSpecHash(spec) {
// Extract only the meaningful parts for hashing
const relevantSpec = this.extractRelevantParts(spec);
// Serialize with sorted keys for consistent hashing
const serialized = JSON.stringify(relevantSpec, null, 2);
return crypto.createHash('sha256').update(serialized).digest('hex');
}
/**
* Extract parts of spec that matter for API contract
*/
extractRelevantParts(spec) {
if (!spec) {
return {};
}
const relevant = {};
// Core spec info
if (spec.info) {
relevant.info = {
version: spec.info.version,
title: spec.info.title,
};
}
// Paths (endpoints)
if (spec.paths) {
relevant.paths = this.normalizePaths(spec.paths);
}
// Components/definitions (schemas)
if (spec.components?.schemas) {
relevant.schemas = spec.components.schemas;
}
else if (spec.definitions) {
relevant.schemas = spec.definitions;
}
// Security definitions
if (spec.components?.securitySchemes) {
relevant.security = spec.components.securitySchemes;
}
else if (spec.securityDefinitions) {
relevant.security = spec.securityDefinitions;
}
return relevant;
}
/**
* Normalize paths for comparison
*/
normalizePaths(paths) {
const normalized = {};
for (const [path, pathItem] of Object.entries(paths)) {
normalized[path] = {};
for (const [method, operation] of Object.entries(pathItem)) {
if (typeof operation === 'object' && operation !== null) {
const op = operation;
normalized[path][method] = {
operationId: op.operationId,
summary: op.summary,
parameters: op.parameters || [],
requestBody: op.requestBody,
responses: op.responses || {},
tags: op.tags || [],
};
}
}
}
return normalized;
}
/**
* Detect specific changes between specs
*/
detectChanges(oldSpec, newSpec) {
const changes = [];
if (!oldSpec || !newSpec) {
return changes;
}
// Extract and normalize relevant parts for comparison
const oldRelevant = this.extractRelevantParts(oldSpec);
const newRelevant = this.extractRelevantParts(newSpec);
// Compare normalized paths
changes.push(...this.compareObjects(oldRelevant.paths || {}, newRelevant.paths || {}, 'paths', this.classifyPathChange.bind(this)));
// Compare schemas
changes.push(...this.compareObjects(oldRelevant.schemas || {}, newRelevant.schemas || {}, 'schemas', this.classifySchemaChange.bind(this)));
return changes;
}
/**
* Compare two objects and detect changes
*/
compareObjects(oldObj, newObj, basePath, classifier) {
const changes = [];
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of allKeys) {
const path = `${basePath}.${key}`;
const oldValue = oldObj[key];
const newValue = newObj[key];
if (oldValue === undefined && newValue !== undefined) {
// Added
const { description, severity } = classifier('added', path);
changes.push({
type: 'added',
path,
description,
severity,
});
}
else if (oldValue !== undefined && newValue === undefined) {
// Removed
const { description, severity } = classifier('removed', path);
changes.push({
type: 'removed',
path,
description,
severity,
});
}
else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
// Modified
const { description, severity } = classifier('modified', path);
changes.push({
type: 'modified',
path,
description,
severity,
});
}
}
return changes;
}
/**
* Classify path changes
*/
classifyPathChange(changeType, path) {
if (changeType === 'added') {
return {
description: `New endpoint added: ${path}`,
severity: 'minor',
};
}
else if (changeType === 'removed') {
return {
description: `Endpoint removed: ${path}`,
severity: 'major',
};
}
else {
return {
description: `Endpoint modified: ${path}`,
severity: 'minor',
};
}
}
/**
* Classify schema changes
*/
classifySchemaChange(changeType, path) {
if (changeType === 'added') {
return {
description: `New schema added: ${path}`,
severity: 'minor',
};
}
else if (changeType === 'removed') {
return {
description: `Schema removed: ${path}`,
severity: 'major',
};
}
else {
return {
description: `Schema modified: ${path}`,
severity: 'minor',
};
}
}
/**
* Determine if changes are significant enough to trigger regeneration
*/
areChangesSignificant(changes) {
// Any major or minor changes are significant
return changes.some((change) => change.severity === 'major' || change.severity === 'minor');
}
/**
* Reset the detector state
*/
reset() {
this.lastState = null;
this.lastSpec = null;
}
/**
* Get current state hash
*/
getCurrentState() {
return this.lastState;
}
}
//# sourceMappingURL=swagger-diff.js.map