549 lines
18 KiB
Markdown
549 lines
18 KiB
Markdown
---
|
|
description: Generate and validate an OpenAPI 3.1 artifact at specs/<feature>/contracts/openapi.yaml from api-ux, data model, and spec. Requires operationId, reusable schemas, standard envelopes, auth/RBAC, pagination, examples, and schema validation.
|
|
handoffs:
|
|
- label: Build Technical Plan
|
|
agent: speckit.plan
|
|
prompt: Create a Python/Svelte implementation plan using the validated OpenAPI contract
|
|
send: true
|
|
---
|
|
|
|
## User Input
|
|
|
|
```text
|
|
$ARGUMENTS
|
|
```
|
|
|
|
You **MUST** consider the user input before proceeding (if not empty).
|
|
|
|
## Applicability
|
|
|
|
This command is applicable when the feature has an API surface (REST endpoints, WebSocket channels). For UI-only features with no new or changed API endpoints, skip gracefully with: "No API surface detected — OpenAPI not applicable. Proceed to `/speckit.plan`."
|
|
|
|
**Decision gate**: If any of the following exist, generate OpenAPI:
|
|
- `FEATURE_DIR/contracts/ux/api-ux.md` — API shapes from `/speckit.ux`
|
|
- `FEATURE_DIR/data-model.md` — data model with Pydantic schemas
|
|
- `FEATURE_DIR/spec.md` sections describing endpoints, request/response shapes, or WebSocket channels
|
|
|
|
## Outline
|
|
|
|
### Phase 0: Pre-Flight
|
|
|
|
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `FEATURE_DIR`.
|
|
2. **Verify applicability**: If no API surface, report skip and exit.
|
|
3. **Load context**:
|
|
- `FEATURE_DIR/spec.md` — functional requirements, endpoint descriptions
|
|
- `FEATURE_DIR/ux_reference.md` — caller interaction reference
|
|
- `FEATURE_DIR/contracts/ux/api-ux.md` — API shapes from UX phase (if exists)
|
|
- `FEATURE_DIR/data-model.md` — Pydantic schemas, SQLAlchemy models (if exists)
|
|
- `FEATURE_DIR/contracts/modules.md` — module and service contracts (if exists)
|
|
- `.specify/memory/constitution.md` — auth/RBAC principles
|
|
- `docs/adr/ADR-0005-auth-rbac.md` — RBAC enforcement rules
|
|
- `backend/src/api/` — existing API route patterns to maintain consistency
|
|
- `backend/src/schemas/` — existing Pydantic schemas for reusable components
|
|
|
|
### Phase 1: Extract API Surface
|
|
|
|
Build the API surface inventory from all available sources:
|
|
|
|
| Source | Extraction |
|
|
|--------|------------|
|
|
| `api-ux.md` | Endpoint paths, methods, request/response shapes, error variants |
|
|
| `data-model.md` | Pydantic schemas → reusable `#/components/schemas/` |
|
|
| `spec.md` | Functional requirements → operation descriptions |
|
|
| `contracts/modules.md` | `@DATA_CONTRACT` entries → Input/Output DTOs |
|
|
| `ux_reference.md` | Result envelopes, warning states, recovery hints |
|
|
|
|
**Surface completeness check**: For each endpoint, verify:
|
|
- [ ] Path and HTTP method
|
|
- [ ] Request body schema (if POST/PUT/PATCH)
|
|
- [ ] Path/query parameters with types
|
|
- [ ] Success response (200/201) schema
|
|
- [ ] Error responses: 400, 401, 403, 404, 409, 422, 429, 500
|
|
- [ ] Auth requirement (RBAC role)
|
|
- [ ] Pagination parameters (if list endpoint)
|
|
|
|
### Phase 2: Generate openapi.yaml
|
|
|
|
Create `specs/<feature>/contracts/openapi.yaml`:
|
|
|
|
```yaml
|
|
openapi: "3.1.0"
|
|
info:
|
|
title: "[Feature Name] API"
|
|
version: "1.0.0"
|
|
description: >
|
|
OpenAPI 3.1 contract for [feature]. Generated from UX contracts,
|
|
data model, and specification. Source: specs/<feature>/
|
|
|
|
servers:
|
|
- url: /api
|
|
description: superset-tools API gateway
|
|
|
|
tags:
|
|
- name: [domain]
|
|
description: [domain description from spec]
|
|
|
|
paths:
|
|
/[resource]:
|
|
get:
|
|
operationId: listResources
|
|
tags: [[domain]]
|
|
summary: List all resources
|
|
description: Returns a paginated list of resources accessible to the caller.
|
|
parameters:
|
|
- $ref: "#/components/parameters/PageParam"
|
|
- $ref: "#/components/parameters/PageSizeParam"
|
|
- name: search
|
|
in: query
|
|
schema: { type: string }
|
|
description: Full-text search filter
|
|
responses:
|
|
"200":
|
|
description: Paginated list of resources
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ResourceListResponse"
|
|
examples:
|
|
withData:
|
|
$ref: "#/components/examples/ResourceListWithData"
|
|
empty:
|
|
$ref: "#/components/examples/ResourceListEmpty"
|
|
"401":
|
|
$ref: "#/components/responses/UnauthorizedError"
|
|
"403":
|
|
$ref: "#/components/responses/ForbiddenError"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
post:
|
|
operationId: createResource
|
|
tags: [[domain]]
|
|
summary: Create a new resource
|
|
description: Creates a resource. Requires [ROLE] permission.
|
|
security:
|
|
- BearerAuth: [[role]]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ResourceCreateRequest"
|
|
examples:
|
|
valid:
|
|
$ref: "#/components/examples/ResourceCreateValid"
|
|
responses:
|
|
"201":
|
|
description: Resource created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ResourceResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequestError"
|
|
"401":
|
|
$ref: "#/components/responses/UnauthorizedError"
|
|
"403":
|
|
$ref: "#/components/responses/ForbiddenError"
|
|
"409":
|
|
$ref: "#/components/responses/ConflictError"
|
|
"422":
|
|
$ref: "#/components/responses/ValidationError"
|
|
"429":
|
|
$ref: "#/components/responses/RateLimitError"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/[resource]/{resourceId}:
|
|
parameters:
|
|
- name: resourceId
|
|
in: path
|
|
required: true
|
|
schema: { type: string, format: uuid }
|
|
get:
|
|
operationId: getResource
|
|
tags: [[domain]]
|
|
summary: Get resource by ID
|
|
responses:
|
|
"200":
|
|
description: Resource found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ResourceResponse"
|
|
"404":
|
|
$ref: "#/components/responses/NotFoundError"
|
|
# ... standard errors
|
|
put:
|
|
operationId: updateResource
|
|
tags: [[domain]]
|
|
summary: Full update of resource
|
|
description: |
|
|
Idempotent full update. Requires [ROLE] permission.
|
|
Uses optimistic concurrency via If-Match header.
|
|
parameters:
|
|
- name: If-Match
|
|
in: header
|
|
schema: { type: string }
|
|
description: Version hash for optimistic concurrency
|
|
security:
|
|
- BearerAuth: [[role]]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ResourceUpdateRequest"
|
|
responses:
|
|
"200":
|
|
description: Resource updated
|
|
"409":
|
|
description: Version conflict — resource modified since If-Match
|
|
$ref: "#/components/responses/ConflictError"
|
|
"412":
|
|
description: Precondition failed — If-Match missing or stale
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
# ... standard errors
|
|
|
|
components:
|
|
securitySchemes:
|
|
BearerAuth:
|
|
type: http
|
|
scheme: bearer
|
|
bearerFormat: JWT
|
|
description: |
|
|
superset-tools JWT. Roles encoded in `roles` claim.
|
|
Required scopes noted per-operation.
|
|
|
|
parameters:
|
|
PageParam:
|
|
name: page
|
|
in: query
|
|
schema: { type: integer, minimum: 1, default: 1 }
|
|
description: Page number (1-indexed)
|
|
PageSizeParam:
|
|
name: page_size
|
|
in: query
|
|
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
|
|
description: Items per page
|
|
|
|
schemas:
|
|
ErrorEnvelope:
|
|
type: object
|
|
required: [error]
|
|
properties:
|
|
error:
|
|
type: object
|
|
required: [code, detail]
|
|
properties:
|
|
code:
|
|
type: string
|
|
description: Machine-readable error code (e.g., NOT_FOUND, VALIDATION_ERROR)
|
|
example: "NOT_FOUND"
|
|
detail:
|
|
type: string
|
|
description: Human-readable error description
|
|
example: "Resource 550e8400-e29b-41d4-a716-446655440000 not found"
|
|
fields:
|
|
type: object
|
|
description: Per-field validation errors (422 only)
|
|
additionalProperties:
|
|
type: string
|
|
example: { "name": "Name is required", "email": "Invalid email format" }
|
|
retry_after:
|
|
type: integer
|
|
description: Seconds until retry is allowed (429 only)
|
|
example: 30
|
|
|
|
SuccessEnvelope:
|
|
type: object
|
|
required: [data]
|
|
properties:
|
|
data: {}
|
|
meta:
|
|
type: object
|
|
properties:
|
|
total:
|
|
type: integer
|
|
description: Total items matching query
|
|
page:
|
|
type: integer
|
|
page_size:
|
|
type: integer
|
|
pages:
|
|
type: integer
|
|
|
|
ResourceResponse:
|
|
allOf:
|
|
- $ref: "#/components/schemas/SuccessEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
$ref: "#/components/schemas/Resource"
|
|
|
|
ResourceListResponse:
|
|
allOf:
|
|
- $ref: "#/components/schemas/SuccessEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Resource"
|
|
|
|
# ... domain-specific schemas derived from data-model.md
|
|
|
|
responses:
|
|
BadRequestError:
|
|
description: Malformed request
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "BAD_REQUEST"
|
|
detail: "Request body is not valid JSON"
|
|
|
|
UnauthorizedError:
|
|
description: Missing or invalid authentication
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "UNAUTHORIZED"
|
|
detail: "Authentication required"
|
|
|
|
ForbiddenError:
|
|
description: Insufficient permissions
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "FORBIDDEN"
|
|
detail: "Requires role: admin"
|
|
|
|
NotFoundError:
|
|
description: Resource not found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "NOT_FOUND"
|
|
detail: "Resource 550e8400-e29b-41d4-a716-446655440000 not found"
|
|
|
|
ConflictError:
|
|
description: Resource conflict (e.g., duplicate, version mismatch)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "CONFLICT"
|
|
detail: "Resource with this name already exists"
|
|
|
|
ValidationError:
|
|
description: Request validation failed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "VALIDATION_ERROR"
|
|
detail: "Request validation failed"
|
|
fields:
|
|
name: "Name is required"
|
|
|
|
RateLimitError:
|
|
description: Too many requests
|
|
headers:
|
|
Retry-After:
|
|
schema: { type: integer }
|
|
description: Seconds until next request is allowed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "RATE_LIMITED"
|
|
detail: "Too many requests. Retry after 30 seconds."
|
|
retry_after: 30
|
|
|
|
InternalError:
|
|
description: Unexpected server error
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorEnvelope"
|
|
example:
|
|
error:
|
|
code: "INTERNAL_ERROR"
|
|
detail: "An unexpected error occurred. Please try again later."
|
|
|
|
examples:
|
|
ResourceListWithData:
|
|
summary: List with items
|
|
value:
|
|
data:
|
|
- id: "550e8400-e29b-41d4-a716-446655440000"
|
|
name: "Example Resource"
|
|
created_at: "2026-07-31T12:00:00Z"
|
|
meta:
|
|
total: 42
|
|
page: 1
|
|
page_size: 20
|
|
pages: 3
|
|
|
|
ResourceListEmpty:
|
|
summary: Empty list
|
|
value:
|
|
data: []
|
|
meta:
|
|
total: 0
|
|
page: 1
|
|
page_size: 20
|
|
pages: 0
|
|
```
|
|
|
|
### Phase 3: Schema Validation
|
|
|
|
Validate the generated `openapi.yaml` using ONLY available repo tooling:
|
|
|
|
1. **YAML syntax**: Verify parseable via Python `import yaml; yaml.safe_load(file)` — Python's `pyyaml` is in `requirements.txt`.
|
|
2. **Structural check**: Verify `openapi`, `info`, `paths`, `components` keys exist.
|
|
3. **OperationId uniqueness**: Every `operationId` MUST be unique across all paths.
|
|
4. **Schema references**: Every `$ref` target MUST exist in `components/schemas/` or `components/responses/` or `components/parameters/`.
|
|
5. **Example completeness**: Every response class (2xx, 4xx, 5xx) for every operation MUST have at least one example.
|
|
6. **Auth coverage**: Every mutating operation (POST, PUT, PATCH, DELETE) MUST declare `security`.
|
|
|
|
**Do NOT install new tools.** If `openapi-spec-validator` or `spectral` are not already in the project, use Python script inline:
|
|
|
|
```python
|
|
import yaml, sys, json
|
|
|
|
with open("specs/<feature>/contracts/openapi.yaml") as f:
|
|
spec = yaml.safe_load(f)
|
|
|
|
errors = []
|
|
|
|
# Check required OpenAPI keys
|
|
for key in ("openapi", "info", "paths"):
|
|
if key not in spec:
|
|
errors.append(f"Missing required key: {key}")
|
|
|
|
# Check operationId uniqueness
|
|
op_ids = set()
|
|
for path, methods in spec.get("paths", {}).items():
|
|
for method, op in methods.items():
|
|
if method in ("parameters", "description", "summary"):
|
|
continue
|
|
oid = op.get("operationId")
|
|
if not oid:
|
|
errors.append(f"{method.upper()} {path}: missing operationId")
|
|
elif oid in op_ids:
|
|
errors.append(f"{method.upper()} {path}: duplicate operationId '{oid}'")
|
|
else:
|
|
op_ids.add(oid)
|
|
|
|
# Check $ref targets
|
|
schemas = set(spec.get("components", {}).get("schemas", {}).keys())
|
|
responses = set(spec.get("components", {}).get("responses", {}).keys())
|
|
params = set(spec.get("components", {}).get("parameters", {}).keys())
|
|
|
|
def check_refs(obj, path=""):
|
|
if isinstance(obj, dict):
|
|
if "$ref" in obj:
|
|
ref = obj["$ref"]
|
|
parts = ref.split("/")
|
|
if len(parts) >= 4 and parts[1] == "components":
|
|
if parts[2] == "schemas" and parts[3] not in schemas:
|
|
errors.append(f"{path}: unresolved $ref {ref} (schema not found)")
|
|
elif parts[2] == "responses" and parts[3] not in responses:
|
|
errors.append(f"{path}: unresolved $ref {ref} (response not found)")
|
|
elif parts[2] == "parameters" and parts[3] not in params:
|
|
errors.append(f"{path}: unresolved $ref {ref} (parameter not found)")
|
|
for k, v in obj.items():
|
|
check_refs(v, f"{path}.{k}")
|
|
elif isinstance(obj, list):
|
|
for i, v in enumerate(obj):
|
|
check_refs(v, f"{path}[{i}]")
|
|
|
|
check_refs(spec)
|
|
|
|
if errors:
|
|
print(f"VALIDATION FAILED: {len(errors)} errors")
|
|
for e in errors:
|
|
print(f" - {e}")
|
|
sys.exit(1)
|
|
else:
|
|
print(f"VALIDATION PASSED: {len(op_ids)} operations, {len(schemas)} schemas")
|
|
```
|
|
|
|
Run: `cd /root/ss-tools && python -c "$(cat <<'PYEOF' ... PYEOF)"`
|
|
|
|
### Phase 4: Drift & Traceability Mappings
|
|
|
|
Create `specs/<feature>/contracts/openapi-traceability.md`:
|
|
|
|
```markdown
|
|
#region Std.Opencode.OpenApiTraceability [C:3] [TYPE ADR] [SEMANTICS openapi,traceability,[DOMAIN]]
|
|
@defgroup OpenAPI Trace OpenAPI operationId → data-model → spec → UX contract drift map.
|
|
|
|
## Operation Traceability
|
|
|
|
| operationId | Spec Requirement | Data Model | UX Contract | Status |
|
|
|-------------|-----------------|------------|-------------|--------|
|
|
| listResources | [DOMAIN]-FR-001 | Resource (data-model.md: §Resources) | api-ux.md: GET /resources | ✅ |
|
|
| createResource | [DOMAIN]-FR-002 | ResourceCreateRequest | api-ux.md: POST /resources | ✅ |
|
|
| getResource | [DOMAIN]-FR-003 | Resource (data-model.md: §Resources) | api-ux.md: GET /resources/{id} | ✅ |
|
|
|
|
## Schema Traceability
|
|
|
|
| Schema | Source | Purpose |
|
|
|--------|--------|---------|
|
|
| Resource | data-model.md: Resource entity | Shared response schema |
|
|
| ResourceCreateRequest | api-ux.md: Create payload | Create request body |
|
|
| ErrorEnvelope | ux_reference.md: Error shapes | Standard error response |
|
|
|
|
## Drift Detection (manual review)
|
|
|
|
- [ ] Every operationId maps to at least one spec requirement
|
|
- [ ] Every spec requirement with an API touchpoint maps to an operationId
|
|
- [ ] Pydantic schema names match OpenAPI schema names
|
|
- [ ] Error response shapes match ux_reference.md promises
|
|
- [ ] Auth requirements match ADR-0005 RBAC model
|
|
|
|
## Coverage Gate
|
|
|
|
- [ ] Success examples for every operation
|
|
- [ ] Error examples for every response class
|
|
- [ ] Pagination parameters on every list endpoint
|
|
- [ ] operationId on every operation
|
|
- [ ] Reusable schemas (no inline anonymous schemas)
|
|
|
|
#endregion Std.Opencode.OpenApiTraceability
|
|
```
|
|
|
|
### Phase 5: Report
|
|
|
|
Report:
|
|
- OpenAPI path: `specs/<feature>/contracts/openapi.yaml`
|
|
- Operations defined: N
|
|
- Reusable schemas: N
|
|
- Standard error responses: N
|
|
- Validation: PASS/FAIL with N errors
|
|
- Traceability: N operations mapped to requirements
|
|
- Recommended next: `/speckit.plan`
|