Initial commit
This commit is contained in:
315
skills/api.validate/SKILL.md
Normal file
315
skills/api.validate/SKILL.md
Normal 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
|
||||
1
skills/api.validate/__init__.py
Normal file
1
skills/api.validate/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Auto-generated package initializer for skills.
|
||||
343
skills/api.validate/api_validate.py
Executable file
343
skills/api.validate/api_validate.py
Executable 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()
|
||||
49
skills/api.validate/skill.yaml
Normal file
49
skills/api.validate/skill.yaml
Normal 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]
|
||||
1
skills/api.validate/validators/__init__.py
Normal file
1
skills/api.validate/validators/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Auto-generated package initializer for skills.
|
||||
359
skills/api.validate/validators/zalando_rules.py
Normal file
359
skills/api.validate/validators/zalando_rules.py
Normal 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()
|
||||
Reference in New Issue
Block a user