add brain

This commit is contained in:
2026-03-12 15:17:52 +07:00
parent fd9f558fa1
commit e7821a7a9d
355 changed files with 93784 additions and 24 deletions

View File

@@ -0,0 +1,914 @@
#!/usr/bin/env python3
"""
API Linter - Analyzes OpenAPI/Swagger specifications for REST conventions and best practices.
This script validates API designs against established conventions including:
- Resource naming conventions (kebab-case resources, camelCase fields)
- HTTP method usage patterns
- URL structure consistency
- Error response format standards
- Documentation completeness
- Pagination patterns
- Versioning compliance
Supports both OpenAPI JSON specifications and raw endpoint definition JSON.
"""
import argparse
import json
import re
import sys
from typing import Any, Dict, List, Tuple, Optional, Set
from urllib.parse import urlparse
from dataclasses import dataclass, field
@dataclass
class LintIssue:
"""Represents a linting issue found in the API specification."""
severity: str # 'error', 'warning', 'info'
category: str
message: str
path: str
suggestion: str = ""
line_number: Optional[int] = None
@dataclass
class LintReport:
"""Complete linting report with issues and statistics."""
issues: List[LintIssue] = field(default_factory=list)
total_endpoints: int = 0
endpoints_with_issues: int = 0
score: float = 0.0
def add_issue(self, issue: LintIssue) -> None:
"""Add an issue to the report."""
self.issues.append(issue)
def get_issues_by_severity(self) -> Dict[str, List[LintIssue]]:
"""Group issues by severity level."""
grouped = {'error': [], 'warning': [], 'info': []}
for issue in self.issues:
if issue.severity in grouped:
grouped[issue.severity].append(issue)
return grouped
def calculate_score(self) -> float:
"""Calculate overall API quality score (0-100)."""
if self.total_endpoints == 0:
return 100.0
error_penalty = len([i for i in self.issues if i.severity == 'error']) * 10
warning_penalty = len([i for i in self.issues if i.severity == 'warning']) * 3
info_penalty = len([i for i in self.issues if i.severity == 'info']) * 1
total_penalty = error_penalty + warning_penalty + info_penalty
base_score = 100.0
# Penalty per endpoint to normalize across API sizes
penalty_per_endpoint = total_penalty / self.total_endpoints if self.total_endpoints > 0 else total_penalty
self.score = max(0.0, base_score - penalty_per_endpoint)
return self.score
class APILinter:
"""Main API linting engine."""
def __init__(self):
self.report = LintReport()
self.openapi_spec: Optional[Dict] = None
self.raw_endpoints: Optional[Dict] = None
# Regex patterns for naming conventions
self.kebab_case_pattern = re.compile(r'^[a-z]+(?:-[a-z0-9]+)*$')
self.camel_case_pattern = re.compile(r'^[a-z][a-zA-Z0-9]*$')
self.snake_case_pattern = re.compile(r'^[a-z]+(?:_[a-z0-9]+)*$')
self.pascal_case_pattern = re.compile(r'^[A-Z][a-zA-Z0-9]*$')
# Standard HTTP methods
self.http_methods = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'}
# Standard HTTP status codes by method
self.standard_status_codes = {
'GET': {200, 304, 404},
'POST': {200, 201, 400, 409, 422},
'PUT': {200, 204, 400, 404, 409},
'PATCH': {200, 204, 400, 404, 409},
'DELETE': {200, 204, 404},
'HEAD': {200, 404},
'OPTIONS': {200}
}
# Common error status codes
self.common_error_codes = {400, 401, 403, 404, 405, 409, 422, 429, 500, 502, 503}
def lint_openapi_spec(self, spec: Dict[str, Any]) -> LintReport:
"""Lint an OpenAPI/Swagger specification."""
self.openapi_spec = spec
self.report = LintReport()
# Basic structure validation
self._validate_openapi_structure()
# Info section validation
self._validate_info_section()
# Server section validation
self._validate_servers_section()
# Paths validation (main linting logic)
self._validate_paths_section()
# Components validation
self._validate_components_section()
# Security validation
self._validate_security_section()
# Calculate final score
self.report.calculate_score()
return self.report
def lint_raw_endpoints(self, endpoints: Dict[str, Any]) -> LintReport:
"""Lint raw endpoint definitions."""
self.raw_endpoints = endpoints
self.report = LintReport()
# Validate raw endpoint structure
self._validate_raw_endpoint_structure()
# Lint each endpoint
for endpoint_path, endpoint_data in endpoints.get('endpoints', {}).items():
self._lint_raw_endpoint(endpoint_path, endpoint_data)
self.report.calculate_score()
return self.report
def _validate_openapi_structure(self) -> None:
"""Validate basic OpenAPI document structure."""
required_fields = ['openapi', 'info', 'paths']
for field in required_fields:
if field not in self.openapi_spec:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message=f"Missing required field: {field}",
path=f"/{field}",
suggestion=f"Add the '{field}' field to the root of your OpenAPI specification"
))
def _validate_info_section(self) -> None:
"""Validate the info section of OpenAPI spec."""
if 'info' not in self.openapi_spec:
return
info = self.openapi_spec['info']
required_info_fields = ['title', 'version']
recommended_info_fields = ['description', 'contact']
for field in required_info_fields:
if field not in info:
self.report.add_issue(LintIssue(
severity='error',
category='documentation',
message=f"Missing required info field: {field}",
path=f"/info/{field}",
suggestion=f"Add a '{field}' field to the info section"
))
for field in recommended_info_fields:
if field not in info:
self.report.add_issue(LintIssue(
severity='warning',
category='documentation',
message=f"Missing recommended info field: {field}",
path=f"/info/{field}",
suggestion=f"Consider adding a '{field}' field to improve API documentation"
))
# Validate version format
if 'version' in info:
version = info['version']
if not re.match(r'^\d+\.\d+(\.\d+)?(-\w+)?$', version):
self.report.add_issue(LintIssue(
severity='warning',
category='versioning',
message=f"Version format '{version}' doesn't follow semantic versioning",
path="/info/version",
suggestion="Use semantic versioning format (e.g., '1.0.0', '2.1.3-beta')"
))
def _validate_servers_section(self) -> None:
"""Validate the servers section."""
if 'servers' not in self.openapi_spec:
self.report.add_issue(LintIssue(
severity='warning',
category='configuration',
message="Missing servers section",
path="/servers",
suggestion="Add a servers section to specify API base URLs"
))
return
servers = self.openapi_spec['servers']
if not isinstance(servers, list) or len(servers) == 0:
self.report.add_issue(LintIssue(
severity='warning',
category='configuration',
message="Empty servers section",
path="/servers",
suggestion="Add at least one server URL"
))
def _validate_paths_section(self) -> None:
"""Validate all API paths and operations."""
if 'paths' not in self.openapi_spec:
return
paths = self.openapi_spec['paths']
if not paths:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message="No paths defined in API specification",
path="/paths",
suggestion="Define at least one API endpoint"
))
return
self.report.total_endpoints = sum(
len([method for method in path_obj.keys() if method.upper() in self.http_methods])
for path_obj in paths.values() if isinstance(path_obj, dict)
)
endpoints_with_issues = set()
for path, path_obj in paths.items():
if not isinstance(path_obj, dict):
continue
# Validate path structure
path_issues = self._validate_path_structure(path)
if path_issues:
endpoints_with_issues.add(path)
# Validate each operation in the path
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
operation_issues = self._validate_operation(path, method.upper(), operation)
if operation_issues:
endpoints_with_issues.add(path)
self.report.endpoints_with_issues = len(endpoints_with_issues)
def _validate_path_structure(self, path: str) -> bool:
"""Validate REST path structure and naming conventions."""
has_issues = False
# Check if path starts with slash
if not path.startswith('/'):
self.report.add_issue(LintIssue(
severity='error',
category='url_structure',
message=f"Path must start with '/' character: {path}",
path=f"/paths/{path}",
suggestion=f"Change '{path}' to '/{path.lstrip('/')}'"
))
has_issues = True
# Split path into segments
segments = [seg for seg in path.split('/') if seg]
# Check for empty segments (double slashes)
if '//' in path:
self.report.add_issue(LintIssue(
severity='error',
category='url_structure',
message=f"Path contains empty segments: {path}",
path=f"/paths/{path}",
suggestion="Remove double slashes from the path"
))
has_issues = True
# Validate each segment
for i, segment in enumerate(segments):
# Skip parameter segments
if segment.startswith('{') and segment.endswith('}'):
# Validate parameter naming
param_name = segment[1:-1]
if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Path parameter '{param_name}' should use camelCase or kebab-case",
path=f"/paths/{path}",
suggestion=f"Use camelCase (e.g., 'userId') or kebab-case (e.g., 'user-id')"
))
has_issues = True
continue
# Check for resource naming conventions
if not self.kebab_case_pattern.match(segment):
# Allow version segments like 'v1', 'v2'
if not re.match(r'^v\d+$', segment):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Resource segment '{segment}' should use kebab-case",
path=f"/paths/{path}",
suggestion=f"Use kebab-case for '{segment}' (e.g., 'user-profiles', 'order-items')"
))
has_issues = True
# Check for verb usage in URLs (anti-pattern)
common_verbs = {'get', 'post', 'put', 'delete', 'create', 'update', 'remove', 'add'}
if segment.lower() in common_verbs:
self.report.add_issue(LintIssue(
severity='warning',
category='rest_conventions',
message=f"Avoid verbs in URLs: '{segment}' in {path}",
path=f"/paths/{path}",
suggestion="Use HTTP methods instead of verbs in URLs. Use nouns for resources."
))
has_issues = True
# Check path depth (avoid over-nesting)
if len(segments) > 6:
self.report.add_issue(LintIssue(
severity='warning',
category='url_structure',
message=f"Path has excessive nesting ({len(segments)} levels): {path}",
path=f"/paths/{path}",
suggestion="Consider flattening the resource hierarchy or using query parameters"
))
has_issues = True
# Check for consistent versioning
if any('v' + str(i) in segments for i in range(1, 10)):
version_segments = [seg for seg in segments if re.match(r'^v\d+$', seg)]
if len(version_segments) > 1:
self.report.add_issue(LintIssue(
severity='error',
category='versioning',
message=f"Multiple version segments in path: {path}",
path=f"/paths/{path}",
suggestion="Use only one version segment per path"
))
has_issues = True
return has_issues
def _validate_operation(self, path: str, method: str, operation: Dict[str, Any]) -> bool:
"""Validate individual operation (HTTP method + path combination)."""
has_issues = False
operation_path = f"/paths/{path}/{method.lower()}"
# Check for required operation fields
if 'responses' not in operation:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message=f"Missing responses section for {method} {path}",
path=f"{operation_path}/responses",
suggestion="Define expected responses for this operation"
))
has_issues = True
# Check for operation documentation
if 'summary' not in operation:
self.report.add_issue(LintIssue(
severity='warning',
category='documentation',
message=f"Missing summary for {method} {path}",
path=f"{operation_path}/summary",
suggestion="Add a brief summary describing what this operation does"
))
has_issues = True
if 'description' not in operation:
self.report.add_issue(LintIssue(
severity='info',
category='documentation',
message=f"Missing description for {method} {path}",
path=f"{operation_path}/description",
suggestion="Add a detailed description for better API documentation"
))
has_issues = True
# Validate HTTP method usage patterns
method_issues = self._validate_http_method_usage(path, method, operation)
if method_issues:
has_issues = True
# Validate responses
if 'responses' in operation:
response_issues = self._validate_responses(path, method, operation['responses'])
if response_issues:
has_issues = True
# Validate parameters
if 'parameters' in operation:
param_issues = self._validate_parameters(path, method, operation['parameters'])
if param_issues:
has_issues = True
# Validate request body
if 'requestBody' in operation:
body_issues = self._validate_request_body(path, method, operation['requestBody'])
if body_issues:
has_issues = True
return has_issues
def _validate_http_method_usage(self, path: str, method: str, operation: Dict[str, Any]) -> bool:
"""Validate proper HTTP method usage patterns."""
has_issues = False
# GET requests should not have request body
if method == 'GET' and 'requestBody' in operation:
self.report.add_issue(LintIssue(
severity='error',
category='rest_conventions',
message=f"GET request should not have request body: {method} {path}",
path=f"/paths/{path}/{method.lower()}/requestBody",
suggestion="Remove requestBody from GET request or use POST if body is needed"
))
has_issues = True
# DELETE requests typically should not have request body
if method == 'DELETE' and 'requestBody' in operation:
self.report.add_issue(LintIssue(
severity='warning',
category='rest_conventions',
message=f"DELETE request typically should not have request body: {method} {path}",
path=f"/paths/{path}/{method.lower()}/requestBody",
suggestion="Consider using query parameters or path parameters instead"
))
has_issues = True
# POST/PUT/PATCH should typically have request body (except for actions)
if method in ['POST', 'PUT', 'PATCH'] and 'requestBody' not in operation:
# Check if this is an action endpoint
if not any(action in path.lower() for action in ['activate', 'deactivate', 'reset', 'confirm']):
self.report.add_issue(LintIssue(
severity='info',
category='rest_conventions',
message=f"{method} request typically should have request body: {method} {path}",
path=f"/paths/{path}/{method.lower()}",
suggestion=f"Consider adding requestBody for {method} operation or use GET if no data is being sent"
))
has_issues = True
return has_issues
def _validate_responses(self, path: str, method: str, responses: Dict[str, Any]) -> bool:
"""Validate response definitions."""
has_issues = False
# Check for success response
success_codes = {'200', '201', '202', '204'}
has_success = any(code in responses for code in success_codes)
if not has_success:
self.report.add_issue(LintIssue(
severity='error',
category='responses',
message=f"Missing success response for {method} {path}",
path=f"/paths/{path}/{method.lower()}/responses",
suggestion="Define at least one success response (200, 201, 202, or 204)"
))
has_issues = True
# Check for error responses
has_error_responses = any(code.startswith('4') or code.startswith('5') for code in responses.keys())
if not has_error_responses:
self.report.add_issue(LintIssue(
severity='warning',
category='responses',
message=f"Missing error responses for {method} {path}",
path=f"/paths/{path}/{method.lower()}/responses",
suggestion="Define common error responses (400, 404, 500, etc.)"
))
has_issues = True
# Validate individual response codes
for status_code, response in responses.items():
if status_code == 'default':
continue
try:
code_int = int(status_code)
except ValueError:
self.report.add_issue(LintIssue(
severity='error',
category='responses',
message=f"Invalid status code '{status_code}' for {method} {path}",
path=f"/paths/{path}/{method.lower()}/responses/{status_code}",
suggestion="Use valid HTTP status codes (e.g., 200, 404, 500)"
))
has_issues = True
continue
# Check if status code is appropriate for the method
expected_codes = self.standard_status_codes.get(method, set())
common_codes = {400, 401, 403, 404, 429, 500} # Always acceptable
if expected_codes and code_int not in expected_codes and code_int not in common_codes:
self.report.add_issue(LintIssue(
severity='info',
category='responses',
message=f"Uncommon status code {status_code} for {method} {path}",
path=f"/paths/{path}/{method.lower()}/responses/{status_code}",
suggestion=f"Consider using standard codes for {method}: {sorted(expected_codes)}"
))
has_issues = True
return has_issues
def _validate_parameters(self, path: str, method: str, parameters: List[Dict[str, Any]]) -> bool:
"""Validate parameter definitions."""
has_issues = False
for i, param in enumerate(parameters):
param_path = f"/paths/{path}/{method.lower()}/parameters[{i}]"
# Check required fields
if 'name' not in param:
self.report.add_issue(LintIssue(
severity='error',
category='parameters',
message=f"Parameter missing name field in {method} {path}",
path=f"{param_path}/name",
suggestion="Add a name field to the parameter"
))
has_issues = True
continue
if 'in' not in param:
self.report.add_issue(LintIssue(
severity='error',
category='parameters',
message=f"Parameter '{param['name']}' missing 'in' field in {method} {path}",
path=f"{param_path}/in",
suggestion="Specify parameter location (query, path, header, cookie)"
))
has_issues = True
# Validate parameter naming
param_name = param['name']
param_location = param.get('in', '')
if param_location == 'query':
# Query parameters should use camelCase or kebab-case
if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Query parameter '{param_name}' should use camelCase or kebab-case in {method} {path}",
path=f"{param_path}/name",
suggestion="Use camelCase (e.g., 'pageSize') or kebab-case (e.g., 'page-size')"
))
has_issues = True
elif param_location == 'path':
# Path parameters should use camelCase or kebab-case
if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Path parameter '{param_name}' should use camelCase or kebab-case in {method} {path}",
path=f"{param_path}/name",
suggestion="Use camelCase (e.g., 'userId') or kebab-case (e.g., 'user-id')"
))
has_issues = True
# Path parameters must be required
if not param.get('required', False):
self.report.add_issue(LintIssue(
severity='error',
category='parameters',
message=f"Path parameter '{param_name}' must be required in {method} {path}",
path=f"{param_path}/required",
suggestion="Set required: true for path parameters"
))
has_issues = True
return has_issues
def _validate_request_body(self, path: str, method: str, request_body: Dict[str, Any]) -> bool:
"""Validate request body definition."""
has_issues = False
if 'content' not in request_body:
self.report.add_issue(LintIssue(
severity='error',
category='request_body',
message=f"Request body missing content for {method} {path}",
path=f"/paths/{path}/{method.lower()}/requestBody/content",
suggestion="Define content types for the request body"
))
has_issues = True
return has_issues
def _validate_components_section(self) -> None:
"""Validate the components section."""
if 'components' not in self.openapi_spec:
self.report.add_issue(LintIssue(
severity='info',
category='structure',
message="Missing components section",
path="/components",
suggestion="Consider defining reusable components (schemas, responses, parameters)"
))
return
components = self.openapi_spec['components']
# Validate schemas
if 'schemas' in components:
self._validate_schemas(components['schemas'])
def _validate_schemas(self, schemas: Dict[str, Any]) -> None:
"""Validate schema definitions."""
for schema_name, schema in schemas.items():
# Check schema naming (should be PascalCase)
if not self.pascal_case_pattern.match(schema_name):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Schema name '{schema_name}' should use PascalCase",
path=f"/components/schemas/{schema_name}",
suggestion=f"Use PascalCase for schema names (e.g., 'UserProfile', 'OrderItem')"
))
# Validate schema properties
if isinstance(schema, dict) and 'properties' in schema:
self._validate_schema_properties(schema_name, schema['properties'])
def _validate_schema_properties(self, schema_name: str, properties: Dict[str, Any]) -> None:
"""Validate schema property naming."""
for prop_name, prop_def in properties.items():
# Properties should use camelCase
if not self.camel_case_pattern.match(prop_name):
self.report.add_issue(LintIssue(
severity='warning',
category='naming',
message=f"Property '{prop_name}' in schema '{schema_name}' should use camelCase",
path=f"/components/schemas/{schema_name}/properties/{prop_name}",
suggestion="Use camelCase for property names (e.g., 'firstName', 'createdAt')"
))
def _validate_security_section(self) -> None:
"""Validate security definitions."""
if 'security' not in self.openapi_spec and 'components' not in self.openapi_spec:
self.report.add_issue(LintIssue(
severity='warning',
category='security',
message="No security configuration found",
path="/security",
suggestion="Define security schemes and apply them to operations"
))
def _validate_raw_endpoint_structure(self) -> None:
"""Validate structure of raw endpoint definitions."""
if 'endpoints' not in self.raw_endpoints:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message="Missing 'endpoints' field in raw endpoint definition",
path="/endpoints",
suggestion="Provide an 'endpoints' object containing endpoint definitions"
))
return
endpoints = self.raw_endpoints['endpoints']
self.report.total_endpoints = len(endpoints)
def _lint_raw_endpoint(self, path: str, endpoint_data: Dict[str, Any]) -> None:
"""Lint individual raw endpoint definition."""
# Validate path structure
self._validate_path_structure(path)
# Check for required fields
if 'method' not in endpoint_data:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message=f"Missing method field for endpoint {path}",
path=f"/endpoints/{path}/method",
suggestion="Specify HTTP method (GET, POST, PUT, PATCH, DELETE)"
))
return
method = endpoint_data['method'].upper()
if method not in self.http_methods:
self.report.add_issue(LintIssue(
severity='error',
category='structure',
message=f"Invalid HTTP method '{method}' for endpoint {path}",
path=f"/endpoints/{path}/method",
suggestion=f"Use valid HTTP methods: {', '.join(sorted(self.http_methods))}"
))
def generate_json_report(self) -> str:
"""Generate JSON format report."""
issues_by_severity = self.report.get_issues_by_severity()
report_data = {
"summary": {
"total_endpoints": self.report.total_endpoints,
"endpoints_with_issues": self.report.endpoints_with_issues,
"total_issues": len(self.report.issues),
"errors": len(issues_by_severity['error']),
"warnings": len(issues_by_severity['warning']),
"info": len(issues_by_severity['info']),
"score": round(self.report.score, 2)
},
"issues": []
}
for issue in self.report.issues:
report_data["issues"].append({
"severity": issue.severity,
"category": issue.category,
"message": issue.message,
"path": issue.path,
"suggestion": issue.suggestion
})
return json.dumps(report_data, indent=2)
def generate_text_report(self) -> str:
"""Generate human-readable text report."""
issues_by_severity = self.report.get_issues_by_severity()
report_lines = [
"═══════════════════════════════════════════════════════════════",
" API LINTING REPORT",
"═══════════════════════════════════════════════════════════════",
"",
"SUMMARY:",
f" Total Endpoints: {self.report.total_endpoints}",
f" Endpoints with Issues: {self.report.endpoints_with_issues}",
f" Overall Score: {self.report.score:.1f}/100.0",
"",
"ISSUE BREAKDOWN:",
f" 🔴 Errors: {len(issues_by_severity['error'])}",
f" 🟡 Warnings: {len(issues_by_severity['warning'])}",
f" Info: {len(issues_by_severity['info'])}",
"",
]
if not self.report.issues:
report_lines.extend([
"🎉 Congratulations! No issues found in your API specification.",
""
])
else:
# Group issues by category
issues_by_category = {}
for issue in self.report.issues:
if issue.category not in issues_by_category:
issues_by_category[issue.category] = []
issues_by_category[issue.category].append(issue)
for category, issues in issues_by_category.items():
report_lines.append(f"{'' * 60}")
report_lines.append(f"CATEGORY: {category.upper().replace('_', ' ')}")
report_lines.append(f"{'' * 60}")
for issue in issues:
severity_icon = {"error": "🔴", "warning": "🟡", "info": ""}[issue.severity]
report_lines.extend([
f"{severity_icon} {issue.severity.upper()}: {issue.message}",
f" Path: {issue.path}",
])
if issue.suggestion:
report_lines.append(f" 💡 Suggestion: {issue.suggestion}")
report_lines.append("")
# Add scoring breakdown
report_lines.extend([
"═══════════════════════════════════════════════════════════════",
"SCORING DETAILS:",
"═══════════════════════════════════════════════════════════════",
f"Base Score: 100.0",
f"Errors Penalty: -{len(issues_by_severity['error']) * 10} (10 points per error)",
f"Warnings Penalty: -{len(issues_by_severity['warning']) * 3} (3 points per warning)",
f"Info Penalty: -{len(issues_by_severity['info']) * 1} (1 point per info)",
f"Final Score: {self.report.score:.1f}/100.0",
""
])
# Add recommendations based on score
if self.report.score >= 90:
report_lines.append("🏆 Excellent! Your API design follows best practices.")
elif self.report.score >= 80:
report_lines.append("✅ Good API design with minor areas for improvement.")
elif self.report.score >= 70:
report_lines.append("⚠️ Fair API design. Consider addressing warnings and errors.")
elif self.report.score >= 50:
report_lines.append("❌ Poor API design. Multiple issues need attention.")
else:
report_lines.append("🚨 Critical API design issues. Immediate attention required.")
return "\n".join(report_lines)
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Analyze OpenAPI/Swagger specifications for REST conventions and best practices",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python api_linter.py openapi.json
python api_linter.py --format json openapi.json > report.json
python api_linter.py --raw-endpoints endpoints.json
"""
)
parser.add_argument(
'input_file',
help='Input file: OpenAPI/Swagger JSON file or raw endpoints JSON'
)
parser.add_argument(
'--format',
choices=['text', 'json'],
default='text',
help='Output format (default: text)'
)
parser.add_argument(
'--raw-endpoints',
action='store_true',
help='Treat input as raw endpoint definitions instead of OpenAPI spec'
)
parser.add_argument(
'--output',
help='Output file (default: stdout)'
)
args = parser.parse_args()
# Load input file
try:
with open(args.input_file, 'r') as f:
input_data = json.load(f)
except FileNotFoundError:
print(f"Error: Input file '{args.input_file}' not found.", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
return 1
# Initialize linter and run analysis
linter = APILinter()
try:
if args.raw_endpoints:
report = linter.lint_raw_endpoints(input_data)
else:
report = linter.lint_openapi_spec(input_data)
except Exception as e:
print(f"Error during linting: {e}", file=sys.stderr)
return 1
# Generate report
if args.format == 'json':
output = linter.generate_json_report()
else:
output = linter.generate_text_report()
# Write output
if args.output:
try:
with open(args.output, 'w') as f:
f.write(output)
print(f"Report written to {args.output}")
except IOError as e:
print(f"Error writing to '{args.output}': {e}", file=sys.stderr)
return 1
else:
print(output)
# Return appropriate exit code
error_count = len([i for i in report.issues if i.severity == 'error'])
return 1 if error_count > 0 else 0
if __name__ == '__main__':
sys.exit(main())