Files
Leo 612f2a63fc merge: resolve conflict with dev, keep POWERFUL-tier SKILL.md
Kept our SKILL.md (POWERFUL-tier, 669 lines) over the codex-synced version.
Accepted all new files from dev (additional scripts, references, assets).
2026-02-16 13:13:28 +00:00

1661 lines
64 KiB
Python

#!/usr/bin/env python3
"""
API Scorecard - Comprehensive API design quality assessment tool.
This script evaluates API designs across multiple dimensions and generates
a detailed scorecard with letter grades and improvement recommendations.
Scoring Dimensions:
- Consistency (30%): Naming conventions, response patterns, structural consistency
- Documentation (20%): Completeness and clarity of API documentation
- Security (20%): Authentication, authorization, and security best practices
- Usability (15%): Ease of use, discoverability, and developer experience
- Performance (15%): Caching, pagination, and efficiency patterns
Generates letter grades (A-F) with detailed breakdowns and actionable recommendations.
"""
import argparse
import json
import re
import sys
from typing import Any, Dict, List, Optional, Set, Tuple
from dataclasses import dataclass, field
from enum import Enum
import math
class ScoreCategory(Enum):
"""Scoring categories."""
CONSISTENCY = "consistency"
DOCUMENTATION = "documentation"
SECURITY = "security"
USABILITY = "usability"
PERFORMANCE = "performance"
@dataclass
class CategoryScore:
"""Score for a specific category."""
category: ScoreCategory
score: float # 0-100
max_score: float # Usually 100
weight: float # Percentage weight in overall score
issues: List[str] = field(default_factory=list)
recommendations: List[str] = field(default_factory=list)
@property
def letter_grade(self) -> str:
"""Convert score to letter grade."""
if self.score >= 90:
return "A"
elif self.score >= 80:
return "B"
elif self.score >= 70:
return "C"
elif self.score >= 60:
return "D"
else:
return "F"
@property
def weighted_score(self) -> float:
"""Calculate weighted contribution to overall score."""
return (self.score / 100.0) * self.weight
@dataclass
class APIScorecard:
"""Complete API scorecard with all category scores."""
category_scores: Dict[ScoreCategory, CategoryScore] = field(default_factory=dict)
overall_score: float = 0.0
overall_grade: str = "F"
total_endpoints: int = 0
api_info: Dict[str, Any] = field(default_factory=dict)
def calculate_overall_score(self) -> None:
"""Calculate overall weighted score and grade."""
self.overall_score = sum(score.weighted_score for score in self.category_scores.values())
if self.overall_score >= 90:
self.overall_grade = "A"
elif self.overall_score >= 80:
self.overall_grade = "B"
elif self.overall_score >= 70:
self.overall_grade = "C"
elif self.overall_score >= 60:
self.overall_grade = "D"
else:
self.overall_grade = "F"
def get_top_recommendations(self, limit: int = 5) -> List[str]:
"""Get top recommendations across all categories."""
all_recommendations = []
for category_score in self.category_scores.values():
for rec in category_score.recommendations:
all_recommendations.append(f"{category_score.category.value.title()}: {rec}")
# Sort by category weight (highest impact first)
weighted_recs = []
for category_score in sorted(self.category_scores.values(),
key=lambda x: x.weight, reverse=True):
for rec in category_score.recommendations[:2]: # Top 2 per category
weighted_recs.append(f"{category_score.category.value.title()}: {rec}")
return weighted_recs[:limit]
class APIScoringEngine:
"""Main API scoring engine."""
def __init__(self):
self.scorecard = APIScorecard()
self.spec: Optional[Dict] = None
# Regex patterns for validation
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.pascal_case_pattern = re.compile(r'^[A-Z][a-zA-Z0-9]*$')
# HTTP methods
self.http_methods = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'}
# Category weights (must sum to 100)
self.category_weights = {
ScoreCategory.CONSISTENCY: 30.0,
ScoreCategory.DOCUMENTATION: 20.0,
ScoreCategory.SECURITY: 20.0,
ScoreCategory.USABILITY: 15.0,
ScoreCategory.PERFORMANCE: 15.0
}
def score_api(self, spec: Dict[str, Any]) -> APIScorecard:
"""Generate comprehensive API scorecard."""
self.spec = spec
self.scorecard = APIScorecard()
# Extract basic API info
self._extract_api_info()
# Score each category
self._score_consistency()
self._score_documentation()
self._score_security()
self._score_usability()
self._score_performance()
# Calculate overall score
self.scorecard.calculate_overall_score()
return self.scorecard
def _extract_api_info(self) -> None:
"""Extract basic API information."""
info = self.spec.get('info', {})
paths = self.spec.get('paths', {})
self.scorecard.api_info = {
'title': info.get('title', 'Unknown API'),
'version': info.get('version', ''),
'description': info.get('description', ''),
'total_paths': len(paths),
'openapi_version': self.spec.get('openapi', self.spec.get('swagger', ''))
}
# Count total endpoints
endpoint_count = 0
for path_obj in paths.values():
if isinstance(path_obj, dict):
endpoint_count += len([m for m in path_obj.keys()
if m.upper() in self.http_methods])
self.scorecard.total_endpoints = endpoint_count
def _score_consistency(self) -> None:
"""Score API consistency (30% weight)."""
category = ScoreCategory.CONSISTENCY
score = CategoryScore(
category=category,
score=0.0,
max_score=100.0,
weight=self.category_weights[category]
)
consistency_checks = [
self._check_naming_consistency(),
self._check_response_consistency(),
self._check_error_format_consistency(),
self._check_parameter_consistency(),
self._check_url_structure_consistency(),
self._check_http_method_consistency(),
self._check_status_code_consistency()
]
# Average the consistency scores
valid_scores = [s for s in consistency_checks if s is not None]
if valid_scores:
score.score = sum(valid_scores) / len(valid_scores)
# Add specific recommendations based on low scores
if score.score < 70:
score.recommendations.extend([
"Review naming conventions across all endpoints and schemas",
"Standardize response formats and error structures",
"Ensure consistent HTTP method usage patterns"
])
elif score.score < 85:
score.recommendations.extend([
"Minor consistency improvements needed in naming or response formats",
"Consider creating API design guidelines document"
])
self.scorecard.category_scores[category] = score
def _check_naming_consistency(self) -> float:
"""Check naming convention consistency."""
paths = self.spec.get('paths', {})
schemas = self.spec.get('components', {}).get('schemas', {})
total_checks = 0
passed_checks = 0
# Check path naming (should be kebab-case)
for path in paths.keys():
segments = [seg for seg in path.split('/') if seg and not seg.startswith('{')]
for segment in segments:
total_checks += 1
if self.kebab_case_pattern.match(segment) or re.match(r'^v\d+$', segment):
passed_checks += 1
# Check schema naming (should be PascalCase)
for schema_name in schemas.keys():
total_checks += 1
if self.pascal_case_pattern.match(schema_name):
passed_checks += 1
# Check property naming within schemas
for schema in schemas.values():
if isinstance(schema, dict) and 'properties' in schema:
for prop_name in schema['properties'].keys():
total_checks += 1
if self.camel_case_pattern.match(prop_name):
passed_checks += 1
return (passed_checks / total_checks * 100) if total_checks > 0 else 100
def _check_response_consistency(self) -> float:
"""Check response format consistency."""
paths = self.spec.get('paths', {})
response_patterns = []
total_responses = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods or not isinstance(operation, dict):
continue
responses = operation.get('responses', {})
for status_code, response in responses.items():
if not isinstance(response, dict):
continue
total_responses += 1
content = response.get('content', {})
# Analyze response structure
for media_type, media_obj in content.items():
schema = media_obj.get('schema', {})
pattern = self._extract_schema_pattern(schema)
response_patterns.append(pattern)
# Calculate consistency by comparing patterns
if not response_patterns:
return 100
pattern_counts = {}
for pattern in response_patterns:
pattern_key = json.dumps(pattern, sort_keys=True)
pattern_counts[pattern_key] = pattern_counts.get(pattern_key, 0) + 1
# Most common pattern should dominate for good consistency
max_count = max(pattern_counts.values()) if pattern_counts else 0
consistency_ratio = max_count / len(response_patterns) if response_patterns else 1
return consistency_ratio * 100
def _extract_schema_pattern(self, schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract a pattern from a schema for consistency checking."""
if not isinstance(schema, dict):
return {}
pattern = {
'type': schema.get('type'),
'has_properties': 'properties' in schema,
'has_items': 'items' in schema,
'required_count': len(schema.get('required', [])),
'property_count': len(schema.get('properties', {}))
}
return pattern
def _check_error_format_consistency(self) -> float:
"""Check error response format consistency."""
paths = self.spec.get('paths', {})
error_responses = []
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
responses = operation.get('responses', {})
for status_code, response in responses.items():
try:
code_int = int(status_code)
if code_int >= 400: # Error responses
content = response.get('content', {})
for media_type, media_obj in content.items():
schema = media_obj.get('schema', {})
error_responses.append(self._extract_schema_pattern(schema))
except ValueError:
continue
if not error_responses:
return 80 # No error responses defined - somewhat concerning
# Check consistency of error response formats
pattern_counts = {}
for pattern in error_responses:
pattern_key = json.dumps(pattern, sort_keys=True)
pattern_counts[pattern_key] = pattern_counts.get(pattern_key, 0) + 1
max_count = max(pattern_counts.values()) if pattern_counts else 0
consistency_ratio = max_count / len(error_responses) if error_responses else 1
return consistency_ratio * 100
def _check_parameter_consistency(self) -> float:
"""Check parameter naming and usage consistency."""
paths = self.spec.get('paths', {})
query_params = []
path_params = []
header_params = []
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
parameters = operation.get('parameters', [])
for param in parameters:
if not isinstance(param, dict):
continue
param_name = param.get('name', '')
param_in = param.get('in', '')
if param_in == 'query':
query_params.append(param_name)
elif param_in == 'path':
path_params.append(param_name)
elif param_in == 'header':
header_params.append(param_name)
# Check naming consistency for each parameter type
scores = []
# Query parameters should be camelCase or kebab-case
if query_params:
valid_query = sum(1 for p in query_params
if self.camel_case_pattern.match(p) or self.kebab_case_pattern.match(p))
scores.append((valid_query / len(query_params)) * 100)
# Path parameters should be camelCase or kebab-case
if path_params:
valid_path = sum(1 for p in path_params
if self.camel_case_pattern.match(p) or self.kebab_case_pattern.match(p))
scores.append((valid_path / len(path_params)) * 100)
return sum(scores) / len(scores) if scores else 100
def _check_url_structure_consistency(self) -> float:
"""Check URL structure and pattern consistency."""
paths = self.spec.get('paths', {})
total_paths = len(paths)
if total_paths == 0:
return 0
structure_score = 0
# Check for consistent versioning
versioned_paths = 0
for path in paths.keys():
if re.search(r'/v\d+/', path):
versioned_paths += 1
# Either all or none should be versioned for consistency
if versioned_paths == 0 or versioned_paths == total_paths:
structure_score += 25
elif versioned_paths > total_paths * 0.8:
structure_score += 20
# Check for reasonable path depth
reasonable_depth = 0
for path in paths.keys():
segments = [seg for seg in path.split('/') if seg]
if 2 <= len(segments) <= 5: # Reasonable depth
reasonable_depth += 1
structure_score += (reasonable_depth / total_paths) * 25
# Check for RESTful resource patterns
restful_patterns = 0
for path in paths.keys():
# Look for patterns like /resources/{id} or /resources
if re.match(r'^/[a-z-]+(/\{[^}]+\})?(/[a-z-]+)*$', path):
restful_patterns += 1
structure_score += (restful_patterns / total_paths) * 30
# Check for consistent trailing slash usage
with_slash = sum(1 for path in paths.keys() if path.endswith('/'))
without_slash = total_paths - with_slash
# Either all or none should have trailing slashes
if with_slash == 0 or without_slash == 0:
structure_score += 20
elif min(with_slash, without_slash) < total_paths * 0.1:
structure_score += 15
return min(structure_score, 100)
def _check_http_method_consistency(self) -> float:
"""Check HTTP method usage consistency."""
paths = self.spec.get('paths', {})
method_usage = {}
total_operations = 0
for path, path_obj in paths.items():
if not isinstance(path_obj, dict):
continue
for method in path_obj.keys():
if method.upper() in self.http_methods:
method_upper = method.upper()
total_operations += 1
# Analyze method usage patterns
if method_upper not in method_usage:
method_usage[method_upper] = {'count': 0, 'appropriate': 0}
method_usage[method_upper]['count'] += 1
# Check if method usage seems appropriate
if self._is_method_usage_appropriate(path, method_upper, path_obj[method]):
method_usage[method_upper]['appropriate'] += 1
if total_operations == 0:
return 0
# Calculate appropriateness score
total_appropriate = sum(data['appropriate'] for data in method_usage.values())
return (total_appropriate / total_operations) * 100
def _is_method_usage_appropriate(self, path: str, method: str, operation: Dict) -> bool:
"""Check if HTTP method usage is appropriate for the endpoint."""
# Simple heuristics for method appropriateness
has_request_body = 'requestBody' in operation
path_has_id = '{' in path and '}' in path
if method == 'GET':
return not has_request_body # GET should not have body
elif method == 'POST':
return not path_has_id # POST typically for collections
elif method == 'PUT':
return path_has_id and has_request_body # PUT for specific resources
elif method == 'PATCH':
return path_has_id # PATCH for specific resources
elif method == 'DELETE':
return path_has_id # DELETE for specific resources
return True # Default to appropriate for other methods
def _check_status_code_consistency(self) -> float:
"""Check HTTP status code usage consistency."""
paths = self.spec.get('paths', {})
method_status_patterns = {}
total_operations = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
responses = operation.get('responses', {})
status_codes = set(responses.keys())
if method.upper() not in method_status_patterns:
method_status_patterns[method.upper()] = []
method_status_patterns[method.upper()].append(status_codes)
if total_operations == 0:
return 0
# Check consistency within each method type
consistency_scores = []
for method, status_patterns in method_status_patterns.items():
if not status_patterns:
continue
# Find common status codes for this method
all_codes = set()
for pattern in status_patterns:
all_codes.update(pattern)
# Calculate how many operations use the most common codes
code_usage = {}
for code in all_codes:
code_usage[code] = sum(1 for pattern in status_patterns if code in pattern)
# Score based on consistency of common status codes
if status_patterns:
avg_consistency = sum(
len([code for code in pattern if code_usage.get(code, 0) > len(status_patterns) * 0.5])
for pattern in status_patterns
) / len(status_patterns)
method_consistency = min(avg_consistency / 3.0 * 100, 100) # Expect ~3 common codes
consistency_scores.append(method_consistency)
return sum(consistency_scores) / len(consistency_scores) if consistency_scores else 100
def _score_documentation(self) -> None:
"""Score API documentation quality (20% weight)."""
category = ScoreCategory.DOCUMENTATION
score = CategoryScore(
category=category,
score=0.0,
max_score=100.0,
weight=self.category_weights[category]
)
documentation_checks = [
self._check_api_level_documentation(),
self._check_endpoint_documentation(),
self._check_schema_documentation(),
self._check_parameter_documentation(),
self._check_response_documentation(),
self._check_example_coverage()
]
valid_scores = [s for s in documentation_checks if s is not None]
if valid_scores:
score.score = sum(valid_scores) / len(valid_scores)
# Add recommendations based on score
if score.score < 60:
score.recommendations.extend([
"Add comprehensive descriptions to all API components",
"Include examples for complex operations and schemas",
"Document all parameters and response fields"
])
elif score.score < 80:
score.recommendations.extend([
"Improve documentation completeness for some endpoints",
"Add more examples to enhance developer experience"
])
self.scorecard.category_scores[category] = score
def _check_api_level_documentation(self) -> float:
"""Check API-level documentation completeness."""
info = self.spec.get('info', {})
score = 0
# Required fields
if info.get('title'):
score += 20
if info.get('version'):
score += 20
if info.get('description') and len(info['description']) > 20:
score += 30
# Optional but recommended fields
if info.get('contact'):
score += 15
if info.get('license'):
score += 15
return score
def _check_endpoint_documentation(self) -> float:
"""Check endpoint-level documentation completeness."""
paths = self.spec.get('paths', {})
total_operations = 0
documented_operations = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
doc_score = 0
if operation.get('summary'):
doc_score += 1
if operation.get('description') and len(operation['description']) > 20:
doc_score += 1
if operation.get('operationId'):
doc_score += 1
# Consider it documented if it has at least 2/3 elements
if doc_score >= 2:
documented_operations += 1
return (documented_operations / total_operations * 100) if total_operations > 0 else 100
def _check_schema_documentation(self) -> float:
"""Check schema documentation completeness."""
schemas = self.spec.get('components', {}).get('schemas', {})
if not schemas:
return 80 # No schemas to document
total_schemas = len(schemas)
documented_schemas = 0
for schema_name, schema in schemas.items():
if not isinstance(schema, dict):
continue
doc_elements = 0
# Schema-level description
if schema.get('description'):
doc_elements += 1
# Property descriptions
properties = schema.get('properties', {})
if properties:
described_props = sum(1 for prop in properties.values()
if isinstance(prop, dict) and prop.get('description'))
if described_props > len(properties) * 0.5: # At least 50% documented
doc_elements += 1
# Examples
if schema.get('example') or any(
isinstance(prop, dict) and prop.get('example')
for prop in properties.values()
):
doc_elements += 1
if doc_elements >= 2:
documented_schemas += 1
return (documented_schemas / total_schemas * 100) if total_schemas > 0 else 100
def _check_parameter_documentation(self) -> float:
"""Check parameter documentation completeness."""
paths = self.spec.get('paths', {})
total_params = 0
documented_params = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
parameters = operation.get('parameters', [])
for param in parameters:
if not isinstance(param, dict):
continue
total_params += 1
doc_score = 0
if param.get('description'):
doc_score += 1
if param.get('example') or (param.get('schema', {}).get('example')):
doc_score += 1
if doc_score >= 1: # At least description
documented_params += 1
return (documented_params / total_params * 100) if total_params > 0 else 100
def _check_response_documentation(self) -> float:
"""Check response documentation completeness."""
paths = self.spec.get('paths', {})
total_responses = 0
documented_responses = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
responses = operation.get('responses', {})
for status_code, response in responses.items():
if not isinstance(response, dict):
continue
total_responses += 1
if response.get('description'):
documented_responses += 1
return (documented_responses / total_responses * 100) if total_responses > 0 else 100
def _check_example_coverage(self) -> float:
"""Check example coverage across the API."""
paths = self.spec.get('paths', {})
schemas = self.spec.get('components', {}).get('schemas', {})
# Check examples in operations
total_operations = 0
operations_with_examples = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
has_example = False
# Check request body examples
request_body = operation.get('requestBody', {})
if self._has_examples(request_body.get('content', {})):
has_example = True
# Check response examples
responses = operation.get('responses', {})
for response in responses.values():
if isinstance(response, dict) and self._has_examples(response.get('content', {})):
has_example = True
break
if has_example:
operations_with_examples += 1
# Check examples in schemas
total_schemas = len(schemas)
schemas_with_examples = 0
for schema in schemas.values():
if isinstance(schema, dict) and self._schema_has_examples(schema):
schemas_with_examples += 1
# Combine scores
operation_score = (operations_with_examples / total_operations * 100) if total_operations > 0 else 100
schema_score = (schemas_with_examples / total_schemas * 100) if total_schemas > 0 else 100
return (operation_score + schema_score) / 2
def _has_examples(self, content: Dict[str, Any]) -> bool:
"""Check if content has examples."""
for media_type, media_obj in content.items():
if isinstance(media_obj, dict):
if media_obj.get('example') or media_obj.get('examples'):
return True
return False
def _schema_has_examples(self, schema: Dict[str, Any]) -> bool:
"""Check if schema has examples."""
if schema.get('example'):
return True
properties = schema.get('properties', {})
for prop in properties.values():
if isinstance(prop, dict) and prop.get('example'):
return True
return False
def _score_security(self) -> None:
"""Score API security implementation (20% weight)."""
category = ScoreCategory.SECURITY
score = CategoryScore(
category=category,
score=0.0,
max_score=100.0,
weight=self.category_weights[category]
)
security_checks = [
self._check_security_schemes(),
self._check_security_requirements(),
self._check_https_usage(),
self._check_authentication_patterns(),
self._check_sensitive_data_handling()
]
valid_scores = [s for s in security_checks if s is not None]
if valid_scores:
score.score = sum(valid_scores) / len(valid_scores)
# Add recommendations
if score.score < 50:
score.recommendations.extend([
"Implement comprehensive security schemes (OAuth2, API keys, etc.)",
"Ensure all endpoints have appropriate security requirements",
"Add input validation and rate limiting patterns"
])
elif score.score < 80:
score.recommendations.extend([
"Review security coverage for all endpoints",
"Consider additional security measures for sensitive operations"
])
self.scorecard.category_scores[category] = score
def _check_security_schemes(self) -> float:
"""Check security scheme definitions."""
security_schemes = self.spec.get('components', {}).get('securitySchemes', {})
if not security_schemes:
return 20 # Very low score for no security
score = 40 # Base score for having security schemes
scheme_types = set()
for scheme in security_schemes.values():
if isinstance(scheme, dict):
scheme_type = scheme.get('type')
scheme_types.add(scheme_type)
# Bonus for modern security schemes
if 'oauth2' in scheme_types:
score += 30
if 'apiKey' in scheme_types:
score += 15
if 'http' in scheme_types:
score += 15
return min(score, 100)
def _check_security_requirements(self) -> float:
"""Check security requirement coverage."""
paths = self.spec.get('paths', {})
global_security = self.spec.get('security', [])
total_operations = 0
secured_operations = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
# Check if operation has security requirements
operation_security = operation.get('security')
if operation_security is not None:
secured_operations += 1
elif global_security:
secured_operations += 1
return (secured_operations / total_operations * 100) if total_operations > 0 else 0
def _check_https_usage(self) -> float:
"""Check HTTPS enforcement."""
servers = self.spec.get('servers', [])
if not servers:
return 60 # No servers defined - assume HTTPS
https_servers = 0
for server in servers:
if isinstance(server, dict):
url = server.get('url', '')
if url.startswith('https://') or not url.startswith('http://'):
https_servers += 1
return (https_servers / len(servers) * 100) if servers else 100
def _check_authentication_patterns(self) -> float:
"""Check authentication pattern quality."""
security_schemes = self.spec.get('components', {}).get('securitySchemes', {})
if not security_schemes:
return 0
pattern_scores = []
for scheme in security_schemes.values():
if not isinstance(scheme, dict):
continue
scheme_type = scheme.get('type', '').lower()
if scheme_type == 'oauth2':
# OAuth2 is highly recommended
flows = scheme.get('flows', {})
if flows:
pattern_scores.append(95)
else:
pattern_scores.append(80)
elif scheme_type == 'http':
scheme_scheme = scheme.get('scheme', '').lower()
if scheme_scheme == 'bearer':
pattern_scores.append(85)
elif scheme_scheme == 'basic':
pattern_scores.append(60) # Less secure
else:
pattern_scores.append(70)
elif scheme_type == 'apikey':
location = scheme.get('in', '').lower()
if location == 'header':
pattern_scores.append(75)
else:
pattern_scores.append(60) # Query/cookie less secure
else:
pattern_scores.append(50) # Unknown scheme
return sum(pattern_scores) / len(pattern_scores) if pattern_scores else 0
def _check_sensitive_data_handling(self) -> float:
"""Check sensitive data handling patterns."""
# This is a simplified check - in reality would need more sophisticated analysis
schemas = self.spec.get('components', {}).get('schemas', {})
score = 80 # Default good score
# Look for potential sensitive fields without proper handling
sensitive_field_names = {'password', 'secret', 'token', 'key', 'ssn', 'credit_card'}
for schema in schemas.values():
if not isinstance(schema, dict):
continue
properties = schema.get('properties', {})
for prop_name, prop_def in properties.items():
if not isinstance(prop_def, dict):
continue
# Check for sensitive field names
if any(sensitive in prop_name.lower() for sensitive in sensitive_field_names):
# Check if it's marked as sensitive (writeOnly, format: password, etc.)
if not (prop_def.get('writeOnly') or
prop_def.get('format') == 'password' or
'password' in prop_def.get('description', '').lower()):
score -= 10 # Penalty for exposed sensitive field
return max(score, 0)
def _score_usability(self) -> None:
"""Score API usability and developer experience (15% weight)."""
category = ScoreCategory.USABILITY
score = CategoryScore(
category=category,
score=0.0,
max_score=100.0,
weight=self.category_weights[category]
)
usability_checks = [
self._check_discoverability(),
self._check_error_handling(),
self._check_filtering_and_searching(),
self._check_resource_relationships(),
self._check_developer_experience()
]
valid_scores = [s for s in usability_checks if s is not None]
if valid_scores:
score.score = sum(valid_scores) / len(valid_scores)
# Add recommendations
if score.score < 60:
score.recommendations.extend([
"Improve error messages with actionable guidance",
"Add filtering and search capabilities to list endpoints",
"Enhance resource discoverability with better linking"
])
elif score.score < 80:
score.recommendations.extend([
"Consider adding HATEOAS links for better discoverability",
"Enhance developer experience with better examples"
])
self.scorecard.category_scores[category] = score
def _check_discoverability(self) -> float:
"""Check API discoverability features."""
paths = self.spec.get('paths', {})
# Look for root/discovery endpoints
has_root = '/' in paths or any(path == '/api' or path.startswith('/api/') for path in paths)
# Look for HATEOAS patterns in responses
hateoas_score = 0
total_responses = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
responses = operation.get('responses', {})
for response in responses.values():
if not isinstance(response, dict):
continue
total_responses += 1
# Look for link-like properties in response schemas
content = response.get('content', {})
for media_obj in content.values():
schema = media_obj.get('schema', {})
if self._has_link_properties(schema):
hateoas_score += 1
break
discovery_score = 50 if has_root else 30
if total_responses > 0:
hateoas_ratio = hateoas_score / total_responses
discovery_score += hateoas_ratio * 50
return min(discovery_score, 100)
def _has_link_properties(self, schema: Dict[str, Any]) -> bool:
"""Check if schema has link-like properties."""
if not isinstance(schema, dict):
return False
properties = schema.get('properties', {})
link_indicators = {'links', '_links', 'href', 'url', 'self', 'next', 'prev'}
return any(prop_name.lower() in link_indicators for prop_name in properties.keys())
def _check_error_handling(self) -> float:
"""Check error handling quality."""
paths = self.spec.get('paths', {})
total_operations = 0
operations_with_errors = 0
detailed_error_responses = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
responses = operation.get('responses', {})
# Check for error responses
has_error_responses = any(
status_code.startswith('4') or status_code.startswith('5')
for status_code in responses.keys()
)
if has_error_responses:
operations_with_errors += 1
# Check for detailed error schemas
for status_code, response in responses.items():
if (status_code.startswith('4') or status_code.startswith('5')) and isinstance(response, dict):
content = response.get('content', {})
for media_obj in content.values():
schema = media_obj.get('schema', {})
if self._has_detailed_error_schema(schema):
detailed_error_responses += 1
break
break
if total_operations == 0:
return 0
error_coverage = (operations_with_errors / total_operations) * 60
error_detail = (detailed_error_responses / operations_with_errors * 40) if operations_with_errors > 0 else 0
return error_coverage + error_detail
def _has_detailed_error_schema(self, schema: Dict[str, Any]) -> bool:
"""Check if error schema has detailed information."""
if not isinstance(schema, dict):
return False
properties = schema.get('properties', {})
error_fields = {'error', 'message', 'details', 'code', 'timestamp'}
matching_fields = sum(1 for field in error_fields if field in properties)
return matching_fields >= 2 # At least 2 standard error fields
def _check_filtering_and_searching(self) -> float:
"""Check filtering and search capabilities."""
paths = self.spec.get('paths', {})
collection_endpoints = 0
endpoints_with_filtering = 0
for path, path_obj in paths.items():
if not isinstance(path_obj, dict):
continue
# Identify collection endpoints (no path parameters)
if '{' not in path:
get_operation = path_obj.get('get')
if get_operation:
collection_endpoints += 1
# Check for filtering/search parameters
parameters = get_operation.get('parameters', [])
filter_params = {'filter', 'search', 'q', 'query', 'limit', 'page', 'offset'}
has_filtering = any(
isinstance(param, dict) and param.get('name', '').lower() in filter_params
for param in parameters
)
if has_filtering:
endpoints_with_filtering += 1
return (endpoints_with_filtering / collection_endpoints * 100) if collection_endpoints > 0 else 100
def _check_resource_relationships(self) -> float:
"""Check resource relationship handling."""
paths = self.spec.get('paths', {})
schemas = self.spec.get('components', {}).get('schemas', {})
# Look for nested resource patterns
nested_resources = 0
total_resource_paths = 0
for path in paths.keys():
# Skip root paths
if path.count('/') >= 3: # e.g., /api/users/123/orders
total_resource_paths += 1
if '{' in path:
nested_resources += 1
# Look for relationship fields in schemas
schemas_with_relations = 0
for schema in schemas.values():
if not isinstance(schema, dict):
continue
properties = schema.get('properties', {})
relation_indicators = {'id', '_id', 'ref', 'link', 'relationship'}
has_relations = any(
any(indicator in prop_name.lower() for indicator in relation_indicators)
for prop_name in properties.keys()
)
if has_relations:
schemas_with_relations += 1
nested_score = (nested_resources / total_resource_paths * 50) if total_resource_paths > 0 else 25
schema_score = (schemas_with_relations / len(schemas) * 50) if schemas else 25
return nested_score + schema_score
def _check_developer_experience(self) -> float:
"""Check overall developer experience factors."""
# This is a composite score based on various DX factors
factors = []
# Factor 1: Consistent response structure
factors.append(self._check_response_consistency())
# Factor 2: Clear operation IDs
paths = self.spec.get('paths', {})
total_operations = 0
operations_with_ids = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method, operation in path_obj.items():
if method.upper() not in self.http_methods:
continue
total_operations += 1
if isinstance(operation, dict) and operation.get('operationId'):
operations_with_ids += 1
operation_id_score = (operations_with_ids / total_operations * 100) if total_operations > 0 else 100
factors.append(operation_id_score)
# Factor 3: Reasonable path complexity
avg_path_complexity = 0
if paths:
complexities = []
for path in paths.keys():
segments = [seg for seg in path.split('/') if seg]
complexities.append(len(segments))
avg_complexity = sum(complexities) / len(complexities)
# Optimal complexity is 3-4 segments
if 3 <= avg_complexity <= 4:
avg_path_complexity = 100
elif 2 <= avg_complexity <= 5:
avg_path_complexity = 80
else:
avg_path_complexity = 60
factors.append(avg_path_complexity)
return sum(factors) / len(factors) if factors else 0
def _score_performance(self) -> None:
"""Score API performance patterns (15% weight)."""
category = ScoreCategory.PERFORMANCE
score = CategoryScore(
category=category,
score=0.0,
max_score=100.0,
weight=self.category_weights[category]
)
performance_checks = [
self._check_caching_headers(),
self._check_pagination_patterns(),
self._check_compression_support(),
self._check_efficiency_patterns(),
self._check_batch_operations()
]
valid_scores = [s for s in performance_checks if s is not None]
if valid_scores:
score.score = sum(valid_scores) / len(valid_scores)
# Add recommendations
if score.score < 60:
score.recommendations.extend([
"Implement pagination for list endpoints",
"Add caching headers for cacheable responses",
"Consider batch operations for bulk updates"
])
elif score.score < 80:
score.recommendations.extend([
"Review caching strategies for better performance",
"Consider field selection parameters for large responses"
])
self.scorecard.category_scores[category] = score
def _check_caching_headers(self) -> float:
"""Check caching header implementation."""
paths = self.spec.get('paths', {})
get_operations = 0
cacheable_operations = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
get_operation = path_obj.get('get')
if get_operation and isinstance(get_operation, dict):
get_operations += 1
# Check for caching-related headers in responses
responses = get_operation.get('responses', {})
for response in responses.values():
if not isinstance(response, dict):
continue
headers = response.get('headers', {})
cache_headers = {'cache-control', 'etag', 'last-modified', 'expires'}
if any(header.lower() in cache_headers for header in headers.keys()):
cacheable_operations += 1
break
return (cacheable_operations / get_operations * 100) if get_operations > 0 else 50
def _check_pagination_patterns(self) -> float:
"""Check pagination implementation."""
paths = self.spec.get('paths', {})
collection_endpoints = 0
paginated_endpoints = 0
for path, path_obj in paths.items():
if not isinstance(path_obj, dict):
continue
# Identify collection endpoints
if '{' not in path: # No path parameters = collection
get_operation = path_obj.get('get')
if get_operation and isinstance(get_operation, dict):
collection_endpoints += 1
# Check for pagination parameters
parameters = get_operation.get('parameters', [])
pagination_params = {'limit', 'offset', 'page', 'pagesize', 'per_page', 'cursor'}
has_pagination = any(
isinstance(param, dict) and param.get('name', '').lower() in pagination_params
for param in parameters
)
if has_pagination:
paginated_endpoints += 1
return (paginated_endpoints / collection_endpoints * 100) if collection_endpoints > 0 else 100
def _check_compression_support(self) -> float:
"""Check compression support indicators."""
# This is speculative - OpenAPI doesn't directly specify compression
# Look for indicators that compression is considered
servers = self.spec.get('servers', [])
# Check if any server descriptions mention compression
compression_mentions = 0
for server in servers:
if isinstance(server, dict):
description = server.get('description', '').lower()
if any(term in description for term in ['gzip', 'compress', 'deflate']):
compression_mentions += 1
# Base score - assume compression is handled at server level
base_score = 70
if compression_mentions > 0:
return min(base_score + (compression_mentions * 10), 100)
return base_score
def _check_efficiency_patterns(self) -> float:
"""Check efficiency patterns like field selection."""
paths = self.spec.get('paths', {})
total_get_operations = 0
operations_with_selection = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
get_operation = path_obj.get('get')
if get_operation and isinstance(get_operation, dict):
total_get_operations += 1
# Check for field selection parameters
parameters = get_operation.get('parameters', [])
selection_params = {'fields', 'select', 'include', 'exclude'}
has_selection = any(
isinstance(param, dict) and param.get('name', '').lower() in selection_params
for param in parameters
)
if has_selection:
operations_with_selection += 1
return (operations_with_selection / total_get_operations * 100) if total_get_operations > 0 else 60
def _check_batch_operations(self) -> float:
"""Check for batch operation support."""
paths = self.spec.get('paths', {})
# Look for batch endpoints
batch_indicators = ['batch', 'bulk', 'multi']
batch_endpoints = 0
for path in paths.keys():
if any(indicator in path.lower() for indicator in batch_indicators):
batch_endpoints += 1
# Look for array-based request bodies (indicating batch operations)
array_operations = 0
total_post_put_operations = 0
for path_obj in paths.values():
if not isinstance(path_obj, dict):
continue
for method in ['post', 'put', 'patch']:
operation = path_obj.get(method)
if operation and isinstance(operation, dict):
total_post_put_operations += 1
request_body = operation.get('requestBody', {})
content = request_body.get('content', {})
for media_obj in content.values():
schema = media_obj.get('schema', {})
if schema.get('type') == 'array':
array_operations += 1
break
# Score based on presence of batch patterns
batch_score = min(batch_endpoints * 20, 60) # Up to 60 points for explicit batch endpoints
if total_post_put_operations > 0:
array_score = (array_operations / total_post_put_operations) * 40
batch_score += array_score
return min(batch_score, 100)
def generate_json_report(self) -> str:
"""Generate JSON format scorecard."""
report_data = {
"overall": {
"score": round(self.scorecard.overall_score, 2),
"grade": self.scorecard.overall_grade,
"totalEndpoints": self.scorecard.total_endpoints
},
"api_info": self.scorecard.api_info,
"categories": {},
"topRecommendations": self.scorecard.get_top_recommendations()
}
for category, score in self.scorecard.category_scores.items():
report_data["categories"][category.value] = {
"score": round(score.score, 2),
"grade": score.letter_grade,
"weight": score.weight,
"weightedScore": round(score.weighted_score, 2),
"issues": score.issues,
"recommendations": score.recommendations
}
return json.dumps(report_data, indent=2)
def generate_text_report(self) -> str:
"""Generate human-readable scorecard report."""
lines = [
"═══════════════════════════════════════════════════════════════",
" API DESIGN SCORECARD",
"═══════════════════════════════════════════════════════════════",
f"API: {self.scorecard.api_info.get('title', 'Unknown')}",
f"Version: {self.scorecard.api_info.get('version', 'Unknown')}",
f"Total Endpoints: {self.scorecard.total_endpoints}",
"",
f"🏆 OVERALL GRADE: {self.scorecard.overall_grade} ({self.scorecard.overall_score:.1f}/100.0)",
"",
"═══════════════════════════════════════════════════════════════",
"DETAILED BREAKDOWN:",
"═══════════════════════════════════════════════════════════════"
]
# Sort categories by weight (most important first)
sorted_categories = sorted(
self.scorecard.category_scores.items(),
key=lambda x: x[1].weight,
reverse=True
)
for category, score in sorted_categories:
category_name = category.value.title().replace('_', ' ')
lines.extend([
"",
f"📊 {category_name.upper()} - Grade: {score.letter_grade} ({score.score:.1f}/100)",
f" Weight: {score.weight}% | Contribution: {score.weighted_score:.1f} points",
" " + "" * 50
])
if score.recommendations:
lines.append(" 💡 Recommendations:")
for rec in score.recommendations[:3]: # Top 3 recommendations
lines.append(f"{rec}")
else:
lines.append(" ✅ No specific recommendations - performing well!")
# Overall assessment
lines.extend([
"",
"═══════════════════════════════════════════════════════════════",
"OVERALL ASSESSMENT:",
"═══════════════════════════════════════════════════════════════"
])
if self.scorecard.overall_grade == "A":
lines.extend([
"🏆 EXCELLENT! Your API demonstrates outstanding design quality.",
" Continue following these best practices and consider sharing",
" your approach as a reference for other teams."
])
elif self.scorecard.overall_grade == "B":
lines.extend([
"✅ GOOD! Your API follows most best practices with room for",
" minor improvements. Focus on the recommendations above",
" to achieve excellence."
])
elif self.scorecard.overall_grade == "C":
lines.extend([
"⚠️ FAIR! Your API has a solid foundation but several areas",
" need improvement. Prioritize the high-weight categories",
" for maximum impact."
])
elif self.scorecard.overall_grade == "D":
lines.extend([
"❌ NEEDS IMPROVEMENT! Your API has significant issues that",
" may impact developer experience and maintainability.",
" Focus on consistency and documentation first."
])
else: # Grade F
lines.extend([
"🚨 CRITICAL ISSUES! Your API requires major redesign to meet",
" basic quality standards. Consider comprehensive review",
" of design principles and best practices."
])
# Top recommendations
top_recs = self.scorecard.get_top_recommendations(3)
if top_recs:
lines.extend([
"",
"🎯 TOP PRIORITY RECOMMENDATIONS:",
""
])
for i, rec in enumerate(top_recs, 1):
lines.append(f" {i}. {rec}")
lines.extend([
"",
"═══════════════════════════════════════════════════════════════",
f"Generated by API Scorecard Tool | Score: {self.scorecard.overall_grade} ({self.scorecard.overall_score:.1f}%)",
"═══════════════════════════════════════════════════════════════"
])
return "\n".join(lines)
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Generate comprehensive API design quality scorecard",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python api_scorecard.py openapi.json
python api_scorecard.py --format json openapi.json > scorecard.json
python api_scorecard.py --output scorecard.txt openapi.json
"""
)
parser.add_argument(
'spec_file',
help='OpenAPI/Swagger specification file (JSON format)'
)
parser.add_argument(
'--format',
choices=['text', 'json'],
default='text',
help='Output format (default: text)'
)
parser.add_argument(
'--output',
help='Output file (default: stdout)'
)
parser.add_argument(
'--min-grade',
choices=['A', 'B', 'C', 'D', 'F'],
help='Exit with code 1 if grade is below minimum'
)
args = parser.parse_args()
# Load specification file
try:
with open(args.spec_file, 'r') as f:
spec = json.load(f)
except FileNotFoundError:
print(f"Error: Specification file '{args.spec_file}' not found.", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in '{args.spec_file}': {e}", file=sys.stderr)
return 1
# Initialize scoring engine and generate scorecard
engine = APIScoringEngine()
try:
scorecard = engine.score_api(spec)
except Exception as e:
print(f"Error during scoring: {e}", file=sys.stderr)
return 1
# Generate report
if args.format == 'json':
output = engine.generate_json_report()
else:
output = engine.generate_text_report()
# Write output
if args.output:
try:
with open(args.output, 'w') as f:
f.write(output)
print(f"Scorecard written to {args.output}")
except IOError as e:
print(f"Error writing to '{args.output}': {e}", file=sys.stderr)
return 1
else:
print(output)
# Check minimum grade requirement
if args.min_grade:
grade_order = ['F', 'D', 'C', 'B', 'A']
current_grade_index = grade_order.index(scorecard.overall_grade)
min_grade_index = grade_order.index(args.min_grade)
if current_grade_index < min_grade_index:
print(f"Grade {scorecard.overall_grade} is below minimum required grade {args.min_grade}", file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())