Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:08 +08:00
commit 8f22ddf339
295 changed files with 59710 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
# api.validate
## Overview
**api.validate** validates OpenAPI and AsyncAPI specifications against enterprise guidelines, with built-in support for Zalando RESTful API Guidelines.
## Purpose
Ensure API specifications meet enterprise standards:
- Validate OpenAPI 3.x specifications
- Validate AsyncAPI 3.x specifications
- Check compliance with Zalando guidelines
- Detect common API design mistakes
- Provide actionable suggestions for fixes
## Usage
### Basic Usage
```bash
python skills/api.validate/api_validate.py <spec_path> [guideline_set] [options]
```
### Parameters
| Parameter | Required | Description | Default |
|-----------|----------|-------------|---------|
| `spec_path` | Yes | Path to API spec file | - |
| `guideline_set` | No | Guidelines to validate against | `zalando` |
| `--strict` | No | Warnings become errors | `false` |
| `--format` | No | Output format (json, human) | `json` |
### Guideline Sets
| Guideline | Status | Description |
|-----------|--------|-------------|
| `zalando` | ✅ Supported | Zalando RESTful API Guidelines |
| `google` | 🚧 Planned | Google API Design Guide |
| `microsoft` | 🚧 Planned | Microsoft REST API Guidelines |
## Examples
### Example 1: Validate OpenAPI Spec
```bash
python skills/api.validate/api_validate.py specs/user-service.openapi.yaml zalando
```
**Output** (JSON format):
```json
{
"status": "success",
"data": {
"valid": false,
"errors": [
{
"rule_id": "MUST_001",
"message": "Missing required field 'info.x-api-id'",
"severity": "error",
"path": "info.x-api-id",
"suggestion": "Add a UUID to uniquely identify this API"
}
],
"warnings": [
{
"rule_id": "SHOULD_001",
"message": "Missing 'info.contact'",
"severity": "warning",
"path": "info.contact"
}
],
"spec_path": "specs/user-service.openapi.yaml",
"spec_type": "openapi",
"guideline_set": "zalando"
}
}
```
### Example 2: Human-Readable Output
```bash
python skills/api.validate/api_validate.py \
specs/user-service.openapi.yaml \
zalando \
--format=human
```
**Output**:
```
============================================================
API Validation Report
============================================================
Spec: specs/user-service.openapi.yaml
Type: OPENAPI
Guidelines: zalando
============================================================
❌ ERRORS (1):
[MUST_001] Missing required field 'info.x-api-id'
Path: info.x-api-id
💡 Add a UUID to uniquely identify this API
⚠️ WARNINGS (1):
[SHOULD_001] Missing 'info.contact'
Path: info.contact
💡 Add contact information
============================================================
❌ Validation FAILED
============================================================
```
### Example 3: Strict Mode
```bash
python skills/api.validate/api_validate.py \
specs/user-service.openapi.yaml \
zalando \
--strict
```
In strict mode, warnings are treated as errors. Use for CI/CD pipelines where you want zero tolerance for issues.
### Example 4: Validate AsyncAPI Spec
```bash
python skills/api.validate/api_validate.py specs/user-events.asyncapi.yaml
```
## Validation Rules
### Zalando Guidelines (OpenAPI)
#### MUST Rules (Errors)
| Rule | Description | Example Fix |
|------|-------------|-------------|
| **MUST_001** | Required `x-api-id` metadata | `x-api-id: 'd0184f38-b98d-11e7-9c56-68f728c1ba70'` |
| **MUST_002** | Required `x-audience` metadata | `x-audience: 'company-internal'` |
| **MUST_003** | Path naming conventions | Use lowercase kebab-case or snake_case |
| **MUST_004** | Property naming (snake_case) | `userId``user_id` |
| **MUST_005** | HTTP method usage | GET should not have requestBody |
#### SHOULD Rules (Warnings)
| Rule | Description | Example Fix |
|------|-------------|-------------|
| **SHOULD_001** | Contact information | Add `info.contact` with team details |
| **SHOULD_002** | POST returns 201 | Add 201 response to POST operations |
| **SHOULD_003** | Document 400 errors | Add 400 Bad Request response |
| **SHOULD_004** | Document 500 errors | Add 500 Internal Error response |
| **SHOULD_005** | 201 includes Location header | Add Location header to 201 responses |
| **SHOULD_006** | Problem schema for errors | Define RFC 7807 Problem schema |
| **SHOULD_007** | Error responses use application/problem+json | Use correct content type |
| **SHOULD_008** | X-Flow-ID header | Add request tracing header |
| **SHOULD_009** | Security schemes defined | Add authentication schemes |
### AsyncAPI Guidelines
| Rule | Description |
|------|-------------|
| **ASYNCAPI_001** | Required `info` field |
| **ASYNCAPI_002** | Required `channels` field |
| **ASYNCAPI_003** | Version check (recommend 3.x) |
## Integration with Hooks
### Automatic Validation on File Edit
```bash
# Create hook using hook.define
python skills/hook.define/hook_define.py \
on_file_edit \
"python betty/skills/api.validate/api_validate.py {file_path} zalando" \
--pattern="*.openapi.yaml" \
--blocking=true \
--timeout=10000 \
--description="Validate OpenAPI specs on edit"
```
**Result**: Every time you edit a `*.openapi.yaml` file, it's automatically validated. If validation fails, the edit is blocked.
### Validation on Commit
```bash
python skills/hook.define/hook_define.py \
on_commit \
"python betty/skills/api.validate/api_validate.py {file_path} zalando --strict" \
--pattern="specs/**/*.yaml" \
--blocking=true \
--description="Prevent commits with invalid specs"
```
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | Validation passed (no errors) |
| `1` | Validation failed (has errors) or execution error |
## Common Validation Errors
### Missing x-api-id
**Error**:
```
Missing required field 'info.x-api-id'
```
**Fix**:
```yaml
info:
title: My API
version: 1.0.0
x-api-id: d0184f38-b98d-11e7-9c56-68f728c1ba70 # Add this
```
### Wrong Property Naming
**Error**:
```
Property 'userId' should use snake_case
```
**Fix**:
```yaml
# Before
properties:
userId:
type: string
# After
properties:
user_id:
type: string
```
### Missing Error Responses
**Error**:
```
GET operation should document 400 (Bad Request) response
```
**Fix**:
```yaml
responses:
'200':
description: Success
'400': # Add this
$ref: '#/components/responses/BadRequest'
'500': # Add this
$ref: '#/components/responses/InternalError'
```
### Wrong Content Type for Errors
**Error**:
```
Error response 400 should use 'application/problem+json'
```
**Fix**:
```yaml
'400':
description: Bad request
content:
application/problem+json: # Not application/json
schema:
$ref: '#/components/schemas/Problem'
```
## Use in Workflows
```yaml
# workflows/api_validation_suite.yaml
steps:
- skill: api.validate
args:
- "specs/user-service.openapi.yaml"
- "zalando"
- "--strict"
required: true
```
## Dependencies
- **PyYAML**: Required for YAML parsing
```bash
pip install pyyaml
```
## Files
### Input
- `*.openapi.yaml` - OpenAPI 3.x specifications
- `*.asyncapi.yaml` - AsyncAPI 3.x specifications
- `*.json` - JSON format specifications
### Output
- JSON validation report (stdout)
- Human-readable report (with `--format=human`)
## See Also
- [hook.define](../hook.define/SKILL.md) - Create validation hooks
- [api.define](../api.define/SKILL.md) - Create OpenAPI specs
- [Betty Architecture](../../docs/betty-architecture.md) - Five-layer model
- [API-Driven Development](../../docs/api-driven-development.md) - Complete guide
- [Zalando API Guidelines](https://opensource.zalando.com/restful-api-guidelines/)
- [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807)
## Version
**0.1.0** - Initial implementation with Zalando guidelines support

View File

@@ -0,0 +1 @@
# Auto-generated package initializer for skills.

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Validate OpenAPI and AsyncAPI specifications against enterprise guidelines.
Supports:
- OpenAPI 3.x specifications
- AsyncAPI 3.x specifications
- Zalando RESTful API Guidelines
- Custom enterprise guidelines
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, Any
# Add betty module to path
from betty.logging_utils import setup_logger
from betty.errors import format_error_response, BettyError
from betty.validation import validate_path
from betty.telemetry_capture import telemetry_decorator
from .validators.zalando_rules import ZalandoValidator
logger = setup_logger(__name__)
def load_spec(spec_path: str) -> Dict[str, Any]:
"""
Load API specification from file.
Args:
spec_path: Path to YAML or JSON specification file
Returns:
Parsed specification dictionary
Raises:
BettyError: If file cannot be loaded or parsed
"""
spec_file = Path(spec_path)
if not spec_file.exists():
raise BettyError(f"Specification file not found: {spec_path}")
if not spec_file.is_file():
raise BettyError(f"Path is not a file: {spec_path}")
try:
import yaml
with open(spec_file, 'r') as f:
spec = yaml.safe_load(f)
if not isinstance(spec, dict):
raise BettyError("Specification must be a valid YAML/JSON object")
logger.info(f"Loaded specification from {spec_path}")
return spec
except yaml.YAMLError as e:
raise BettyError(f"Failed to parse YAML: {e}")
except Exception as e:
raise BettyError(f"Failed to load specification: {e}")
def detect_spec_type(spec: Dict[str, Any]) -> str:
"""
Detect specification type (OpenAPI or AsyncAPI).
Args:
spec: Parsed specification
Returns:
Specification type: "openapi" or "asyncapi"
Raises:
BettyError: If type cannot be determined
"""
if "openapi" in spec:
version = spec["openapi"]
logger.info(f"Detected OpenAPI {version} specification")
return "openapi"
elif "asyncapi" in spec:
version = spec["asyncapi"]
logger.info(f"Detected AsyncAPI {version} specification")
return "asyncapi"
else:
raise BettyError(
"Could not detect specification type. Must contain 'openapi' or 'asyncapi' field."
)
def validate_openapi_zalando(spec: Dict[str, Any], strict: bool = False) -> Dict[str, Any]:
"""Validate an OpenAPI specification against Zalando guidelines."""
validator = ZalandoValidator(spec, strict=strict)
report = validator.validate()
logger.info(
f"Validation complete: {len(report['errors'])} errors, "
f"{len(report['warnings'])} warnings"
)
return report
def validate_asyncapi(spec: Dict[str, Any], strict: bool = False) -> Dict[str, Any]:
"""
Validate AsyncAPI specification.
Args:
spec: AsyncAPI specification
strict: Enable strict mode
Returns:
Validation report
"""
# Basic AsyncAPI validation
errors = []
warnings = []
# Check required fields
if "info" not in spec:
errors.append({
"rule_id": "ASYNCAPI_001",
"message": "Missing required field 'info'",
"severity": "error",
"path": "info"
})
if "channels" not in spec:
errors.append({
"rule_id": "ASYNCAPI_002",
"message": "Missing required field 'channels'",
"severity": "error",
"path": "channels"
})
# Check version
asyncapi_version = spec.get("asyncapi", "unknown")
if not asyncapi_version.startswith("3."):
warnings.append({
"rule_id": "ASYNCAPI_003",
"message": f"AsyncAPI version {asyncapi_version} - consider upgrading to 3.x",
"severity": "warning",
"path": "asyncapi"
})
logger.info(f"AsyncAPI validation complete: {len(errors)} errors, {len(warnings)} warnings")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"spec_version": asyncapi_version,
"rules_checked": [
"ASYNCAPI_001: Required info field",
"ASYNCAPI_002: Required channels field",
"ASYNCAPI_003: Version check"
]
}
def validate_spec(
spec_path: str,
guideline_set: str = "zalando",
strict: bool = False
) -> Dict[str, Any]:
"""
Validate API specification against guidelines.
Args:
spec_path: Path to specification file
guideline_set: Guidelines to validate against
strict: Enable strict mode
Returns:
Validation report
Raises:
BettyError: If validation fails
"""
# Load specification
spec = load_spec(spec_path)
# Detect type
spec_type = detect_spec_type(spec)
# Validate based on type and guidelines
if spec_type == "openapi":
if guideline_set == "zalando":
report = validate_openapi_zalando(spec, strict=strict)
else:
raise BettyError(
f"Guideline set '{guideline_set}' not yet supported for OpenAPI. "
f"Supported: zalando"
)
elif spec_type == "asyncapi":
report = validate_asyncapi(spec, strict=strict)
else:
raise BettyError(f"Unsupported specification type: {spec_type}")
# Add metadata
report["spec_path"] = spec_path
report["spec_type"] = spec_type
report["guideline_set"] = guideline_set
return report
def format_validation_output(report: Dict[str, Any]) -> str:
"""
Format validation report for human-readable output.
Args:
report: Validation report
Returns:
Formatted output string
"""
lines = []
# Header
spec_path = report.get("spec_path", "unknown")
spec_type = report.get("spec_type", "unknown").upper()
lines.append(f"\n{'='*60}")
lines.append(f"API Validation Report")
lines.append(f"{'='*60}")
lines.append(f"Spec: {spec_path}")
lines.append(f"Type: {spec_type}")
lines.append(f"Guidelines: {report.get('guideline_set', 'unknown')}")
lines.append(f"{'='*60}\n")
# Errors
errors = report.get("errors", [])
if errors:
lines.append(f"❌ ERRORS ({len(errors)}):")
for error in errors:
lines.append(f" [{error.get('rule_id', 'UNKNOWN')}] {error.get('message', '')}")
if error.get('path'):
lines.append(f" Path: {error['path']}")
if error.get('suggestion'):
lines.append(f" 💡 {error['suggestion']}")
lines.append("")
# Warnings
warnings = report.get("warnings", [])
if warnings:
lines.append(f"⚠️ WARNINGS ({len(warnings)}):")
for warning in warnings:
lines.append(f" [{warning.get('rule_id', 'UNKNOWN')}] {warning.get('message', '')}")
if warning.get('path'):
lines.append(f" Path: {warning['path']}")
if warning.get('suggestion'):
lines.append(f" 💡 {warning['suggestion']}")
lines.append("")
# Summary
lines.append(f"{'='*60}")
if report.get("valid"):
lines.append("✅ Validation PASSED")
else:
lines.append("❌ Validation FAILED")
lines.append(f"{'='*60}\n")
return "\n".join(lines)
@telemetry_decorator(skill_name="api.validate", caller="cli")
def main():
parser = argparse.ArgumentParser(
description="Validate API specifications against enterprise guidelines"
)
parser.add_argument(
"spec_path",
type=str,
help="Path to the API specification file (YAML or JSON)"
)
parser.add_argument(
"guideline_set",
type=str,
nargs="?",
default="zalando",
choices=["zalando", "google", "microsoft"],
help="Guidelines to validate against (default: zalando)"
)
parser.add_argument(
"--strict",
action="store_true",
help="Enable strict mode (warnings become errors)"
)
parser.add_argument(
"--format",
type=str,
choices=["json", "human"],
default="json",
help="Output format (default: json)"
)
args = parser.parse_args()
try:
# Check if PyYAML is installed
try:
import yaml
except ImportError:
raise BettyError(
"PyYAML is required for api.validate. Install with: pip install pyyaml"
)
# Validate inputs
validate_path(args.spec_path)
# Run validation
logger.info(f"Validating {args.spec_path} against {args.guideline_set} guidelines")
report = validate_spec(
spec_path=args.spec_path,
guideline_set=args.guideline_set,
strict=args.strict
)
# Output based on format
if args.format == "human":
print(format_validation_output(report))
else:
output = {
"status": "success",
"data": report
}
print(json.dumps(output, indent=2))
# Exit with error code if validation failed
if not report["valid"]:
sys.exit(1)
except Exception as e:
logger.error(f"Validation failed: {e}")
print(json.dumps(format_error_response(e), indent=2))
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,49 @@
name: api.validate
version: 0.1.0
description: Validate OpenAPI and AsyncAPI specifications against enterprise guidelines
inputs:
- name: spec_path
type: string
required: true
description: Path to the API specification file (OpenAPI or AsyncAPI)
- name: guideline_set
type: string
required: false
default: zalando
description: Which API guidelines to validate against (zalando, google, microsoft)
- name: strict
type: boolean
required: false
default: false
description: Enable strict mode (warnings become errors)
outputs:
- name: validation_report
type: object
description: Detailed validation results including errors and warnings
- name: valid
type: boolean
description: Whether the spec is valid
- name: guideline_version
type: string
description: Version of guidelines used for validation
dependencies:
- context.schema
entrypoints:
- command: /skill/api/validate
handler: api_validate.py
runtime: python
permissions:
- filesystem:read
- network:http
status: active
tags: [api, validation, openapi, asyncapi, zalando]

View File

@@ -0,0 +1 @@
# Auto-generated package initializer for skills.

View File

@@ -0,0 +1,359 @@
"""
Zalando RESTful API Guidelines validation rules.
Based on: https://opensource.zalando.com/restful-api-guidelines/
"""
from typing import Dict, List, Any, Optional
import re
class ValidationError:
"""Represents a validation error or warning."""
def __init__(
self,
rule_id: str,
message: str,
severity: str = "error",
path: Optional[str] = None,
suggestion: Optional[str] = None
):
self.rule_id = rule_id
self.message = message
self.severity = severity # "error" or "warning"
self.path = path
self.suggestion = suggestion
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"rule_id": self.rule_id,
"message": self.message,
"severity": self.severity
}
if self.path:
result["path"] = self.path
if self.suggestion:
result["suggestion"] = self.suggestion
return result
class ZalandoValidator:
"""Validates OpenAPI specs against Zalando guidelines."""
def __init__(self, spec: Dict[str, Any], strict: bool = False):
self.spec = spec
self.strict = strict
self.errors: List[ValidationError] = []
self.warnings: List[ValidationError] = []
def validate(self) -> Dict[str, Any]:
"""
Run all validation rules.
Returns:
Validation report with errors and warnings
"""
# Required metadata
self._check_required_metadata()
# Naming conventions
self._check_naming_conventions()
# HTTP methods and status codes
self._check_http_methods()
self._check_status_codes()
# Error handling
self._check_error_responses()
# Headers
self._check_required_headers()
# Security
self._check_security_schemes()
return {
"valid": len(self.errors) == 0 and (not self.strict or len(self.warnings) == 0),
"errors": [e.to_dict() for e in self.errors],
"warnings": [w.to_dict() for w in self.warnings],
"guideline_version": "zalando-1.0",
"rules_checked": self._get_rules_checked()
}
def _add_error(self, rule_id: str, message: str, path: str = None, suggestion: str = None):
"""Add a validation error."""
self.errors.append(ValidationError(rule_id, message, "error", path, suggestion))
def _add_warning(self, rule_id: str, message: str, path: str = None, suggestion: str = None):
"""Add a validation warning."""
if self.strict:
self.errors.append(ValidationError(rule_id, message, "error", path, suggestion))
else:
self.warnings.append(ValidationError(rule_id, message, "warning", path, suggestion))
def _check_required_metadata(self):
"""
Check required metadata fields.
Zalando requires: x-api-id, x-audience
"""
info = self.spec.get("info", {})
# Check x-api-id (MUST)
if "x-api-id" not in info:
self._add_error(
"MUST_001",
"Missing required field 'info.x-api-id'",
"info.x-api-id",
"Add a UUID to uniquely identify this API: x-api-id: 'd0184f38-b98d-11e7-9c56-68f728c1ba70'"
)
else:
# Validate UUID format
api_id = info["x-api-id"]
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
if not re.match(uuid_pattern, str(api_id), re.IGNORECASE):
self._add_error(
"MUST_001",
f"'info.x-api-id' must be a valid UUID, got: {api_id}",
"info.x-api-id"
)
# Check x-audience (MUST)
if "x-audience" not in info:
self._add_error(
"MUST_002",
"Missing required field 'info.x-audience'",
"info.x-audience",
"Specify target audience: x-audience: 'component-internal' | 'company-internal' | 'external-partner' | 'external-public'"
)
else:
valid_audiences = ["component-internal", "company-internal", "external-partner", "external-public"]
audience = info["x-audience"]
if audience not in valid_audiences:
self._add_error(
"MUST_002",
f"'info.x-audience' must be one of: {', '.join(valid_audiences)}",
"info.x-audience"
)
# Check contact information (SHOULD)
if "contact" not in info:
self._add_warning(
"SHOULD_001",
"Missing 'info.contact' - should provide API owner contact information",
"info.contact",
"Add contact information: contact: {name: 'Team Name', email: 'team@company.com'}"
)
def _check_naming_conventions(self):
"""
Check naming conventions.
Zalando requires: snake_case for properties, kebab-case or snake_case for paths
"""
# Check path naming
paths = self.spec.get("paths", {})
for path in paths.keys():
# Remove path parameters for checking
path_without_params = re.sub(r'\{[^}]+\}', '', path)
segments = [s for s in path_without_params.split('/') if s]
for segment in segments:
# Should be kebab-case or snake_case
if not re.match(r'^[a-z0-9_-]+$', segment):
self._add_error(
"MUST_003",
f"Path segment '{segment}' should use lowercase kebab-case or snake_case",
f"paths.{path}",
f"Use lowercase: {segment.lower()}"
)
# Check schema property naming (should be snake_case)
schemas = self.spec.get("components", {}).get("schemas", {})
for schema_name, schema in schemas.items():
if "properties" in schema and schema["properties"] is not None and isinstance(schema["properties"], dict):
for prop_name in schema["properties"].keys():
if not re.match(r'^[a-z][a-z0-9_]*$', prop_name):
self._add_error(
"MUST_004",
f"Property '{prop_name}' in schema '{schema_name}' should use snake_case",
f"components.schemas.{schema_name}.properties.{prop_name}",
f"Use snake_case: {self._to_snake_case(prop_name)}"
)
def _check_http_methods(self):
"""
Check HTTP methods are used correctly.
"""
paths = self.spec.get("paths", {})
for path, path_item in paths.items():
for method in path_item.keys():
if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "PARAMETERS"]:
continue
operation = path_item[method]
# GET should not have requestBody
if method.upper() == "GET" and "requestBody" in operation:
self._add_error(
"MUST_005",
f"GET operation should not have requestBody",
f"paths.{path}.get.requestBody"
)
# POST should return 201 for resource creation
if method.upper() == "POST":
responses = operation.get("responses", {})
if "201" not in responses and "200" not in responses:
self._add_warning(
"SHOULD_002",
"POST operation should return 201 (Created) for resource creation",
f"paths.{path}.post.responses"
)
def _check_status_codes(self):
"""
Check proper use of HTTP status codes.
"""
paths = self.spec.get("paths", {})
for path, path_item in paths.items():
for method, operation in path_item.items():
if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE"]:
continue
responses = operation.get("responses", {})
# All operations should document error responses
if "400" not in responses:
self._add_warning(
"SHOULD_003",
f"{method.upper()} operation should document 400 (Bad Request) response",
f"paths.{path}.{method}.responses"
)
if "500" not in responses:
self._add_warning(
"SHOULD_004",
f"{method.upper()} operation should document 500 (Internal Error) response",
f"paths.{path}.{method}.responses"
)
# Check 201 has Location header
if "201" in responses:
response_201 = responses["201"]
headers = response_201.get("headers", {})
if "Location" not in headers:
self._add_warning(
"SHOULD_005",
"201 (Created) response should include Location header",
f"paths.{path}.{method}.responses.201.headers",
"Add: headers: {Location: {schema: {type: string, format: uri}}}"
)
def _check_error_responses(self):
"""
Check error responses use RFC 7807 Problem JSON.
Zalando requires: application/problem+json for errors
"""
# Check if Problem schema exists
schemas = self.spec.get("components", {}).get("schemas", {})
has_problem_schema = "Problem" in schemas
if not has_problem_schema:
self._add_warning(
"SHOULD_006",
"Missing 'Problem' schema for RFC 7807 error responses",
"components.schemas",
"Add Problem schema following RFC 7807: https://datatracker.ietf.org/doc/html/rfc7807"
)
# Check error responses use application/problem+json
paths = self.spec.get("paths", {})
for path, path_item in paths.items():
for method, operation in path_item.items():
if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE"]:
continue
responses = operation.get("responses", {})
for status_code, response in responses.items():
# Check 4xx and 5xx responses
if status_code.startswith("4") or status_code.startswith("5"):
content = response.get("content", {})
if content and "application/problem+json" not in content:
self._add_warning(
"SHOULD_007",
f"Error response {status_code} should use 'application/problem+json' content type",
f"paths.{path}.{method}.responses.{status_code}.content"
)
def _check_required_headers(self):
"""
Check for required headers.
Zalando requires: X-Flow-ID for request tracing
"""
# Check if responses document X-Flow-ID
paths = self.spec.get("paths", {})
missing_flow_id = []
for path, path_item in paths.items():
for method, operation in path_item.items():
if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE"]:
continue
responses = operation.get("responses", {})
for status_code, response in responses.items():
if status_code.startswith("2"): # Success responses
headers = response.get("headers", {})
if "X-Flow-ID" not in headers and "X-Flow-Id" not in headers:
missing_flow_id.append(f"paths.{path}.{method}.responses.{status_code}")
if missing_flow_id and len(missing_flow_id) > 0:
self._add_warning(
"SHOULD_008",
f"Success responses should include X-Flow-ID header for request tracing",
missing_flow_id[0],
"Add: headers: {X-Flow-ID: {schema: {type: string, format: uuid}}}"
)
def _check_security_schemes(self):
"""
Check security schemes are defined.
"""
components = self.spec.get("components", {})
security_schemes = components.get("securitySchemes", {})
if not security_schemes:
self._add_warning(
"SHOULD_009",
"No security schemes defined - consider adding authentication",
"components.securitySchemes",
"Add security schemes: bearerAuth, oauth2, etc."
)
def _get_rules_checked(self) -> List[str]:
"""Get list of rules that were checked."""
return [
"MUST_001: Required x-api-id metadata",
"MUST_002: Required x-audience metadata",
"MUST_003: Path naming conventions",
"MUST_004: Property naming conventions (snake_case)",
"MUST_005: HTTP method usage",
"SHOULD_001: Contact information",
"SHOULD_002: POST returns 201",
"SHOULD_003: Document 400 errors",
"SHOULD_004: Document 500 errors",
"SHOULD_005: 201 includes Location header",
"SHOULD_006: Problem schema for errors",
"SHOULD_007: Error responses use application/problem+json",
"SHOULD_008: X-Flow-ID header for tracing",
"SHOULD_009: Security schemes defined"
]
@staticmethod
def _to_snake_case(text: str) -> str:
"""Convert text to snake_case."""
# Insert underscore before uppercase letters
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
# Insert underscore before uppercase letters preceded by lowercase
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()