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

231
skills/api.define/SKILL.md Normal file
View File

@@ -0,0 +1,231 @@
# api.define
## Overview
**api.define** scaffolds OpenAPI and AsyncAPI specifications from enterprise-compliant templates, generating production-ready API contracts with best practices built-in.
## Purpose
Quickly create API specifications that follow enterprise guidelines:
- Generate Zalando-compliant OpenAPI 3.1 specs
- Generate AsyncAPI 3.0 specs for event-driven APIs
- Include proper error handling (RFC 7807 Problem JSON)
- Use correct naming conventions (snake_case)
- Include required metadata and security schemes
## Usage
### Basic Usage
```bash
python skills/api.define/api_define.py <service_name> [spec_type] [options]
```
### Parameters
| Parameter | Required | Description | Default |
|-----------|----------|-------------|---------|
| `service_name` | Yes | Service/API name | - |
| `spec_type` | No | openapi or asyncapi | `openapi` |
| `--template` | No | Template name | `zalando` |
| `--output-dir` | No | Output directory | `specs` |
| `--version` | No | API version | `1.0.0` |
## Examples
### Example 1: Create Zalando-Compliant OpenAPI Spec
```bash
python skills/api.define/api_define.py user-service openapi --template=zalando
```
**Output**: `specs/user-service.openapi.yaml`
Generated spec includes:
- ✅ Required Zalando metadata (`x-api-id`, `x-audience`)
- ✅ CRUD operations for users resource
- ✅ RFC 7807 Problem JSON for errors
- ✅ snake_case property names
- ✅ X-Flow-ID headers for tracing
- ✅ Proper HTTP status codes
- ✅ JWT authentication scheme
### Example 2: Create AsyncAPI Spec
```bash
python skills/api.define/api_define.py user-service asyncapi
```
**Output**: `specs/user-service.asyncapi.yaml`
Generated spec includes:
- ✅ Lifecycle events (created, updated, deleted)
- ✅ Kafka channel definitions
- ✅ Event payload schemas
- ✅ Publish/subscribe operations
### Example 3: Custom Output Directory
```bash
python skills/api.define/api_define.py order-api openapi \
--output-dir=api-specs \
--version=2.0.0
```
## Generated OpenAPI Structure
For a service named `user-service`, the generated OpenAPI spec includes:
**Paths**:
- `GET /users` - List users with pagination
- `POST /users` - Create new user
- `GET /users/{user_id}` - Get user by ID
- `PUT /users/{user_id}` - Update user
- `DELETE /users/{user_id}` - Delete user
**Schemas**:
- `User` - Main resource schema
- `UserCreate` - Creation payload schema
- `UserUpdate` - Update payload schema
- `Pagination` - Pagination metadata
- `Problem` - RFC 7807 error schema
**Responses**:
- `200` - Success (with X-Flow-ID header)
- `201` - Created (with Location and X-Flow-ID headers)
- `204` - No Content (for deletes)
- `400` - Bad Request (application/problem+json)
- `404` - Not Found (application/problem+json)
- `409` - Conflict (application/problem+json)
- `500` - Internal Error (application/problem+json)
**Security**:
- Bearer token authentication (JWT)
**Required Metadata**:
- `x-api-id` - Unique UUID
- `x-audience` - Target audience (company-internal)
- `contact` - Team contact information
## Resource Name Extraction
The skill automatically extracts resource names from service names:
| Service Name | Resource | Plural |
|--------------|----------|--------|
| `user-service` | user | users |
| `order-api` | order | orders |
| `payment-gateway` | payment | payments |
## Naming Conventions
The skill automatically applies proper naming:
| Context | Convention | Example |
|---------|------------|---------|
| Paths | kebab-case | `/user-profiles` |
| Properties | snake_case | `user_id` |
| Schemas | TitleCase | `UserProfile` |
| Operations | camelCase | `getUserById` |
## Integration with api.validate
The generated specs are designed to pass `api.validate` with zero errors:
```bash
# Generate spec
python skills/api.define/api_define.py user-service
# Validate (should pass)
python skills/api.validate/api_validate.py specs/user-service.openapi.yaml zalando
```
## Use in Workflows
```yaml
# workflows/api_first_development.yaml
steps:
- skill: api.define
args:
- "user-service"
- "openapi"
- "--template=zalando"
output: spec_path
- skill: api.validate
args:
- "{spec_path}"
- "zalando"
required: true
```
## Customization
After generation, customize the spec:
1. **Add properties** to schemas:
```yaml
User:
properties:
user_id: ...
email: # Add this
type: string
format: email
```
2. **Add operations**:
```yaml
/users/search: # Add new endpoint
post:
summary: Search users
```
3. **Modify metadata**:
```yaml
info:
contact:
name: Your Team Name # Update this
email: team@company.com
```
## Output
### Success Response
```json
{
"status": "success",
"data": {
"spec_path": "specs/user-service.openapi.yaml",
"spec_content": {...},
"api_id": "d0184f38-b98d-11e7-9c56-68f728c1ba70",
"template_used": "zalando",
"service_name": "user-service"
}
}
```
## Dependencies
- **PyYAML**: Required for YAML handling
```bash
pip install pyyaml
```
## Templates
| Template | Spec Type | Description |
|----------|-----------|-------------|
| `zalando` | OpenAPI | Zalando-compliant with all required fields |
| `basic` | AsyncAPI | Basic event-driven API structure |
## See Also
- [api.validate](../api.validate/SKILL.md) - Validate generated specs
- [hook.define](../hook.define/SKILL.md) - Set up automatic validation
- [Betty Architecture](../../docs/betty-architecture.md) - Five-layer model
- [API-Driven Development](../../docs/api-driven-development.md) - Complete guide
## Version
**0.1.0** - Initial implementation with Zalando template support

View File

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

446
skills/api.define/api_define.py Executable file
View File

@@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
Create OpenAPI and AsyncAPI specifications from templates.
This skill scaffolds API specifications following enterprise guidelines
with proper structure and best practices built-in.
"""
import sys
import json
import argparse
import uuid
import re
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_skill_name
logger = setup_logger(__name__)
def to_snake_case(text: str) -> str:
"""Convert text to snake_case."""
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower().replace('-', '_').replace(' ', '_')
def to_kebab_case(text: str) -> str:
"""Convert text to kebab-case."""
return to_snake_case(text).replace('_', '-')
def to_title_case(text: str) -> str:
"""Convert text to TitleCase."""
return ''.join(word.capitalize() for word in re.split(r'[-_\s]', text))
def pluralize(word: str) -> str:
"""Simple pluralization (works for most common cases)."""
if word.endswith('y'):
return word[:-1] + 'ies'
elif word.endswith('s'):
return word + 'es'
else:
return word + 's'
def load_template(template_name: str, spec_type: str) -> str:
"""
Load template file content.
Args:
template_name: Template name (zalando, basic, minimal)
spec_type: Specification type (openapi or asyncapi)
Returns:
Template content as string
Raises:
BettyError: If template not found
"""
template_file = Path(__file__).parent / "templates" / f"{spec_type}-{template_name}.yaml"
if not template_file.exists():
raise BettyError(
f"Template not found: {spec_type}-{template_name}.yaml. "
f"Available templates in {template_file.parent}: "
f"{', '.join([f.stem for f in template_file.parent.glob(f'{spec_type}-*.yaml')])}"
)
try:
with open(template_file, 'r') as f:
content = f.read()
logger.info(f"Loaded template: {template_file}")
return content
except Exception as e:
raise BettyError(f"Failed to load template: {e}")
def render_template(template: str, variables: Dict[str, str]) -> str:
"""
Render template with variables.
Args:
template: Template string with {{variable}} placeholders
variables: Variable values to substitute
Returns:
Rendered template string
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
# Check for unrendered variables
unrendered = re.findall(r'\{\{(\w+)\}\}', result)
if unrendered:
logger.warning(f"Unrendered template variables: {', '.join(set(unrendered))}")
return result
def extract_resource_name(service_name: str) -> str:
"""
Extract primary resource name from service name.
Examples:
user-service -> user
order-api -> order
payment-gateway -> payment
"""
# Remove common suffixes
for suffix in ['-service', '-api', '-gateway', '-manager']:
if service_name.endswith(suffix):
return service_name[:-len(suffix)]
return service_name
def generate_openapi_spec(
service_name: str,
template_name: str = "zalando",
version: str = "1.0.0",
output_dir: str = "specs"
) -> Dict[str, Any]:
"""
Generate OpenAPI specification from template.
Args:
service_name: Service/API name
template_name: Template to use
version: API version
output_dir: Output directory
Returns:
Result dictionary with spec path and content
"""
# Generate API ID
api_id = str(uuid.uuid4())
# Extract resource name
resource_name = extract_resource_name(service_name)
# Generate template variables
variables = {
"service_name": to_kebab_case(service_name),
"service_title": to_title_case(service_name),
"version": version,
"description": f"RESTful API for {service_name.replace('-', ' ')} management",
"team_name": "Platform Team",
"team_email": "platform@company.com",
"api_id": api_id,
"audience": "company-internal",
"resource_singular": to_snake_case(resource_name),
"resource_plural": pluralize(to_snake_case(resource_name)),
"resource_title": to_title_case(resource_name),
"resource_schema": to_title_case(resource_name)
}
logger.info(f"Generated template variables: {variables}")
# Load and render template
template = load_template(template_name, "openapi")
spec_content = render_template(template, variables)
# Parse to validate YAML
try:
import yaml
spec_dict = yaml.safe_load(spec_content)
except Exception as e:
raise BettyError(f"Failed to parse generated spec: {e}")
# Create output directory
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Write specification file
spec_filename = f"{to_kebab_case(service_name)}.openapi.yaml"
spec_path = output_path / spec_filename
with open(spec_path, 'w') as f:
f.write(spec_content)
logger.info(f"Generated OpenAPI spec: {spec_path}")
return {
"spec_path": str(spec_path),
"spec_content": spec_dict,
"api_id": api_id,
"template_used": template_name,
"service_name": to_kebab_case(service_name)
}
def generate_asyncapi_spec(
service_name: str,
template_name: str = "basic",
version: str = "1.0.0",
output_dir: str = "specs"
) -> Dict[str, Any]:
"""
Generate AsyncAPI specification from template.
Args:
service_name: Service/API name
template_name: Template to use
version: API version
output_dir: Output directory
Returns:
Result dictionary with spec path and content
"""
# Basic AsyncAPI template (inline for now)
resource_name = extract_resource_name(service_name)
asyncapi_template = f"""asyncapi: 3.0.0
info:
title: {to_title_case(service_name)} Events
version: {version}
description: Event-driven API for {service_name.replace('-', ' ')} lifecycle notifications
servers:
production:
host: kafka.company.com:9092
protocol: kafka
description: Production Kafka cluster
channels:
{to_snake_case(resource_name)}.created:
address: {to_snake_case(resource_name)}.created.v1
messages:
{to_title_case(resource_name)}Created:
$ref: '#/components/messages/{to_title_case(resource_name)}Created'
{to_snake_case(resource_name)}.updated:
address: {to_snake_case(resource_name)}.updated.v1
messages:
{to_title_case(resource_name)}Updated:
$ref: '#/components/messages/{to_title_case(resource_name)}Updated'
{to_snake_case(resource_name)}.deleted:
address: {to_snake_case(resource_name)}.deleted.v1
messages:
{to_title_case(resource_name)}Deleted:
$ref: '#/components/messages/{to_title_case(resource_name)}Deleted'
operations:
publish{to_title_case(resource_name)}Created:
action: send
channel:
$ref: '#/channels/{to_snake_case(resource_name)}.created'
subscribe{to_title_case(resource_name)}Created:
action: receive
channel:
$ref: '#/channels/{to_snake_case(resource_name)}.created'
components:
messages:
{to_title_case(resource_name)}Created:
name: {to_title_case(resource_name)}Created
title: {to_title_case(resource_name)} Created Event
contentType: application/json
payload:
$ref: '#/components/schemas/{to_title_case(resource_name)}CreatedPayload'
{to_title_case(resource_name)}Updated:
name: {to_title_case(resource_name)}Updated
title: {to_title_case(resource_name)} Updated Event
contentType: application/json
payload:
$ref: '#/components/schemas/{to_title_case(resource_name)}UpdatedPayload'
{to_title_case(resource_name)}Deleted:
name: {to_title_case(resource_name)}Deleted
title: {to_title_case(resource_name)} Deleted Event
contentType: application/json
payload:
$ref: '#/components/schemas/{to_title_case(resource_name)}DeletedPayload'
schemas:
{to_title_case(resource_name)}CreatedPayload:
type: object
required: [event_id, {to_snake_case(resource_name)}_id, occurred_at]
properties:
event_id:
type: string
format: uuid
{to_snake_case(resource_name)}_id:
type: string
format: uuid
occurred_at:
type: string
format: date-time
{to_title_case(resource_name)}UpdatedPayload:
type: object
required: [event_id, {to_snake_case(resource_name)}_id, occurred_at, changes]
properties:
event_id:
type: string
format: uuid
{to_snake_case(resource_name)}_id:
type: string
format: uuid
changes:
type: object
occurred_at:
type: string
format: date-time
{to_title_case(resource_name)}DeletedPayload:
type: object
required: [event_id, {to_snake_case(resource_name)}_id, occurred_at]
properties:
event_id:
type: string
format: uuid
{to_snake_case(resource_name)}_id:
type: string
format: uuid
occurred_at:
type: string
format: date-time
"""
# Parse to validate YAML
try:
import yaml
spec_dict = yaml.safe_load(asyncapi_template)
except Exception as e:
raise BettyError(f"Failed to parse generated spec: {e}")
# Create output directory
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Write specification file
spec_filename = f"{to_kebab_case(service_name)}.asyncapi.yaml"
spec_path = output_path / spec_filename
with open(spec_path, 'w') as f:
f.write(asyncapi_template)
logger.info(f"Generated AsyncAPI spec: {spec_path}")
return {
"spec_path": str(spec_path),
"spec_content": spec_dict,
"template_used": template_name,
"service_name": to_kebab_case(service_name)
}
def main():
parser = argparse.ArgumentParser(
description="Create OpenAPI and AsyncAPI specifications from templates"
)
parser.add_argument(
"service_name",
type=str,
help="Name of the service/API (e.g., user-service, order-api)"
)
parser.add_argument(
"spec_type",
type=str,
nargs="?",
default="openapi",
choices=["openapi", "asyncapi"],
help="Type of specification (default: openapi)"
)
parser.add_argument(
"--template",
type=str,
default="zalando",
help="Template to use (default: zalando for OpenAPI, basic for AsyncAPI)"
)
parser.add_argument(
"--output-dir",
type=str,
default="specs",
help="Output directory (default: specs)"
)
parser.add_argument(
"--version",
type=str,
default="1.0.0",
help="API version (default: 1.0.0)"
)
args = parser.parse_args()
try:
# Check if PyYAML is installed
try:
import yaml
except ImportError:
raise BettyError(
"PyYAML is required for api.define. Install with: pip install pyyaml"
)
# Generate specification
logger.info(
f"Generating {args.spec_type.upper()} spec for '{args.service_name}' "
f"using template '{args.template}'"
)
if args.spec_type == "openapi":
result = generate_openapi_spec(
service_name=args.service_name,
template_name=args.template,
version=args.version,
output_dir=args.output_dir
)
elif args.spec_type == "asyncapi":
result = generate_asyncapi_spec(
service_name=args.service_name,
template_name=args.template,
version=args.version,
output_dir=args.output_dir
)
else:
raise BettyError(f"Unsupported spec type: {args.spec_type}")
# Return structured result
output = {
"status": "success",
"data": result
}
print(json.dumps(output, indent=2))
except Exception as e:
logger.error(f"Failed to generate specification: {e}")
print(json.dumps(format_error_response(e), indent=2))
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
name: api.define
version: 0.1.0
description: Create OpenAPI and AsyncAPI specifications from templates
inputs:
- name: service_name
type: string
required: true
description: Name of the service/API (e.g., user-service, order-api)
- name: spec_type
type: string
required: false
default: openapi
description: Type of specification (openapi or asyncapi)
- name: template
type: string
required: false
default: zalando
description: Template to use (zalando, basic, minimal)
- name: output_dir
type: string
required: false
default: specs
description: Output directory for generated specification
- name: version
type: string
required: false
default: 1.0.0
description: API version
outputs:
- name: spec_path
type: string
description: Path to generated specification file
- name: spec_content
type: object
description: Generated specification content
dependencies:
- context.schema
entrypoints:
- command: /skill/api/define
handler: api_define.py
runtime: python
permissions:
- filesystem:read
- filesystem:write
status: active
tags: [api, openapi, asyncapi, scaffolding, zalando]

View File

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

View File

@@ -0,0 +1,303 @@
openapi: 3.1.0
info:
title: {{service_title}}
version: {{version}}
description: {{description}}
contact:
name: {{team_name}}
email: {{team_email}}
x-api-id: {{api_id}}
x-audience: {{audience}}
servers:
- url: https://api.company.com/{{service_name}}/v1
description: Production
paths:
/{{resource_plural}}:
get:
summary: List {{resource_plural}}
operationId: list{{resource_title}}
tags: [{{resource_title}}]
parameters:
- name: limit
in: query
description: Maximum number of items to return
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: offset
in: query
description: Number of items to skip
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: List of {{resource_plural}}
headers:
X-Flow-ID:
description: Request flow ID for tracing
schema:
type: string
format: uuid
content:
application/json:
schema:
type: object
required: [{{resource_plural}}, pagination]
properties:
{{resource_plural}}:
type: array
items:
$ref: '#/components/schemas/{{resource_schema}}'
pagination:
$ref: '#/components/schemas/Pagination'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
post:
summary: Create a new {{resource_singular}}
operationId: create{{resource_title}}
tags: [{{resource_title}}]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{{resource_schema}}Create'
responses:
'201':
description: {{resource_title}} created successfully
headers:
Location:
description: URL of the created resource
schema:
type: string
format: uri
X-Flow-ID:
description: Request flow ID for tracing
schema:
type: string
format: uuid
content:
application/json:
schema:
$ref: '#/components/schemas/{{resource_schema}}'
'400':
$ref: '#/components/responses/BadRequest'
'409':
$ref: '#/components/responses/Conflict'
'500':
$ref: '#/components/responses/InternalError'
/{{resource_plural}}/{{{resource_singular}}_id}:
parameters:
- name: {{resource_singular}}_id
in: path
required: true
description: Unique identifier of the {{resource_singular}}
schema:
type: string
format: uuid
get:
summary: Get {{resource_singular}} by ID
operationId: get{{resource_title}}ById
tags: [{{resource_title}}]
responses:
'200':
description: {{resource_title}} details
headers:
X-Flow-ID:
description: Request flow ID for tracing
schema:
type: string
format: uuid
content:
application/json:
schema:
$ref: '#/components/schemas/{{resource_schema}}'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
put:
summary: Update {{resource_singular}}
operationId: update{{resource_title}}
tags: [{{resource_title}}]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{{resource_schema}}Update'
responses:
'200':
description: {{resource_title}} updated successfully
headers:
X-Flow-ID:
description: Request flow ID for tracing
schema:
type: string
format: uuid
content:
application/json:
schema:
$ref: '#/components/schemas/{{resource_schema}}'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
summary: Delete {{resource_singular}}
operationId: delete{{resource_title}}
tags: [{{resource_title}}]
responses:
'204':
description: {{resource_title}} deleted successfully
headers:
X-Flow-ID:
description: Request flow ID for tracing
schema:
type: string
format: uuid
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
components:
schemas:
{{resource_schema}}:
type: object
required: [{{resource_singular}}_id, created_at]
properties:
{{resource_singular}}_id:
type: string
format: uuid
description: Unique identifier
created_at:
type: string
format: date-time
description: Creation timestamp
updated_at:
type: string
format: date-time
description: Last update timestamp
{{resource_schema}}Create:
type: object
required: []
properties:
# Add creation-specific fields here
{{resource_schema}}Update:
type: object
properties:
# Add update-specific fields here
Pagination:
type: object
required: [limit, offset, total]
properties:
limit:
type: integer
description: Number of items per page
offset:
type: integer
description: Number of items skipped
total:
type: integer
description: Total number of items available
Problem:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
description: URI reference identifying the problem type
title:
type: string
description: Short, human-readable summary
status:
type: integer
description: HTTP status code
detail:
type: string
description: Human-readable explanation
instance:
type: string
format: uri
description: URI reference identifying the specific occurrence
responses:
BadRequest:
description: Bad request - invalid parameters or malformed request
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
example:
type: https://api.company.com/problems/bad-request
title: Bad Request
status: 400
detail: "Invalid query parameter 'limit': must be between 1 and 100"
NotFound:
description: Resource not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
example:
type: https://api.company.com/problems/not-found
title: Not Found
status: 404
detail: {{resource_title}} with the specified ID was not found
Conflict:
description: Conflict - resource already exists or state conflict
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
example:
type: https://api.company.com/problems/conflict
title: Conflict
status: 409
detail: {{resource_title}} with this identifier already exists
InternalError:
description: Internal server error
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
example:
type: https://api.company.com/problems/internal-error
title: Internal Server Error
status: 500
detail: An unexpected error occurred while processing the request
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT-based authentication
security:
- bearerAuth: []