api: make the OpenAPI spec usable as documentation
ci / lint (push) Failing after 56s
ci / types (push) Has been skipped
ci / unit (push) Has been skipped
ci / integration (push) Has been skipped
ci / security (push) Has been skipped
ci / dockerfile (push) Has been skipped
ci / chart (push) Has been skipped
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
ci / lint (push) Failing after 56s
ci / types (push) Has been skipped
ci / unit (push) Has been skipped
ci / integration (push) Has been skipped
ci / security (push) Has been skipped
ci / dockerfile (push) Has been skipped
ci / chart (push) Has been skipped
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
FastAPI already served /docs, /redoc and /openapi.json, and the ingress already
passed them through — the mechanism was there, the content was not. The schema
alone cannot tell a caller the three things they most need to know, and the
route docstrings explain implementation reasoning to a maintainer rather than
usage to a consumer.
Added to the spec itself, so it travels with the API rather than living in a
README the caller does not have:
- An app description covering bearer auth, that every write is 202 + poll,
the instance lifecycle, and the uniform {"code", "message"} error body.
- Tag descriptions for `instances` and `ops`.
- Field descriptions and worked examples on CreateInstanceRequest,
InstanceResponse and ErrorBody, so /docs shows a valid payload instead of
leaving callers to infer one.
The bearer scheme was already exposed via HTTPBearer, which is what makes the
Authorize button in /docs work; there is now a test asserting it stays, along
with the description, the tags and the request example. Docs that are not
tested rot silently, and this is the artifact other teams integrate against.
README documents the three URLs and how to generate a client from the spec.
This commit is contained in:
@@ -78,6 +78,39 @@ uv run pytest -q -m slow
|
|||||||
uv run python -m scripts.redis_budget # projects month-end burn, exits 1 if over
|
uv run python -m scripts.redis_budget # projects month-end burn, exits 1 if over
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using the API
|
||||||
|
|
||||||
|
The API documents itself. FastAPI generates OpenAPI from the same models and routes it
|
||||||
|
serves, so the spec cannot drift from the implementation the way a hand-written one does.
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| Swagger UI (try requests in the browser) | `https://svcforge.oci-oci.duckdns.org/docs` |
|
||||||
|
| ReDoc (nicer to read) | `https://svcforge.oci-oci.duckdns.org/redoc` |
|
||||||
|
| Raw spec, for generating clients | `https://svcforge.oci-oci.duckdns.org/openapi.json` |
|
||||||
|
|
||||||
|
Locally, `uv run uvicorn services.api.main:app --factory` then <http://127.0.0.1:8000/docs>.
|
||||||
|
|
||||||
|
Three things a caller needs that a schema cannot state on its own, so they are written into
|
||||||
|
the spec's description and rendered at the top of `/docs`:
|
||||||
|
|
||||||
|
- **Every write is asynchronous.** `POST` and `DELETE` return `202 Accepted` and enqueue
|
||||||
|
work. Poll `GET /v1/instances/{id}` and watch `state`; only `ready` carries an endpoint.
|
||||||
|
- **Authorisation is a WHERE clause.** Another team's instance returns `404`, not `403`, so
|
||||||
|
the API never confirms that an id you cannot access exists.
|
||||||
|
- **Every non-2xx body is `{"code", "message"}`**, including the 404s and 405s raised by
|
||||||
|
the framework itself, so clients never branch on the body's shape.
|
||||||
|
|
||||||
|
Generate a client from the spec rather than hand-rolling one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://svcforge.oci-oci.duckdns.org/openapi.json > openapi.json
|
||||||
|
# e.g. openapi-generator-cli generate -i openapi.json -g python -o ./client
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/integration/test_api.py` pins the description, the tags and the bearer security
|
||||||
|
scheme, so the docs fail CI if they rot.
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
| Module | Teaches | Read here |
|
| Module | Teaches | Read here |
|
||||||
|
|||||||
@@ -85,6 +85,60 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
await redis.aclose()
|
await redis.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- API docs
|
||||||
|
|
||||||
|
# Everything a caller needs that the generated schema cannot express on its own. Kept next
|
||||||
|
# to create_app rather than in a README because /docs is what someone integrating actually
|
||||||
|
# reads, and a README in this repo is not something they have.
|
||||||
|
API_DESCRIPTION = """
|
||||||
|
Provision managed service instances (Elasticsearch, Redis, Postgres) into Kubernetes.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Every `/v1` route needs a bearer JWT: `Authorization: Bearer <token>`. The token is
|
||||||
|
verified against the configured JWKS (RS256), and its `team` claim decides which instances
|
||||||
|
you can see. **Authorisation is a WHERE clause** — asking for another team's instance
|
||||||
|
returns `404`, not `403`, so the API never confirms that an id you cannot access exists.
|
||||||
|
|
||||||
|
## Writes are asynchronous
|
||||||
|
|
||||||
|
`POST` and `DELETE` return **202 Accepted**, not 201/204. They enqueue work and return
|
||||||
|
immediately; nothing is provisioned yet when you get the response. Poll
|
||||||
|
`GET /v1/instances/{id}` and watch `state`.
|
||||||
|
|
||||||
|
## Instance lifecycle
|
||||||
|
|
||||||
|
requested -> provisioning -> ready
|
||||||
|
|
|
||||||
|
v
|
||||||
|
deleting -> deleted
|
||||||
|
|
||||||
|
`failed` is reachable from `requested` and `provisioning` when a provision exhausts its
|
||||||
|
retries. A `ready` instance whose release vanished is re-provisioned automatically by the
|
||||||
|
reconciler, so `ready` is the only state that carries a usable `endpoint`.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Every non-2xx body is the same shape — `{"code": ..., "message": ...}` — including the
|
||||||
|
404s and 405s raised by the framework itself. `code` is stable and meant for machines;
|
||||||
|
`message` is for humans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
OPENAPI_TAGS = [
|
||||||
|
{
|
||||||
|
"name": "instances",
|
||||||
|
"description": "Create, inspect and delete service instances. All writes are 202 + poll.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ops",
|
||||||
|
"description": (
|
||||||
|
"Liveness, readiness and Prometheus metrics. Unauthenticated, and not part of "
|
||||||
|
"the tenant API surface."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""Render HTTPException bodies as ErrorBody, so every error has one shape.
|
"""Render HTTPException bodies as ErrorBody, so every error has one shape.
|
||||||
|
|
||||||
@@ -142,10 +196,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
# remembered to write is a check that does not run.
|
# remembered to write is a check that does not run.
|
||||||
settings.check_production()
|
settings.check_production()
|
||||||
|
|
||||||
|
# The description is the API's documentation. FastAPI renders it as markdown at /docs,
|
||||||
|
# and it is the only place a caller who does not have this repo can learn the two things
|
||||||
|
# that are not obvious from the schema: every write is asynchronous, and the instance
|
||||||
|
# lifecycle is a state machine they have to poll.
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="svcforge",
|
title="svcforge",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
summary="X-as-a-Service control plane",
|
summary="X-as-a-Service control plane",
|
||||||
|
description=API_DESCRIPTION,
|
||||||
|
openapi_tags=OPENAPI_TAGS,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
|
|||||||
+53
-12
@@ -24,29 +24,70 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
against the catalog in the handler.
|
against the catalog in the handler.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(
|
||||||
|
extra="forbid",
|
||||||
|
json_schema_extra={"examples": [{"service_type": "redis", "size": "small", "ttl_days": 7}]},
|
||||||
|
)
|
||||||
|
|
||||||
service_type: str = Field(min_length=1)
|
service_type: str = Field(
|
||||||
size: str
|
min_length=1,
|
||||||
ttl_days: int | None = Field(default=None, ge=1, le=30)
|
description=(
|
||||||
|
"A service type in the catalog, e.g. `elasticsearch`, `redis`, `postgres`. "
|
||||||
|
"Unknown values return 404."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
size: str = Field(
|
||||||
|
description=(
|
||||||
|
"A size the catalog defines for that service type, e.g. `small`. Unknown values return 422."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ttl_days: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
le=30,
|
||||||
|
description="Delete the instance automatically after this many days. Omit for no expiry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InstanceResponse(BaseModel):
|
class InstanceResponse(BaseModel):
|
||||||
"""What a tenant gets back. A subset of Instance, on purpose."""
|
"""What a tenant gets back. A subset of Instance, on purpose."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"id": "0f8b7d3e-1c2a-4f5b-9e6d-7a8b9c0d1e2f",
|
||||||
|
"state": "ready",
|
||||||
|
"service_type": "redis",
|
||||||
|
"size": "small",
|
||||||
|
"endpoint": "http://acme-redis-0f8b7d3e.tenant-acme.svc.cluster.local",
|
||||||
|
"chart_version": "20.6.2",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
id: UUID
|
id: UUID = Field(description="Poll `GET /v1/instances/{id}` with this to watch the state change.")
|
||||||
state: InstanceState
|
state: InstanceState = Field(description="Lifecycle state. Only `ready` carries a usable endpoint.")
|
||||||
service_type: str
|
service_type: str
|
||||||
size: str
|
size: str
|
||||||
endpoint: str | None
|
endpoint: str | None = Field(description="In-cluster DNS name. Null until the instance is `ready`.")
|
||||||
chart_version: str
|
chart_version: str = Field(
|
||||||
error: str | None
|
description="The chart version actually deployed, written only after helm succeeds."
|
||||||
|
)
|
||||||
|
error: str | None = Field(description="Why the last attempt failed. Null unless `state` is `failed`.")
|
||||||
|
|
||||||
|
|
||||||
class ErrorBody(BaseModel):
|
class ErrorBody(BaseModel):
|
||||||
"""Every non-2xx body. `code` is for machines, `message` is for humans."""
|
"""Every non-2xx body. `code` is for machines, `message` is for humans."""
|
||||||
|
|
||||||
code: str
|
model_config = ConfigDict(
|
||||||
message: str
|
json_schema_extra={
|
||||||
|
"examples": [{"code": "unknown_service_type", "message": "no such service_type: mongodb"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
code: str = Field(description="Stable machine-readable identifier for the failure.")
|
||||||
|
message: str = Field(description="Human-readable detail. Do not parse this.")
|
||||||
|
|||||||
@@ -634,3 +634,40 @@ async def test_auth_disabled_accepts_an_unauthenticated_request(settings: Settin
|
|||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
resp = await c.get("/v1/instances")
|
resp = await c.get("/v1/instances")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- openapi
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openapi_documents_the_api_for_a_caller_without_this_repo(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""/openapi.json is the contract other teams integrate against, so it gets a test.
|
||||||
|
|
||||||
|
Pins the parts a generated schema does not give you for free and that silently rot: the
|
||||||
|
prose description, the tag docs, and the bearer scheme that makes the Authorize button
|
||||||
|
in /docs work. Without the security scheme a caller cannot try a single authenticated
|
||||||
|
route from the UI.
|
||||||
|
"""
|
||||||
|
resp = await client.get("/openapi.json")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
spec = resp.json()
|
||||||
|
|
||||||
|
assert spec["info"]["title"] == "svcforge"
|
||||||
|
# The description carries the two things the schema cannot express: writes are async,
|
||||||
|
# and authorisation is a WHERE clause that 404s rather than 403s.
|
||||||
|
description = spec["info"]["description"]
|
||||||
|
assert "202" in description and "404" in description
|
||||||
|
|
||||||
|
assert {t["name"] for t in spec["tags"]} == {"instances", "ops"}
|
||||||
|
assert "HTTPBearer" in spec["components"]["securitySchemes"]
|
||||||
|
assert "/v1/instances" in spec["paths"]
|
||||||
|
# An example payload, so a caller can see a valid body rather than infer one.
|
||||||
|
assert spec["components"]["schemas"]["CreateInstanceRequest"]["examples"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_swagger_and_redoc_are_served(client: httpx.AsyncClient) -> None:
|
||||||
|
"""The human-facing docs. Both are on by default; a `docs_url=None` would drop them."""
|
||||||
|
for path in ("/docs", "/redoc"):
|
||||||
|
resp = await client.get(path)
|
||||||
|
assert resp.status_code == 200, path
|
||||||
|
|||||||
Reference in New Issue
Block a user