docs-v2/api-docs/scripts/apply-overlay.js

259 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Apply OpenAPI Overlay to Base Spec
*
* Implements a subset of the OpenAPI Overlay Specification v1.0.0
* to merge product-specific overlays onto a shared base spec.
*
* Supported overlay actions:
* - target: $.info.title (update info title)
* - target: $.info.description (update info description)
* - target: $.servers[0].description (update server description)
* - target: $.servers[0].variables.*.description (update variable description)
* - target: $.paths['/path'].method (add/update operation)
* - target: $.paths['/path'] (add entire path)
*
* Usage:
* node apply-overlay.js <base.yml> <overlay.yml> -o <output.yml>
*
* @module apply-overlay
*/
const fs = require('fs');
const yaml = require('js-yaml');
const path = require('path');
/**
* Parse a JSONPath-like target string
* @param {string} target - JSONPath expression (e.g., "$.info.title")
* @returns {string[]} - Path segments
*/
function parseTarget(target) {
// Remove leading $. and split by . or bracket notation
const cleaned = target.replace(/^\$\.?/, '');
const segments = [];
let current = '';
let inBracket = false;
for (let i = 0; i < cleaned.length; i++) {
const char = cleaned[i];
if (char === '[' && !inBracket) {
if (current) segments.push(current);
current = '';
inBracket = true;
} else if (char === ']' && inBracket) {
// Remove quotes from bracket content
segments.push(current.replace(/^['"]|['"]$/g, ''));
current = '';
inBracket = false;
} else if (char === '.' && !inBracket) {
if (current) segments.push(current);
current = '';
} else {
current += char;
}
}
if (current) segments.push(current);
return segments;
}
/**
* Get a value from an object using path segments
* @param {object} obj - Source object
* @param {string[]} segments - Path segments
* @returns {*} - Value at path
*/
function getPath(obj, segments) {
let current = obj;
for (const segment of segments) {
if (current === undefined || current === null) return undefined;
// Handle array index
if (/^\d+$/.test(segment)) {
current = current[parseInt(segment, 10)];
} else {
current = current[segment];
}
}
return current;
}
/**
* Set a value in an object using path segments
* @param {object} obj - Target object
* @param {string[]} segments - Path segments
* @param {*} value - Value to set
*/
function setPath(obj, segments, value) {
let current = obj;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const nextSegment = segments[i + 1];
// Handle array index
if (/^\d+$/.test(segment)) {
const idx = parseInt(segment, 10);
if (current[idx] === undefined) {
current[idx] = /^\d+$/.test(nextSegment) ? [] : {};
}
current = current[idx];
} else {
if (current[segment] === undefined) {
current[segment] = /^\d+$/.test(nextSegment) ? [] : {};
}
current = current[segment];
}
}
const lastSegment = segments[segments.length - 1];
if (/^\d+$/.test(lastSegment)) {
current[parseInt(lastSegment, 10)] = value;
} else {
current[lastSegment] = value;
}
}
/**
* Deep merge two objects
* Arrays are replaced, not merged.
* @param {object} target - Target object
* @param {object} source - Source object to merge
* @returns {object} - Merged object
*/
function deepMerge(target, source) {
// Arrays are replaced entirely, not merged
if (Array.isArray(source)) {
return source;
}
if (typeof source !== 'object' || source === null) {
return source;
}
if (typeof target !== 'object' || target === null || Array.isArray(target)) {
return source;
}
const result = { ...target };
for (const key of Object.keys(source)) {
if (
typeof source[key] === 'object' &&
source[key] !== null &&
!Array.isArray(source[key])
) {
result[key] = deepMerge(result[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* Apply overlay actions to base spec
* @param {object} base - Base OpenAPI spec
* @param {object} overlay - Overlay spec with actions
* @returns {object} - Merged spec
*/
function applyOverlay(base, overlay) {
const result = JSON.parse(JSON.stringify(base)); // Deep clone
if (!overlay.actions || !Array.isArray(overlay.actions)) {
console.warn('Warning: No actions found in overlay');
return result;
}
for (const action of overlay.actions) {
if (!action.target) {
console.warn('Warning: Action missing target, skipping');
continue;
}
const segments = parseTarget(action.target);
if (action.update !== undefined) {
// Get existing value at path
const existing = getPath(result, segments);
if (
existing !== undefined &&
typeof existing === 'object' &&
typeof action.update === 'object'
) {
// Merge objects
setPath(result, segments, deepMerge(existing, action.update));
} else {
// Replace value
setPath(result, segments, action.update);
}
console.log(`Applied: ${action.target}`);
} else if (action.remove === true) {
// Remove is not implemented yet
console.warn(
`Warning: remove action not implemented for ${action.target}`
);
}
}
return result;
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
// Parse arguments
let baseFile = null;
let overlayFile = null;
let outputFile = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '-o' || args[i] === '--output') {
outputFile = args[++i];
} else if (!baseFile) {
baseFile = args[i];
} else if (!overlayFile) {
overlayFile = args[i];
}
}
if (!baseFile || !overlayFile) {
console.error(
'Usage: node apply-overlay.js <base.yml> <overlay.yml> -o <output.yml>'
);
process.exit(1);
}
// Read files
console.log(`Base: ${baseFile}`);
console.log(`Overlay: ${overlayFile}`);
const baseContent = fs.readFileSync(baseFile, 'utf8');
const overlayContent = fs.readFileSync(overlayFile, 'utf8');
const base = yaml.load(baseContent);
const overlay = yaml.load(overlayContent);
// Apply overlay
const result = applyOverlay(base, overlay);
// Output
const outputYaml = yaml.dump(result, {
lineWidth: -1, // Don't wrap lines
noRefs: true, // Don't use YAML references
quotingType: "'",
forceQuotes: false,
});
if (outputFile) {
fs.writeFileSync(outputFile, outputYaml);
console.log(`Output: ${outputFile}`);
} else {
console.log(outputYaml);
}
}
main();