UNPKG

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
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