diff --git a/README.md b/README.md index bcc505c..07d76d5 100644 --- a/README.md +++ b/README.md @@ -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 ``` +## 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 . + +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 | Module | Teaches | Read here | diff --git a/services/api/main.py b/services/api/main.py index 3a3340a..e5b2176 100644 --- a/services/api/main.py +++ b/services/api/main.py @@ -85,6 +85,60 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: 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 `. 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: """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. 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( title="svcforge", version="0.1.0", summary="X-as-a-Service control plane", + description=API_DESCRIPTION, + openapi_tags=OPENAPI_TAGS, lifespan=lifespan, ) app.state.settings = settings diff --git a/services/api/models.py b/services/api/models.py index 6a33392..65ff111 100644 --- a/services/api/models.py +++ b/services/api/models.py @@ -24,29 +24,70 @@ class CreateInstanceRequest(BaseModel): 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) - size: str - ttl_days: int | None = Field(default=None, ge=1, le=30) + service_type: str = Field( + min_length=1, + 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): """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 - state: InstanceState + id: UUID = Field(description="Poll `GET /v1/instances/{id}` with this to watch the state change.") + state: InstanceState = Field(description="Lifecycle state. Only `ready` carries a usable endpoint.") service_type: str size: str - endpoint: str | None - chart_version: str - error: str | None + endpoint: str | None = Field(description="In-cluster DNS name. Null until the instance is `ready`.") + chart_version: str = Field( + 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): """Every non-2xx body. `code` is for machines, `message` is for humans.""" - code: str - message: str + model_config = ConfigDict( + 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.") diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index ccfb6fc..b91624a 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -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: resp = await c.get("/v1/instances") 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