Files
prettier-plugin-openapi/src/index.ts

654 lines
22 KiB
TypeScript

import { SupportLanguage, Parser, Printer } from 'prettier';
import type { Plugin } from 'prettier';
import * as yaml from 'js-yaml';
import { getVendorExtensions } from './extensions/vendor-loader.js';
import {
RootKeys,
InfoKeys,
ContactKeys,
LicenseKeys,
ComponentsKeys,
OperationKeys,
ParameterKeys,
SchemaKeys,
ResponseKeys,
SecuritySchemeKeys,
OAuthFlowKeys,
ServerKeys,
ServerVariableKeys,
TagKeys,
ExternalDocsKeys,
WebhookKeys,
PathItemKeys,
RequestBodyKeys,
MediaTypeKeys,
EncodingKeys,
HeaderKeys,
LinkKeys,
ExampleKeys,
DiscriminatorKeys,
XMLKeys,
} from './keys.js';
// Type definitions for better type safety
interface OpenAPINode {
type: 'openapi';
content: any;
originalText: string;
format: 'json' | 'yaml';
}
interface PrettierPath {
getValue(): OpenAPINode;
}
interface OpenAPIPluginOptions {
tabWidth?: number;
printWidth?: number;
}
// Load vendor extensions
const vendorExtensions = getVendorExtensions();
// ============================================================================
// UNIFIED PARSER FUNCTIONS
// ============================================================================
/**
* Unified parser that can handle both JSON and YAML OpenAPI files
*/
function parseOpenAPIFile(text: string, options?: any): OpenAPINode {
// Try to detect the format based on file extension or content
const filePath = options?.filepath || '';
const isYamlFile = filePath.endsWith('.yaml') || filePath.endsWith('.yml');
const isJsonFile = filePath.endsWith('.json');
// If we can't determine from extension, try to detect from content
let format: 'json' | 'yaml' = 'json'; // default to JSON
if (isYamlFile) {
format = 'yaml';
} else if (isJsonFile) {
format = 'json';
} else {
// Try to detect format from content
const trimmedText = text.trim();
if (trimmedText.startsWith('{') || trimmedText.startsWith('[')) {
format = 'json';
} else {
format = 'yaml';
}
}
try {
let parsed: any;
if (format === 'json') {
parsed = JSON.parse(text);
} else {
parsed = yaml.load(text, {
schema: yaml.DEFAULT_SCHEMA,
onWarning: (warning) => {
// Handle YAML warnings if needed
console.warn('YAML parsing warning:', warning);
}
});
}
// Check if this is an OpenAPI file
if (!isOpenAPIFile(parsed, filePath)) {
throw new Error('Not an OpenAPI file');
}
return {
type: 'openapi',
content: parsed,
originalText: text,
format: format,
};
} catch (error) {
throw new Error(`Failed to parse OpenAPI ${format.toUpperCase()}: ${error}`);
}
}
// ============================================================================
// FILE DETECTION FUNCTIONS
// ============================================================================
/**
* Detects if a file is an OpenAPI-related file based on content and structure
*/
function isOpenAPIFile(content: any, filePath?: string): boolean {
if (!content || typeof content !== 'object') {
return false;
}
// Check for root-level OpenAPI indicators (most important)
if (content.openapi || content.swagger) {
return true;
}
// Check file path patterns for common OpenAPI file structures
// Only accept files in OpenAPI-related directories
if (filePath) {
const path = filePath.toLowerCase();
// Check for component directory patterns
if (path.includes('/components/') ||
path.includes('/schemas/') ||
path.includes('/parameters/') ||
path.includes('/responses/') ||
path.includes('/requestbodies/') ||
path.includes('/headers/') ||
path.includes('/examples/') ||
path.includes('/securityschemes/') ||
path.includes('/links/') ||
path.includes('/callbacks/') ||
path.includes('/webhooks/') ||
path.includes('/paths/')) {
return true;
}
}
// Check for component-like structures (only if we have strong indicators)
if (content.components || content.definitions || content.parameters || content.responses || content.securityDefinitions) {
return true;
}
// Check for path-like structures (operations)
if (content.paths || isPathObject(content)) {
return true;
}
// Check for schema-like structures (but be more strict)
// Only accept if we have strong schema indicators
if (isSchemaObject(content) && (content.$ref || content.allOf || content.oneOf || content.anyOf || content.not || content.properties || content.items)) {
return true;
}
// Check for parameter-like structures
if (isParameterObject(content)) {
return true;
}
// Check for response-like structures
if (isResponseObject(content)) {
return true;
}
// Check for security scheme-like structures
if (isSecuritySchemeObject(content)) {
return true;
}
// Check for server-like structures
if (isServerObject(content)) {
return true;
}
// Check for tag-like structures
if (isTagObject(content)) {
return true;
}
// Check for external docs-like structures
if (isExternalDocsObject(content)) {
return true;
}
// Check for webhook-like structures
if (isWebhookObject(content)) {
return true;
}
// Additional strict check: reject objects that look like generic data
// If an object only has simple properties like name, age, etc. without any OpenAPI structure, reject it
const keys = Object.keys(content);
const hasOnlyGenericProperties = keys.every(key =>
!key.startsWith('x-') && // Not a custom extension
!['openapi', 'swagger', 'info', 'paths', 'components', 'definitions', 'parameters', 'responses', 'securityDefinitions', 'tags', 'servers', 'webhooks'].includes(key)
);
if (hasOnlyGenericProperties) {
return false;
}
// If none of the above conditions are met, it's not an OpenAPI file
return false;
}
/**
* Detects if an object represents a path with operations
*/
function isPathObject(obj: any): boolean {
if (!obj || typeof obj !== 'object') {
return false;
}
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
return Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase()));
}
const plugin: Plugin = {
languages: [
{
name: 'openapi',
extensions: [
// Accepting all JSON and YAML files so that component files used by $ref work
'.json', '.yaml', '.yml'
],
parsers: ['openapi-parser'],
},
],
parsers: {
'openapi-parser': {
parse: (text: string, options?: any): OpenAPINode => {
return parseOpenAPIFile(text, options);
},
astFormat: 'openapi-ast',
locStart: (node: OpenAPINode) => 0,
locEnd: (node: OpenAPINode) => node.originalText?.length || 0,
},
},
printers: {
'openapi-ast': {
print: (path: PrettierPath, options?: any, print?: any, ...rest: any[]): string => {
const node = path.getValue();
return formatOpenAPI(node.content, node.format, options);
},
},
},
};
/**
* Unified formatter that outputs in the detected format
*/
function formatOpenAPI(content: any, format: 'json' | 'yaml', options?: OpenAPIPluginOptions): string {
// Sort keys for better organization
const sortedContent = sortOpenAPIKeys(content);
if (format === 'json') {
// Format with proper indentation
return JSON.stringify(sortedContent, null, options?.tabWidth || 2);
} else {
// Format YAML with proper indentation and line breaks
return yaml.dump(sortedContent, {
indent: options?.tabWidth || 2,
lineWidth: options?.printWidth || 80,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
}
}
function formatOpenAPIJSON(content: any, options?: OpenAPIPluginOptions): string {
return formatOpenAPI(content, 'json', options);
}
function formatOpenAPIYAML(content: any, options?: OpenAPIPluginOptions): string {
return formatOpenAPI(content, 'yaml', options);
}
function sortOpenAPIKeys(obj: any): any {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return obj;
}
// Get vendor extensions for top-level
const topLevelExtensions = vendorExtensions['top-level'] || {};
const sortedKeys = Object.keys(obj).sort((a, b) => {
// Use the unified sorting function
return sortKeys(a, b, RootKeys, topLevelExtensions);
});
const sortedObj: any = {};
for (const key of sortedKeys) {
sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], key);
}
return sortedObj;
}
// Enhanced sorting for nested OpenAPI structures
function sortOpenAPIKeysEnhanced(obj: any, path: string = ''): any {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// Handle arrays by recursively sorting each element
if (Array.isArray(obj)) {
return obj.map((item, index) => sortOpenAPIKeysEnhanced(item, `${path}[${index}]`));
}
const contextKey = getContextKey(path, obj);
const standardKeys = getStandardKeysForContext(contextKey);
const customExtensions = vendorExtensions[contextKey] || {};
const sortedKeys = Object.keys(obj).sort((a, b) => {
// Special handling for paths (sort by path pattern)
if (path === 'paths') {
return sortPathKeys(a, b);
}
// Special handling for response codes (sort numerically)
if (path === 'responses') {
return sortResponseCodes(a, b);
}
// Use the unified sorting function for all other cases
return sortKeys(a, b, standardKeys, customExtensions);
});
const sortedObj: any = {};
for (const key of sortedKeys) {
const newPath = path ? `${path}.${key}` : key;
sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], newPath);
}
return sortedObj;
}
function sortPathKeys(a: string, b: string): number {
// Sort paths by specificity (more specific paths first)
const aSpecificity = (a.match(/\{/g) || []).length;
const bSpecificity = (b.match(/\{/g) || []).length;
if (aSpecificity !== bSpecificity) {
return aSpecificity - bSpecificity;
}
return a.localeCompare(b);
}
function sortResponseCodes(a: string, b: string): number {
// Sort response codes numerically
const aNum = parseInt(a);
const bNum = parseInt(b);
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) {
return aNum - bNum;
}
return a.localeCompare(b);
}
//#region Object type detection functions
function isOperationObject(obj: any): boolean {
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
return Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase()));
}
function isParameterObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'name' in obj && 'in' in obj;
}
function isSchemaObject(obj: any): boolean {
if (!obj || typeof obj !== 'object') {
return false;
}
// Check for JSON Schema keywords - be very strict
const hasSchemaKeywords = '$ref' in obj || 'allOf' in obj || 'oneOf' in obj || 'anyOf' in obj || 'not' in obj;
const hasValidType = 'type' in obj && obj.type && ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(obj.type);
// Only return true if we have clear schema indicators
// Must have either schema keywords OR valid type with schema properties
// Also require additional schema-specific properties to be more strict
return hasSchemaKeywords || (hasValidType && ('properties' in obj || 'items' in obj || 'enum' in obj || 'format' in obj || 'pattern' in obj));
}
function isResponseObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('description' in obj || 'content' in obj);
}
function isSecuritySchemeObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'type' in obj &&
['apiKey', 'http', 'oauth2', 'openIdConnect'].includes(obj.type);
}
function isServerObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'url' in obj;
}
function isTagObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'name' in obj && typeof obj.name === 'string' &&
(Object.keys(obj).length === 1 || // Only name
'description' in obj || // name + description
'externalDocs' in obj); // name + externalDocs
}
function isExternalDocsObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'url' in obj;
}
function isWebhookObject(obj: any): boolean {
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase()));
}
function isPathItemObject(obj: any): boolean {
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase()));
}
function isRequestBodyObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('content' in obj || 'description' in obj);
}
function isMediaTypeObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('schema' in obj || 'example' in obj || 'examples' in obj);
}
function isEncodingObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('contentType' in obj || 'style' in obj || 'explode' in obj);
}
function isHeaderObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('description' in obj || 'schema' in obj || 'required' in obj);
}
function isLinkObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('operationRef' in obj || 'operationId' in obj);
}
function isExampleObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('summary' in obj || 'value' in obj || 'externalValue' in obj);
}
function isDiscriminatorObject(obj: any): boolean {
return obj && typeof obj === 'object' && 'propertyName' in obj;
}
function isXMLObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('name' in obj || 'namespace' in obj || 'attribute' in obj);
}
function isContactObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('name' in obj || 'url' in obj || 'email' in obj);
}
function isLicenseObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('name' in obj || 'identifier' in obj || 'url' in obj);
}
function isOAuthFlowObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('authorizationUrl' in obj || 'tokenUrl' in obj || 'scopes' in obj);
}
function isServerVariableObject(obj: any): boolean {
return obj && typeof obj === 'object' && ('enum' in obj || 'default' in obj);
}
//#endregion
//#region Unified sorting function
/**
* Universal sorting function that handles all OpenAPI key sorting
* @param a First key to compare
* @param b Second key to compare
* @param standardKeys Array of standard keys in order
* @param customExtensions Custom extension positions
* @returns Comparison result
*/
function sortKeys(a: string, b: string, standardKeys: readonly string[], customExtensions: Record<string, number> = {}): number {
const aCustomPos = customExtensions[a];
const bCustomPos = customExtensions[b];
// Handle custom extensions first
if (aCustomPos !== undefined && bCustomPos !== undefined) {
return aCustomPos - bCustomPos;
}
if (aCustomPos !== undefined) {
if (aCustomPos < standardKeys.length) {
return -1; // Custom key should come before standard keys
}
}
if (bCustomPos !== undefined) {
if (bCustomPos < standardKeys.length) {
return 1; // Custom key should come before standard keys
}
}
// Standard sorting
const aIndex = standardKeys.indexOf(a);
const bIndex = standardKeys.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
// Handle custom extensions after standard keys
if (aCustomPos !== undefined) return -1;
if (bCustomPos !== undefined) return 1;
// For unknown keys, sort alphabetically at the end
return a.localeCompare(b);
}
//#endregion
//#region Helper functions for custom extensions
function getContextKey(path: string, obj: any): string {
// Determine the context based on path and object properties
if (path === 'info') return 'info';
if (path === 'components') return 'components';
if (path === 'servers' || path.startsWith('servers[')) return 'server';
if (path === 'tags' || path.startsWith('tags[')) return 'tag';
if (path === 'externalDocs') return 'externalDocs';
if (path === 'webhooks') return 'webhook';
if (path === 'definitions') return 'definitions';
if (path === 'securityDefinitions') return 'securityDefinitions';
// Check if this is a path operation (e.g., "paths./users.get")
if (path.includes('.') && path.split('.').length >= 3) {
const pathParts = path.split('.');
const lastPart = pathParts[pathParts.length - 1];
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
if (httpMethods.includes(lastPart.toLowerCase())) {
return 'operation';
}
}
// Handle nested paths for components
if (path.startsWith('components.')) {
if (path.includes('schemas.')) return 'schema';
if (path.includes('parameters.')) return 'parameter';
if (path.includes('responses.')) return 'response';
if (path.includes('securitySchemes.')) return 'securityScheme';
if (path.includes('requestBodies.')) return 'requestBody';
if (path.includes('headers.')) return 'header';
if (path.includes('examples.')) return 'example';
if (path.includes('links.')) return 'link';
if (path.includes('callbacks.')) return 'callback';
if (path.includes('pathItems.')) return 'pathItem';
}
// Handle nested paths for Swagger 2.0
if (path.startsWith('definitions.')) return 'definitions';
if (path.startsWith('securityDefinitions.')) return 'securityDefinitions';
// Handle nested paths for operations (parameters, responses, etc.)
if (path.includes('.parameters.') && path.split('.').length > 3) return 'parameter';
if (path.includes('.responses.') && path.split('.').length > 3) return 'response';
if (path.includes('.requestBody.')) return 'requestBody';
if (path.includes('.headers.')) return 'header';
if (path.includes('.examples.')) return 'example';
if (path.includes('.links.')) return 'link';
if (path.includes('.content.')) return 'mediaType';
if (path.includes('.encoding.')) return 'encoding';
if (path.includes('.discriminator.')) return 'discriminator';
if (path.includes('.xml.')) return 'xml';
if (path.includes('.contact.')) return 'contact';
if (path.includes('.license.')) return 'license';
if (path.includes('.flows.')) return 'oauthFlow';
if (path.includes('.variables.')) return 'serverVariable';
// Check object types as fallback
if (isOperationObject(obj)) return 'operation';
if (isParameterObject(obj)) return 'parameter';
if (isSchemaObject(obj)) return 'schema';
if (isResponseObject(obj)) return 'response';
if (isSecuritySchemeObject(obj)) return 'securityScheme';
if (isServerObject(obj)) return 'server';
if (isTagObject(obj)) return 'tag';
if (isExternalDocsObject(obj)) return 'externalDocs';
if (isWebhookObject(obj)) return 'webhook';
if (isPathItemObject(obj)) return 'pathItem';
if (isRequestBodyObject(obj)) return 'requestBody';
if (isMediaTypeObject(obj)) return 'mediaType';
if (isEncodingObject(obj)) return 'encoding';
if (isHeaderObject(obj)) return 'header';
if (isLinkObject(obj)) return 'link';
if (isExampleObject(obj)) return 'example';
if (isDiscriminatorObject(obj)) return 'discriminator';
if (isXMLObject(obj)) return 'xml';
if (isContactObject(obj)) return 'contact';
if (isLicenseObject(obj)) return 'license';
if (isOAuthFlowObject(obj)) return 'oauthFlow';
if (isServerVariableObject(obj)) return 'serverVariable';
return 'top-level';
}
function getStandardKeysForContext(contextKey: string): readonly string[] {
switch (contextKey) {
case 'info': return InfoKeys;
case 'components': return ComponentsKeys;
case 'operation': return OperationKeys;
case 'parameter': return ParameterKeys;
case 'schema': return SchemaKeys;
case 'response': return ResponseKeys;
case 'securityScheme': return SecuritySchemeKeys;
case 'server': return ServerKeys;
case 'tag': return TagKeys;
case 'externalDocs': return ExternalDocsKeys;
case 'webhook': return WebhookKeys;
case 'pathItem': return PathItemKeys;
case 'requestBody': return RequestBodyKeys;
case 'mediaType': return MediaTypeKeys;
case 'encoding': return EncodingKeys;
case 'header': return HeaderKeys;
case 'link': return LinkKeys;
case 'example': return ExampleKeys;
case 'discriminator': return DiscriminatorKeys;
case 'xml': return XMLKeys;
case 'contact': return ContactKeys;
case 'license': return LicenseKeys;
case 'oauthFlow': return OAuthFlowKeys;
case 'serverVariable': return ServerVariableKeys;
case 'definitions': return SchemaKeys; // Definitions use schema keys
case 'securityDefinitions': return SecuritySchemeKeys; // Security definitions use security scheme keys
default: return RootKeys;
}
}
export default plugin;