openapi-minifier
Version:
A CLI tool by Treblle tp minify OpenAPI V3 Specs by removing redundant information not relevant to AI Agents and LLMs.
786 lines • 28.8 kB
JavaScript
import { parseOpenAPI, serializeOpenAPI } from './parser.js';
import { countNestedKey, deepClone, isObject } from './utils.js';
export async function minifyOpenAPI(content, inputFormat, options, outputFormat) {
const originalSpec = parseOpenAPI(content, inputFormat);
const spec = deepClone(originalSpec);
const removedElements = {
examples: 0,
descriptions: 0,
summaries: 0,
tags: 0,
deprecatedPaths: 0,
extractedResponses: 0,
extractedSchemas: 0,
};
const originalCounts = {
examples: countNestedKey(originalSpec, 'examples'),
descriptions: countNestedKey(originalSpec, 'description'),
summaries: countNestedKey(originalSpec, 'summary'),
tags: countNestedKey(originalSpec, 'tags'),
};
if (options.removeDeprecated) {
removedElements.deprecatedPaths = removeDeprecatedPaths(spec);
}
if (options.extractCommonResponses) {
removedElements.extractedResponses = extractCommonResponses(spec);
}
if (options.extractCommonSchemas) {
removedElements.extractedSchemas = extractCommonSchemas(spec);
}
minifyObject(spec, options, removedElements);
if (options.preset === 'max' || options.preset === 'balanced' || removedElements.deprecatedPaths > 0) {
removeUnusedComponents(spec);
}
if (!spec.openapi)
spec.openapi = originalSpec.openapi;
if (!spec.info)
spec.info = originalSpec.info;
if (!spec.paths)
spec.paths = originalSpec.paths;
const finalCounts = {
examples: countNestedKey(spec, 'examples'),
descriptions: countNestedKey(spec, 'description'),
summaries: countNestedKey(spec, 'summary'),
tags: countNestedKey(spec, 'tags'),
};
removedElements.examples = originalCounts.examples - finalCounts.examples;
removedElements.descriptions = originalCounts.descriptions - finalCounts.descriptions;
removedElements.summaries = originalCounts.summaries - finalCounts.summaries;
removedElements.tags = originalCounts.tags - finalCounts.tags;
const finalFormat = outputFormat || inputFormat;
const minifiedContent = serializeOpenAPI(spec, finalFormat);
const originalSize = Buffer.byteLength(content, 'utf-8');
const minifiedSize = Buffer.byteLength(minifiedContent, 'utf-8');
const reductionPercentage = ((originalSize - minifiedSize) / originalSize) * 100;
return {
minifiedContent,
stats: {
originalSize,
minifiedSize,
reductionPercentage,
removedElements,
},
};
}
function minifyDescriptionText(description) {
let result = description;
let prevResult;
let iterations = 0;
do {
prevResult = result;
result = result
.replace(/<[^>]*>/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^#+\s*/gm, '')
.replace(/^[-*]\s*/gm, '')
.replace(/^\d+\.\s*/gm, '');
iterations++;
} while (result !== prevResult && iterations < 10);
return result
.replace(/\s+/g, ' ')
.replace(/\n\s*\n/g, ' ')
.trim();
}
function removeDeprecatedPaths(spec) {
if (!spec.paths || typeof spec.paths !== 'object') {
return 0;
}
let removedCount = 0;
const pathsToRemove = [];
for (const [pathName, pathItem] of Object.entries(spec.paths)) {
if (isObject(pathItem)) {
if (pathItem.deprecated === true) {
pathsToRemove.push(pathName);
removedCount++;
continue;
}
const operations = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
const pathOperations = operations.filter(op => op in pathItem);
if (pathOperations.length > 0) {
const allOperationsDeprecated = pathOperations.every(op => {
const operation = pathItem[op];
return isObject(operation) && operation.deprecated === true;
});
if (allOperationsDeprecated) {
pathsToRemove.push(pathName);
removedCount++;
}
else {
for (const op of pathOperations) {
const operation = pathItem[op];
if (isObject(operation) && operation.deprecated === true) {
delete pathItem[op];
}
}
const remainingOps = operations.filter(op => op in pathItem);
if (remainingOps.length === 0) {
pathsToRemove.push(pathName);
removedCount++;
}
}
}
}
}
for (const pathName of pathsToRemove) {
delete spec.paths[pathName];
}
return removedCount;
}
function minifyObject(obj, options, removedElements, path = []) {
if (!isObject(obj))
return;
const keys = Object.keys(obj);
if (path.length === 0) {
const protectedFields = ['openapi', 'info', 'paths'];
if (keys.some(key => protectedFields.includes(key))) {
for (const key of keys) {
if (protectedFields.includes(key)) {
const currentPath = [...path, key];
const value = obj[key];
if (isObject(value) || Array.isArray(value)) {
minifyObject(value, options, removedElements, currentPath);
}
}
else {
const currentPath = [...path, key];
const value = obj[key];
processField(obj, key, value, options, removedElements, currentPath);
}
}
return;
}
}
for (const key of keys) {
const currentPath = [...path, key];
const value = obj[key];
processField(obj, key, value, options, removedElements, currentPath);
}
}
function processField(obj, key, value, options, removedElements, currentPath) {
if (key === 'examples' && !options.keepExamples) {
delete obj[key];
return;
}
if (key === 'description' && typeof value === 'string') {
const isRequiredDescription = isRequiredDescriptionField(currentPath);
if (options.keepDescriptions === 'none' && !isRequiredDescription) {
delete obj[key];
return;
}
else if (options.keepDescriptions === 'schema-only' && !isRequiredDescription) {
const isInSchema = currentPath.some(segment => segment === 'schemas' ||
segment === 'properties' ||
(segment === 'components' && currentPath.includes('schemas')));
if (!isInSchema) {
delete obj[key];
return;
}
}
obj[key] = minifyDescriptionText(value);
}
if (key === 'summary' && !options.keepSummaries) {
const isRequiredSummary = isRequiredSummaryField(currentPath);
if (!isRequiredSummary) {
delete obj[key];
return;
}
}
if (key === 'tags' && !options.keepTags && Array.isArray(value)) {
if (currentPath.length === 1) {
const cleanedTags = value.map(tag => {
if (isObject(tag) && 'name' in tag) {
return { name: tag.name };
}
return tag;
});
obj[key] = cleanedTags;
}
}
if (currentPath[0] === 'info' && isObject(value)) {
cleanInfoSection(obj, options);
}
if (key === 'servers' && Array.isArray(value)) {
const cleanedServers = value.map(server => {
if (isObject(server)) {
const cleaned = { ...server };
if (!options.keepDescriptions || options.keepDescriptions === 'none') {
delete cleaned.description;
}
else if (typeof cleaned.description === 'string') {
cleaned.description = minifyDescriptionText(cleaned.description);
}
return cleaned;
}
return server;
});
obj[key] = cleanedServers;
}
if (key === 'responses' && isObject(value)) {
cleanResponses(value, options);
}
if (key === 'parameters' && Array.isArray(value)) {
const cleanedParams = value.map(param => {
if (isObject(param)) {
const cleaned = { ...param };
if (!options.keepExamples) {
delete cleaned.examples;
delete cleaned.example;
}
return cleaned;
}
return param;
});
obj[key] = cleanedParams;
}
if (isInSchema(currentPath)) {
cleanSchemaProperties(obj, key, value, options);
}
if (isObject(value)) {
minifyObject(value, options, removedElements, currentPath);
}
else if (Array.isArray(value)) {
value.forEach((item, index) => {
minifyObject(item, options, removedElements, [...currentPath, index.toString()]);
});
}
}
function cleanInfoSection(obj, options) {
const essentialFields = ['title', 'version'];
const optionalFields = ['description', 'contact', 'license', 'termsOfService'];
const cleaned = {};
for (const field of essentialFields) {
if (field in obj) {
cleaned[field] = obj[field];
}
}
for (const field of optionalFields) {
if (field in obj) {
if (field === 'description') {
if (options.keepDescriptions === 'none') {
continue;
}
else if (typeof obj[field] === 'string') {
cleaned[field] = minifyDescriptionText(obj[field]);
}
else {
cleaned[field] = obj[field];
}
}
else if (field === 'contact' || field === 'license') {
if (isObject(obj[field])) {
if (field === 'contact') {
const contact = obj[field];
const cleanedContact = {};
if ('name' in contact)
cleanedContact.name = contact.name;
if ('url' in contact)
cleanedContact.url = contact.url;
if ('email' in contact)
cleanedContact.email = contact.email;
cleaned[field] = cleanedContact;
}
else if (field === 'license') {
const license = obj[field];
const cleanedLicense = {};
if ('name' in license)
cleanedLicense.name = license.name;
if ('url' in license)
cleanedLicense.url = license.url;
cleaned[field] = cleanedLicense;
}
}
}
else {
cleaned[field] = obj[field];
}
}
}
Object.keys(obj).forEach(key => delete obj[key]);
Object.assign(obj, cleaned);
}
function cleanResponses(responses, options) {
for (const [statusCode, response] of Object.entries(responses)) {
if (isObject(response)) {
const cleaned = { ...response };
if (!cleaned.description) {
cleaned.description = getMinimalResponseDescription(statusCode);
}
else if (options.keepDescriptions === 'none') {
cleaned.description = getMinimalResponseDescription(statusCode);
}
else if (typeof cleaned.description === 'string') {
cleaned.description = minifyDescriptionText(cleaned.description);
}
if (!options.keepExamples) {
if (isObject(cleaned.content)) {
for (const [, mediaTypeObj] of Object.entries(cleaned.content)) {
if (isObject(mediaTypeObj)) {
delete mediaTypeObj.examples;
delete mediaTypeObj.example;
}
}
}
}
responses[statusCode] = cleaned;
}
}
}
function getMinimalResponseDescription(statusCode) {
const descriptions = {
'200': 'Success',
'201': 'Created',
'204': 'No Content',
'400': 'Bad Request',
'401': 'Unauthorized',
'403': 'Forbidden',
'404': 'Not Found',
'412': 'Precondition Failed',
'429': 'Too Many Requests',
'500': 'Internal Server Error',
'503': 'Service Unavailable',
'504': 'Gateway Timeout',
};
return descriptions[statusCode] || 'Response';
}
function isRequiredDescriptionField(path) {
if (path.includes('responses') && path[path.length - 1] === 'description') {
return true;
}
if (path.includes('tags') && path[path.length - 1] === 'description') {
return true;
}
return false;
}
function isRequiredSummaryField(_path) {
return false;
}
function isInSchema(path) {
return path.includes('schemas') ||
path.includes('properties') ||
path.some(segment => segment === 'schema') ||
(path.includes('components') && path.includes('schemas'));
}
function cleanSchemaProperties(obj, key, value, options) {
const schemaPropertiesToRemove = [
'format',
'pattern',
'minLength',
'maxLength',
'minimum',
'maximum',
'multipleOf',
'exclusiveMinimum',
'exclusiveMaximum',
'minItems',
'maxItems',
'uniqueItems',
'minProperties',
'maxProperties',
'additionalProperties',
'title',
'default',
'readOnly',
'writeOnly',
'deprecated',
'nullable',
];
if (options.preset === 'max') {
if (schemaPropertiesToRemove.includes(key)) {
delete obj[key];
return;
}
if (key === 'additionalProperties' && value !== true) {
delete obj[key];
return;
}
}
if (options.preset === 'balanced') {
const balancedRemovalList = [
'format',
'pattern',
'title',
'default',
'readOnly',
'writeOnly',
'deprecated',
'nullable',
];
if (balancedRemovalList.includes(key)) {
delete obj[key];
return;
}
}
}
function removeUnusedComponents(spec) {
if (!spec.components || !spec.components.schemas) {
return;
}
const referencedSchemas = new Set();
function findReferences(obj, currentPath = []) {
if (!obj || typeof obj !== 'object')
return;
if (currentPath[0] === 'components')
return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
findReferences(item, [...currentPath, index.toString()]);
});
}
else {
for (const [key, value] of Object.entries(obj)) {
if (key === '$ref' && typeof value === 'string') {
const match = value.match(/#\/components\/schemas\/(.+)$/);
if (match) {
referencedSchemas.add(match[1]);
}
}
else {
findReferences(value, [...currentPath, key]);
}
}
}
}
findReferences({
paths: spec.paths,
...(spec.webhooks && { webhooks: spec.webhooks }),
...(spec.callbacks && { callbacks: spec.callbacks })
});
function findTransitiveReferences(schemaName, visited = new Set()) {
if (visited.has(schemaName))
return;
visited.add(schemaName);
const schema = spec.components.schemas[schemaName];
if (schema) {
const tempRefs = new Set();
function findRefsInSchema(obj) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach(item => findRefsInSchema(item));
}
else {
for (const [key, value] of Object.entries(obj)) {
if (key === '$ref' && typeof value === 'string') {
const match = value.match(/#\/components\/schemas\/(.+)$/);
if (match) {
tempRefs.add(match[1]);
referencedSchemas.add(match[1]);
}
}
else {
findRefsInSchema(value);
}
}
}
}
findRefsInSchema(schema);
for (const newRef of tempRefs) {
findTransitiveReferences(newRef, visited);
}
}
}
const initialRefs = new Set(referencedSchemas);
for (const schemaName of initialRefs) {
findTransitiveReferences(schemaName);
}
const originalSchemaCount = Object.keys(spec.components.schemas).length;
const usedSchemas = {};
for (const schemaName of referencedSchemas) {
if (spec.components.schemas[schemaName]) {
usedSchemas[schemaName] = spec.components.schemas[schemaName];
}
}
spec.components.schemas = usedSchemas;
const removedSchemaCount = originalSchemaCount - Object.keys(usedSchemas).length;
if (removedSchemaCount > 0) {
console.log(` Removed ${removedSchemaCount} unused schemas`);
}
if (Object.keys(spec.components.schemas).length === 0) {
delete spec.components.schemas;
}
if (spec.components && Object.keys(spec.components).length === 0) {
delete spec.components;
}
}
function extractCommonResponses(spec) {
if (!spec.paths || typeof spec.paths !== 'object') {
return 0;
}
const responseMap = new Map();
function collectResponses(obj, currentPath = []) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
collectResponses(item, [...currentPath, index.toString()]);
});
return;
}
for (const [key, value] of Object.entries(obj)) {
const newPath = [...currentPath, key];
if (currentPath[currentPath.length - 1] === 'responses' &&
/^\d{3}$/.test(key) &&
isObject(value)) {
const responseKey = createResponseKey(value);
const locationKey = newPath.slice(0, -2).join('/') + `/${key}`;
if (responseMap.has(responseKey)) {
responseMap.get(responseKey).locations.push(locationKey);
}
else {
responseMap.set(responseKey, {
response: deepClone(value),
locations: [locationKey]
});
}
}
else {
collectResponses(value, newPath);
}
}
}
collectResponses(spec);
const commonResponses = new Map();
let refCounter = 0;
for (const [responseKey, data] of responseMap.entries()) {
if (data.locations.length >= 3) {
const refName = generateResponseRefName(data.response, refCounter++);
commonResponses.set(responseKey, {
...data,
refName
});
}
}
if (commonResponses.size === 0) {
return 0;
}
if (!spec.components) {
spec.components = {};
}
if (!spec.components.responses) {
spec.components.responses = {};
}
let extractedCount = 0;
for (const [responseKey, data] of commonResponses.entries()) {
spec.components.responses[data.refName] = data.response;
function replaceWithRef(obj, currentPath = []) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
replaceWithRef(item, [...currentPath, index.toString()]);
});
return;
}
for (const [key, value] of Object.entries(obj)) {
const newPath = [...currentPath, key];
if (currentPath[currentPath.length - 1] === 'responses' &&
/^\d{3}$/.test(key) &&
isObject(value)) {
const checkKey = createResponseKey(value);
if (checkKey === responseKey) {
obj[key] = { $ref: `#/components/responses/${data.refName}` };
extractedCount++;
}
}
else {
replaceWithRef(value, newPath);
}
}
}
replaceWithRef(spec);
}
return extractedCount;
}
function createResponseKey(response) {
const normalized = deepClone(response);
function removeVariableContent(obj) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach(item => removeVariableContent(item));
return;
}
delete obj.examples;
delete obj.example;
for (const value of Object.values(obj)) {
removeVariableContent(value);
}
}
removeVariableContent(normalized);
return JSON.stringify(normalized);
}
function generateResponseRefName(response, counter) {
const statusMatch = response.description?.match(/^(Unauthorized|Forbidden|Not Found|Bad Request|Too Many Requests|Internal Server Error|Success|Created|No Content)/i);
if (statusMatch) {
const baseName = statusMatch[1].replace(/\s+/g, '');
return counter === 0 ? baseName : `${baseName}${counter + 1}`;
}
const fallbackNames = [
'CommonError', 'ApiError', 'ValidationError', 'AuthError', 'ServerError',
'ClientError', 'NotFoundError', 'ForbiddenError', 'RateLimitError'
];
return fallbackNames[counter % fallbackNames.length] + Math.floor(counter / fallbackNames.length + 1);
}
function extractCommonSchemas(spec) {
if (!spec.paths || typeof spec.paths !== 'object') {
return 0;
}
const schemaMap = new Map();
function collectSchemas(obj, currentPath = []) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
collectSchemas(item, [...currentPath, index.toString()]);
});
return;
}
for (const [key, value] of Object.entries(obj)) {
const newPath = [...currentPath, key];
if (currentPath[0] === 'components' && currentPath[1] === 'schemas') {
continue;
}
if (key === 'schema' &&
isObject(value) &&
!('$ref' in value) &&
isInlineSchemaWorthExtracting(value)) {
const schemaKey = createSchemaKey(value);
const locationKey = newPath.slice(0, -1).join('/');
if (schemaMap.has(schemaKey)) {
schemaMap.get(schemaKey).locations.push(locationKey);
}
else {
schemaMap.set(schemaKey, {
schema: deepClone(value),
locations: [locationKey]
});
}
}
else {
collectSchemas(value, newPath);
}
}
}
collectSchemas(spec);
const commonSchemas = new Map();
let refCounter = 0;
for (const [schemaKey, data] of schemaMap.entries()) {
if (data.locations.length >= 3) {
const refName = generateSchemaRefName(data.schema, refCounter++);
commonSchemas.set(schemaKey, {
...data,
refName
});
}
}
if (commonSchemas.size === 0) {
return 0;
}
if (!spec.components) {
spec.components = {};
}
if (!spec.components.schemas) {
spec.components.schemas = {};
}
let extractedCount = 0;
for (const [schemaKey, data] of commonSchemas.entries()) {
spec.components.schemas[data.refName] = data.schema;
function replaceWithRef(obj, currentPath = []) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
replaceWithRef(item, [...currentPath, index.toString()]);
});
return;
}
for (const [key, value] of Object.entries(obj)) {
const newPath = [...currentPath, key];
if (currentPath[0] === 'components' && currentPath[1] === 'schemas') {
continue;
}
if (key === 'schema' &&
isObject(value) &&
!('$ref' in value)) {
const checkKey = createSchemaKey(value);
if (checkKey === schemaKey) {
obj[key] = { $ref: `#/components/schemas/${data.refName}` };
extractedCount++;
}
}
else {
replaceWithRef(value, newPath);
}
}
}
replaceWithRef(spec);
}
return extractedCount;
}
function createSchemaKey(schema) {
const normalized = deepClone(schema);
function removeVariableContent(obj) {
if (!obj || typeof obj !== 'object')
return;
if (Array.isArray(obj)) {
obj.forEach(item => removeVariableContent(item));
return;
}
delete obj.examples;
delete obj.example;
delete obj.default;
delete obj.description;
delete obj.title;
for (const value of Object.values(obj)) {
removeVariableContent(value);
}
}
removeVariableContent(normalized);
return JSON.stringify(normalized);
}
function generateSchemaRefName(schema, counter) {
if (schema.type) {
const baseType = schema.type;
if (baseType === 'object' && schema.properties) {
const props = Object.keys(schema.properties);
if (props.includes('message')) {
return counter === 0 ? 'ErrorMessage' : `ErrorMessage${counter + 1}`;
}
if (props.includes('id') && props.includes('name')) {
return counter === 0 ? 'IdNameResource' : `IdNameResource${counter + 1}`;
}
if (props.includes('status')) {
return counter === 0 ? 'StatusObject' : `StatusObject${counter + 1}`;
}
return counter === 0 ? 'CommonObject' : `CommonObject${counter + 1}`;
}
if (baseType === 'array') {
return counter === 0 ? 'CommonArray' : `CommonArray${counter + 1}`;
}
if (baseType === 'string' && schema.enum) {
return counter === 0 ? 'CommonEnum' : `CommonEnum${counter + 1}`;
}
}
const fallbackNames = [
'CommonSchema', 'SharedSchema', 'ReusableSchema', 'ExtractedSchema',
'CommonType', 'SharedType', 'ReusableType', 'ExtractedType'
];
return fallbackNames[counter % fallbackNames.length] + Math.floor(counter / fallbackNames.length + 1);
}
function isInlineSchemaWorthExtracting(schema) {
if (!isObject(schema))
return false;
if (schema.type && typeof schema.type === 'string' &&
!schema.properties && !schema.enum && !schema.items && !schema.oneOf && !schema.anyOf && !schema.allOf) {
return false;
}
const schemaSize = JSON.stringify(schema).length;
if (schemaSize < 50) {
return false;
}
return true;
}
//# sourceMappingURL=minifier.js.map