docs: add OpenAPI-first wiki (overview, components, use cases for templates/client/codegen, design, security, error handling, testing) and refresh lib docs index

This commit is contained in:
2026-09-15 22:29:05 +05:30
parent adeb02e162
commit b559323dfe
26 changed files with 1590 additions and 86 deletions

View File

@@ -1,4 +1,3 @@
# openapi_first # openapi_first
::: openapi_first ::: openapi_first
- [Openapi First](openapi_first/)

74
docs/mkdocs.wiki.yml Normal file
View File

@@ -0,0 +1,74 @@
theme:
name: material
palette:
scheme: slate
primary: blue grey
accent: teal
font:
text: Roboto
code: JetBrains Mono
features:
- navigation.sections
- navigation.expand
- navigation.top
- navigation.instant
- navigation.tracking
- navigation.indexes
- content.code.copy
- content.code.annotate
- content.tabs.link
- content.action.edit
- search.highlight
- search.share
- search.suggest
- navigation.tabs
- toc.integrate
- header.autohide
- announce.dismiss
- footer.social
- content.code.select
- content.code.line_numbers
- content.tooltips
icon:
logo: material/api
repo: fontawesome/brands/github
markdown_extensions:
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- admonition
- pymdownx.details
- pymdownx.highlight:
linenums: true
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist:
custom_checkbox: true
- tables
- footnotes
- pymdownx.caret
- pymdownx.tilde
- pymdownx.mark
extra_css:
- https://unpkg.com/dracula-prism/dist/css/dracula-prism.css
plugins:
- search
site_name: openapi_first
docs_dir: wiki
site_dir: ../site/wiki
nav:
- Home: index.md
- Overview: 01_overview.md
- Components: 02_components.md
- Use Cases:
- Quickstart: 03_use_cases/01_quickstart.md
- Templates: 03_use_cases/02_templates.md
- Client: 03_use_cases/03_client.md
- Codegen: 03_use_cases/04_codegen.md
- Design: 04_design.md
- Security: 05_security.md
- Error Handling: 06_error_handling.md
- Testing: 07_testing.md

157
docs/wiki/01_overview.md Normal file
View File

@@ -0,0 +1,157 @@
# Overview — The OpenAPI-First Mental Model
`openapi-first` inverts the usual FastAPI workflow. Instead of decorating routes in code and letting FastAPI *invent* an OpenAPI document for you, you write **one OpenAPI document first** and let the library assemble both the application **and** the client from it. The spec is the contract; code is the implementation.
---
## 🧠 1. The Mental Model
### 1.1 One source of truth
Your OpenAPI document (`openapi.yaml` or `openapi.json`) is the **single authoritative contract**:
```yaml
paths:
/health:
get:
operationId: get_health
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
```
Every route, method, parameter, schema, and security requirement lives **here** — and only here. Code never declares routes.
### 1.2 `operationId` is the binding key
The only bridge between the spec and your Python code is the `operationId`. Each operation maps, **by name**, to exactly one plain callable:
```python
# routes.py
def get_health():
"""Health check operation handler."""
return {"status": "ok"}
```
`openapi_first` resolves `operationId: get_health` → `routes.get_health` and registers the route. **No decorators, no `@app.get`, no routing metadata in code.**
Guarantees this binding provides:
- 🚫 No undocumented route can exist — every route *must* be in the spec
- 🚫 No spec operation can go unhandled — every `operationId` *must* resolve at startup
- 🔒 No auth can be bypassed — security is injected from `securitySchemes` + per-operation `security`, spec-driven
- 🧱 No drift possible — the server and client are built from the *same* document
### 1.3 Fail-fast by design
Contract violations are detected **at application startup** (or client construction), never silently at request time:
| What goes wrong | When it fails |
|------------------------------------------|----------------------|
| Invalid / unloadable spec | `load_openapi` at startup |
| Spec fails OpenAPI 3.x validation | `load_openapi` at startup |
| `operationId` missing a handler | `bind_routes` at startup |
| Operation declared but no `operationId` | `bind_routes` at startup |
| Missing / duplicate `operationId` in client | `OpenAPIClient(...)` construction |
---
## ⚙️ 2. What It Looks Like
### 2.1 Scaffold an application
```bash
openapi-first scaffold health_app my-health-service
openapi-first scaffold --list
```
`scaffold` copies a bundled template (verbatim — no code generation, no mutation) into a directory of your choice.
### 2.2 Bootstrap the server
```python
# main.py
from openapi_first.app import OpenAPIFirstApp
import routes
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=routes,
title="My Service",
)
```
Run with your FastAPI-compatible server (ASGI):
```bash
uvicorn main:app --reload
```
FastAPI itself drives the server; every route, response model, and security dependency comes from the spec. `/openapi.json` and Swagger UI always reflect the spec, byte-for-byte.
### 2.3 Talk to it with the client
```python
# client-side
from openapi_first.loader import load_openapi
from openapi_first.client import OpenAPIClient
spec = load_openapi("openapi.yaml")
client = OpenAPIClient(spec)
response = client.get_health() # operationId-driven call
print(response.status_code) # 200
print(response.json()) # {"status": "ok"}
```
`OpenAPIClient` builds one callable per `operationId` from the *same* spec, so the client can never drift from the server.
---
## 🧩 3. Where Things Live
| Concern | Module |
|------------------------|---------------------|
| Load + validate spec | `openapi_first.loader` |
| Boot FastAPI app | `openapi_first.app` |
| Bind routes by opId | `openapi_first.binder` |
| HTTP client | `openapi_first.client` |
| Error hierarchy | `openapi_first.errors` |
| Security dependencies | `openapi_first.security` |
| Pydantic model codegen | `openapi_first.codegen` (uses `datamodel_code_generator`) |
| Route stub codegen | `openapi_first.codegen_routes` |
| Models / routes CLI | `openapi_first.cli` |
| Bundled templates | `openapi_first.templates` |
Jurisdictions:
- `loader`, `app`, `binder`, `client`, `errors`, `security` are the **library API surface** — stable, tested, documented.
- `templates` are **copyable scaffolds** — not part of the library API; excluded from lint/format/type gates, never imported at runtime.
---
## 📄 4. The Non-Goals
`openapi-first` deliberately does **not**:
- Parse decorators to generate an OpenAPI schema (that's default FastAPI behavior — the inverse)
- Generate models from code at runtime (only at **build time** via CLI, from spec → Pydantic)
- Validate request/response bodies against the spec at runtime (contract is enforced at startup / client construction; FastAPI + Pydantic handle runtime coercion)
- Invent routing from path conventions — `operationId` binding only
- Ship a production feature set in the bundled templates (they're demos/scaffolds: in-memory stores, no concurrency, no auth configured)
---
## 🧭 5. Path Forward
New here? Start with [01 – Quickstart](03_use_cases/01_quickstart.md). Want to copy a runnable app? Jump to [02 – Templates](03_use_cases/02_templates.md). Digging into internals? See [Design](04_design.md).
---
## Related
- [01 – Quickstart](03_use_cases/01_quickstart.md) · [02 – Components](02_components.md) · [01 – Overview](01_overview.md)

208
docs/wiki/02_components.md Normal file
View File

@@ -0,0 +1,208 @@
# Components — What Ships in the Box
The library is deliberately small. Everything you need to run an OpenAPI-first service and talk to it with a strict client fits in a handful of modules.
---
## 🗂️ 1. Module Map
| Module | Responsibility | Import |
|---------------------------------|---------------------------------------------------|--------|
| `openapi_first.loader` | Load/validate the spec, resolve `{ENV_VAR}` | `load_openapi` |
| `openapi_first.app` | OpenAPI-first FastAPI application bootstrap | `OpenAPIFirstApp` |
| `openapi_first.binder` | Spec → route binding via `operationId` | `bind_routes` |
| `openapi_first.client` | `operationId`-driven HTTP client | `OpenAPIClient` |
| `openapi_first.errors` | Explicit error hierarchy | `OpenAPIFirstError`, `OpenAPIClientError`, `MissingOperationHandler` |
| `openapi_first.security` | Security-dependency construction from the spec | `parse_security_schemes`, `make_security_dependencies` |
| `openapi_first.codegen` | Pydantic model generation (build-time) | `generate_models` |
| `openapi_first.codegen_routes` | Route-handler stub generation (build-time) | `generate_routes` |
| `openapi_first.cli` | `scaffold` / `models` / `routes` command surface | CLI entry point |
| `openapi_first.templates` | Copyable application templates (NOT library API) | `openapi-first scaffold` |
---
## ⚙️ 2. `loader` — Load & Validate
`load_openapi(path: str | Path) -> dict[str, Any]` (in `openapi_first/loader.py`).
`loader.py` ensures a spec is real, readable, parseable, and schema-valid *before* anything else runs — a golden rule of fail-fast.
```python
from openapi_first.loader import load_openapi
spec = load_openapi("openapi.yaml")
```
Behavior:
- Accepts `.json`, `.yaml`, `.yml` (parsed by extension).
- Runs strict OpenAPI 3.x validation (`openapi-spec-validator`) at load time.
- Raises `OpenAPISpecLoadError` on: missing file, unparseable content, or spec-validation failure.
- Does **not** modify, coerce, or "fix" the spec — it's a read-only gate.
**Env-var resolution** lives at the *security* layer rather than the loader (see [Security](05_security.md)).
---
## 🧬 3. `app` — The Application Bootstrap
`OpenAPIFirstApp` (in `openapi_first/app.py`) is a FastAPI subclass that replaces manual route registration with OpenAPI-driven binding.
```python
from openapi_first.app import OpenAPIFirstApp
import routes
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=routes,
title="My Service",
)
```
**Startup pipeline (fail-fast, in this order):**
1. **Load** the spec (`.yaml`/`.json`).
2. **Validate** it against OpenAPI 3.x schema.
3. **Parse** `securitySchemes` and per-operation `security`.
4. **Build** per-route security dependencies.
5. **Bind** every path/method → handler by `operationId`; a missing handler, a missing `operationId` on a declared operation, or an unbound operation raises at startup.
**Guarantees:**
- Every route has a spec declaration (no undocumented routes).
- Every spec operation has a handler (no unhandled operations).
- Auth enforcement is spec-driven, not hand-wired.
- `/openapi.json` + Swagger UI always reflect the provided spec.
Keyword arguments beyond `openapi_path` / `routes_module` pass straight through to `fastapi.FastAPI` (it's a subclass — `title`, `version`, middleware, lifespan, … all work).
---
## 🔗 4. `binder` — Spec → Route Binding
`bind_routes(app, spec, routes_module, security_deps=None) -> None` (in `openapi_first/binder.py`).
This is the heart of the OpenAPI-first guarantee. For each `path` + HTTP method in the spec:
1. Reads the operation's `operationId`.
2. Looks up `routes_module.<operationId>` — a **plain callable**.
3. Registers a FastAPI `APIRoute` bound to that handler, injecting `Depends(...)` for any matching security requirements.
Failures are explicit and early:
| Condition | Raised |
|---------------------------------------|---------------------------------------|
| No `operationId` on an operation | `MissingOperationHandler` |
| `operationId` has no handler function | `MissingOperationHandler` |
| A path/method declared but unbound | `MissingOperationHandler` |
Handlers stay framework-agnostic: they're plain functions `(payload, id, response)` named after `operationId`s — no decorators, no routing metadata.
---
## 📡 5. `client` — The Other Side of the Contract
`OpenAPIClient(spec, base_url=None, client=None)` (in `openapi_first/client.py`).
The same spec that builds the server builds its client — one callable per `operationId`, keyed by name:
```python
from openapi_first.loader import load_openapi
from openapi_first.client import OpenAPIClient
spec = load_openapi("openapi.yaml")
client = OpenAPIClient(spec)
response = client.get_health()
response = client.get_user(path_params={"user_id": 1})
response = client.create_user(body={"name": "Ada"})
```
How operations become methods:
- Each `operationId` → a dynamically-built callable on the client.
- **Path parameters** → `path_params={"user_id": 1}`.
- **Request body** → `body={...}` (JSON) or raw content for non-JSON media types.
- **Query/headers** → `query=` / `headers=`.
- Returns the raw `httpx.Response` (no implicit deserialization, no hidden schema inference).
Client-construction guarantees (fail-fast, same philosophy as the app):
- Spec must declare at least one `servers` entry (`base_url` falls back to it).
- Spec must have `paths`.
- Every operation must have a **unique** `operationId`.
- Required params must match the spec at call time.
Construction errors raise `OpenAPIClientError`; request-time errors surface as `httpx` exceptions with `operationId` context.
---
## 💥 6. `errors` — Explicit Error Hierarchy
Everything raised by `openapi-first` derives from `OpenAPIFirstError` (in `openapi_first/errors.py`), so callers can handle first-party failures with a single `except`:
- `OpenAPIFirstError` — base (comparable / aims to be picklable share).
- `OpenAPIClientError` — client-side contract violations.
- `MissingOperationHandler` — spec declares an operation whose handler is missing or unresolvable; carries `path`, `method`, and optional `operationId`.
See [Error Handling](06_error_handling.md) for the full table.
---
## 🛡️ 7. `security` — Auth from the Spec
`security.py` turns an OpenAPI `securitySchemes` section into FastAPI `Depends(...)` objects — **no manual middleware**.
- `parse_security_schemes(spec) -> dict[str, dict]` — collects schemes, resolving `{ENV_VAR}` placeholders.
- `make_security_dependencies(spec, security_schemes) -> dict[str, list[Depends]]` — builds `METHOD:/path` → dependency list from per-operation `security`, falling back to top-level `security`.
Supported scheme types (extensible):
- `type: http, scheme: bearer` — `OpenAPIFirstSecurityDependency(HTTPBearer)`. With `x-introspect-path`/`x-server-url`, it introspects the JWT against an auth service; optionally sets `request.state.user`. Without introspection, it validates token presence and stores it on `request.state.token`.
- API-key style schemes via the same resolution path.
Key properties: resolution is **per-operation**, env placeholders resolve once at startup, and dependencies are injected by `binder` — your handlers never mention security.
---
## 🏭 8. `codegen` & `cli` — Build-Time Tooling
Generation is strictly **build-time** — spec → code, run once by a developer, committed:
### `openapi-first models <spec> -o <file>`
Generates Pydantic models from spec schemas (wraps `datamodel_code_generator`):
```python
# cli path
openapi-first models openapi.yaml -o app/models.py
```
### `openapi-first routes <spec> -o <dir> [--use-models] [--models-module models]`
Generates one `routes_<resource>.py` stub file per resource, with `NotImplementedError` handlers bound to `operationId`s (optionally importing your generated models):
```python
openapi-first routes openapi.yaml -o app/routes --use-models
```
### `openapi-first scaffold <template> [path]`
Copies a bundled application template verbatim (see [Templates](03_use_cases/02_templates.md)).
---
## 🧱 9. `templates` — Copyable Applications
Four bundled, runnable applications live under `openapi_first/templates/`: `health_app`, `crud_app`, `model_app`, and `vet_app`. They are **not** part of the library API — no lint/format/type gates, never imported at runtime. They exist to be copied via:
```bash
openapi-first scaffold <template> [target-dir]
openapi-first scaffold --list
```
---
## 🔗 Related
- [01 – Overview](01_overview.md) · [02 – Templates](03_use_cases/02_templates.md) · [03 – Security](05_security.md) · [04 – Design](04_design.md)

View File

@@ -0,0 +1,122 @@
# Use Case 1: Quickstart — Build Your First OpenAPI-First Service
This guide walks you from an empty directory to a running, contract-driven service in a few minutes, then talks to it with the generated client.
---
## 🛠️ 1. Prerequisites
- Python 3.10+
- `openapi-first` installed (see [Overview](../01_overview.md#installation))
- `pip install "fastapi[standard]"` (or `uvicorn`) to run the app
---
## 📄 2. Write the OpenAPI document
OpenAPI comes first. Create `openapi.yaml`:
```yaml
openapi: 3.0.3
info:
title: Greeting Service
version: 1.0.0
servers:
- url: http://localhost:8000
paths:
/greet/{name}:
get:
operationId: get_greeting
parameters:
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: A greeting
content:
application/json:
schema:
type: object
properties:
greeting:
type: string
```
Key points: every operation needs `operationId`, and every route must exist *only* here.
---
## 🧑‍💻 3. Write the handlers
Create `routes.py` — plain functions, no decorators, named exactly like the `operationId`s:
```python
# routes.py
def get_greeting(name: str) -> dict:
"""Return a greeting for the given name."""
return {"greeting": f"Hello, {name}!"}
```
If a handler is missing at startup, the app **refuses to boot** (`MissingOperationHandler`) — the fail-fast guarantee catches contract drift immediately.
---
## 🚀 4. Bootstrap the app
Create `main.py`:
```python
# main.py
from openapi_first.app import OpenAPIFirstApp
import routes
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=routes,
title="Greeting Service",
)
```
Run it:
```bash
uvicorn main:app --reload
```
Visit `http://localhost:8000/docs` (Swagger UI) and `http://localhost:8000/openapi.json` — both are generated from *your* spec.
---
## 📡 5. Call it with the client
The same spec builds a strict client:
```python
# client.py
from openapi_first.loader import load_openapi
from openapi_first.client import OpenAPIClient
spec = load_openapi("openapi.yaml")
client = OpenAPIClient(spec)
response = client.get_greeting(path_params={"name": "Ada"})
print(response.status_code) # 200
print(response.json()) # {"greeting": "Hello, Ada!"}
```
---
## 💡 6. Next Steps
- Copy a fuller example: [02 – Templates](02_templates.md)
- Generate models/routes from a bigger spec: [04 – Codegen](04_codegen.md)
- Drive everything from a client: [03 – Client](03_client.md)
---
## Related
- [01 – Overview](../01_overview.md) · [02 – Components](../02_components.md) · [01 – Quickstart](01_quickstart.md)

View File

@@ -0,0 +1,167 @@
# Use Case 2: Templates — Copyable Reference Applications
`openapi-first` ships four runnable, copyable applications under `openapi_first/templates/`. They are **not part of the library API** — they are bundled scaffold examples you copy into your own project and build on.
---
## 🎬 1. What Templates Are
A template is a complete, self-contained OpenAPI-first service:
- A bundled directory inside `openapi_first/templates/<name>/`
- Copyable **verbatim** — no code generation, no mutation — via the CLI
- Each one demonstrates a specific set of OpenAPI-first behaviors and FastAPI features
All templates share the same skeleton:
```
<name>_app/
├── __init__.py # explains the template + how to scaffold it
├── openapi.yaml # the contract (source of truth)
├── main.py # assembles OpenAPIFirstApp from the spec
├── routes.py # operationId-bound handler functions
└── data.py # in-memory data store (demo only)
```
---
## 📋 2. The Four Templates
### 2.1 `health_app` — minimal liveness probe
```bash
openapi-first scaffold health_app
# or into a custom directory:
openapi-first scaffold health_app my-health-service
```
| File | Purpose |
|------|---------|
| `openapi.yaml` | `GET /health` → `operationId: get_health` |
| `routes.py` | `get_health()` returns `{"status": "ok"}` |
| `main.py` | `OpenAPIFirstApp(openapi_path="openapi.yaml", routes_module=routes)` |
**Why it exists:** the absolute minimal OpenAPI-first round trip — one operation, one handler, zero moving parts. The best starting point to internalize the mental model.
**Smoke test:**
```bash
pip install -e .
uvicorn main:app
curl http://localhost:8000/health
# → {"status": "ok"}
```
### 2.2 `crud_app` — dict-based CRUD
```bash
openapi-first scaffold crud_app my-crud-service
```
| File | Purpose |
|------|---------|
| `openapi.yaml` | Full CRUD over `/items` (list/get/create/update/delete) |
| `routes.py` | Handlers bound via `operationId`s `list_items`, `get_item`, `create_item`, `update_item`, `delete_item` |
| `data.py` | In-memory dict store with auto-incrementing `id` |
Behaviors you learn:
- **Explicit status codes** — `create_item`/`delete_item` take `response: Response` and set `201`/`204`; `get_item`/`update_item` raise `HTTPException(404)` on `KeyError`
- **Handlers as plain callables** — no FastAPI decorators, routing comes solely from the spec
- **Mock data store** — `data.py` is a copyable in-memory store, explicitly **not** production-ready
### 2.3 `model_app` — Pydantic model CRUD
```bash
openapi-first scaffold model_app my-model-service
```
| File | Purpose |
|------|---------|
| `openapi.yaml` | Same CRUD surface, schemas reference models |
| `models.py` | Pydantic `Item`, `ItemCreate`, `ItemBase` (request/response models) |
| `routes.py` | Handlers type-annotated with the models; `create_item` sets `201` |
| `data.py` | In-memory store returning real model instances |
Behaviors you learn:
- **Pydantic request/response models** — payloads validated and serialized via FastAPI
- **Same handler contracts** — identical `operationId` set as `crud_app`, so the two are interchangeable
- **Models in the client too** — the same spec drives `OpenAPIClient` body handling
### 2.4 `vet_app` — the full-featured demo
```bash
openapi-first scaffold vet_app my-vet-clinic
```
| File | Purpose |
|------|---------|
| `openapi.yaml` | Five resources (parents, vets, treatments, pets, appointments) + SSE + upload + discriminated unions |
| `models.py` | Pydantic models incl. **discriminated unions** (`noteType` literal fields) |
| `routes.py` | ~20 handlers across all resources, incl. pagination, filtering, photo upload, SSE streaming |
| `sse.py` | Server-Sent Events helper (`StreamingResponse`, per-pet subscriber queues, background `asyncio` workers) |
| `data.py` | Larger in-memory store (parents → vets → treatments → pets → appointments) |
| `main.py` | App + CORS + lifespan example |
Behaviors you learn — the *advanced* tier:
- **Discriminated unions** — `ProcedureNotes` uses `oneOf` + `discriminator.noteType` mapping; Pydantic models use `Literal[...]` discriminator fields
- **SSE streaming** — a `GET /pets/{id}/actions` operation streaming `text/event-stream` via `StreamingResponse` with background task workers
- **File upload** — `UploadFile` handler setting a multi-part body
- **CORS + middleware** — `add_middleware(CORSMiddleware, ...)` alongside the spec-driven setup
- **Response injection** — handlers set `201`/`204` explicitly via injected `Response`
---
## 🚀 3. CLI Reference
```bash
# List available templates
openapi-first scaffold --list
# Copy a template into its default directory (template name, dashes)
openapi-first scaffold health_app
# Copy into a custom target directory
openapi-first scaffold crud_app my-project/crud
```
> **Protip:** `DEFAULT_TEMPLATE` is `health_app`, so `openapi-first scaffold` with no template name scaffolds the health app.
---
## 🧩 4. Anatomy of a Scaffolded Service
After `openapi-first scaffold health_app my-health-service`, your directory contains a drop-in FastAPI service:
```
my-health-service/
├── openapi.yaml # THE contract
├── main.py # `app = OpenAPIFirstApp(openapi_path=..., routes_module=routes)`
└── routes.py # `def get_health(): ...`
```
Run it:
```bash
cd my-health-service
pip install -e .
uvicorn main:app --reload
```
`/docs`, `/openapi.json`, and every declared route now exist — all derived from `openapi.yaml`.
---
## ⚠️ 5. Production Disclaimer
Templates use **in-memory, non-persistent, non-concurrency-safe** data stores. They are learning scaffolds — **not** production references. Swap in a real data layer (SQL/REDIS/object store) the moment you go beyond a demo.
See the `__init__.py` of each template for detailed client examples, CLI examples, and design notes.
---
## Related
- [01 – Quickstart](01_quickstart.md) · [04 – Codegen](04_codegen.md) · [02 – Components](../02_components.md)

View File

@@ -0,0 +1,156 @@
# Use Case 3: The OperationId-Driven Client
`OpenAPIClient` is the other side of the contract. It reads the *same* OpenAPI document the server runs on and exposes one callable per `operationId` — so "client" and "server" are two views of one truth.
---
## 🔍 1. Before You Start
The client is `httpx`-based and returns raw `httpx.Response` objects — no magic deserialization, no hidden schema inference:
- **No** response Pydantic models
- **No** implicit URL construction (path params are explicit)
- **No** hand-written `requests.get(...)` scattered through your code
---
## 🧬 2. Constructing the Client
```python
from openapi_first.loader import load_openapi
from openapi_first.client import OpenAPIClient
spec = load_openapi("openapi.yaml")
client = OpenAPIClient(spec)
```
The base URL comes from the spec's `servers` list (first entry) unless you pass `base_url` explicitly:
```python
client = OpenAPIClient(spec, base_url="https://api.internal.myco/v1")
```
You can also hand over a preconfigured `httpx.Client` (custom transport, TLS, retries):
```python
import httpx
transport = httpx.HTTPTransport(retries=3)
client = OpenAPIClient(
spec,
client=httpx.Client(transport=transport),
)
```
---
## 💥 3. Fail-Fast at Construction
`OpenAPIClient(...)` **raises immediately** if the contract is broken — you find out at startup, not on the first request:
| Violation | Error |
|----------------------------------------|-----------------------------------|
| Spec has no `servers` entry | `OpenAPIClientError` |
| Spec has no `paths` | `OpenAPIClientError` |
| Operation missing `operationId` | `OpenAPIClientError` |
| Duplicate `operationId` | `OpenAPIClientError` |
| Operation references unknown parameters| `OpenAPIClientError` |
Missing *required parameters* fail at **call time** — pydoclint-grade strictness on the wire.
---
## 📞 4. Calling Operations
Every `operationId` becomes a method. The call signature is uniform across the whole client:
```python
response = client.<operationId>(
*,
path_params: dict | None = None,
query: dict | None = None,
headers: dict | None = None,
body: Any | None = None,
timeout: float | None = None,
) -> httpx.Response
```
Concrete examples (from the `crud_app` / `health_app` templates):
```python
# No parameters — simplest
response = client.get_health()
assert response.status_code == 200
# Path parameter
response = client.get_item(path_params={"item_id": 3})
# Query parameters
response = client.list_items(query={"limit": 10, "offset": 20})
# JSON request body
response = client.create_item(body={"name": "Orange", "price": 0.8})
# Custom headers / timeout
response = client.get_user(
path_params={"user_id": 1},
headers={"X-Internal-Key": "..."},
timeout=30,
)
```
**Returns:** the raw `httpx.Response`, so `status_code`, `.json()`, `.headers` are all yours to inspect.
---
## 🧠 5. How Parameters Are Bound
For each operation the client knows exactly *where* each parameter belongs:
| OpenAPI location | Client kwarg |
|------------------|-------------------|
| `in: path` | `path_params[name]` |
| `in: query` | `query[name]` |
| `in: header` | `headers[name]` |
| `requestBody` | `body` |
JSON media types are sent as `json=`; other media types as raw `content=`.
---
## 🔄 6. Server ↔ Client, One Spec
```bash
# One directory, two processes
uvicorn main:app --port 8000 # server
python -c "import asyncio; from client_script import run; asyncio.run(run())" # client
```
Or the same client against a remote environment:
```python
client = OpenAPIClient(
spec,
base_url="https://staging.internal.myco",
)
```
The URL is the *only* thing that changes between environments — the contract never does.
---
## ✍️ 7. OperationId as the API
Because the client is operationId-driven:
- Adding an operation = adding a method (and vice versa)
- No operation can be "forgotten" by the client — it's constructed from the spec
- `operationId` is the one name you remember; the HTTP verb/path is an implementation detail
If an `operationId` you call doesn't exist, you get an `AttributeError` at construction-scan time — never a silent 404.
---
## Related
- [01 – Overview](../01_overview.md) · [01 – Quickstart](01_quickstart.md) · [02 – Templates](02_templates.md)

View File

@@ -0,0 +1,70 @@
# Codegen — Generate Models & Routes From Your Spec
> **TL;DR** — point `openapi-first codegen` at your spec and get `models` + `routes` scaffolds you can bind with one line. The codegen output is deterministic, and it's a *starting point* — not a maintained artifact.
---
## 🧭 1. Why Celebrate Codegen?
Two pain points kill OpenAPI projects:
1. **The "write the spec, then write the same thing as Pydantic models" step** — exactly where server/client param types drift (your route says `item_id: int`, your client sends a string…).
2. **Writing the docs/spec catalog by hand**, once you have more than a handful of operations.
Codegen collapses both. One command, one source file, same generated shapes everywhere.
---
## 🚀 2. Model Generation
```bash
openapi-first codegen models --module my_project.models --input openapi.yaml
```
The generated model module mirrors the spec's `components.schemas` **by name**:
| Spec | Generated |
|------|-----------|
| `components.schemas.User` | `class User(BaseModel)` |
| `components.schemas.Item` | `class Item(BaseModel)` |
| every `$ref` (schema) | a `type: ClassVar` alias ✓ |
| `required + type` from schema | Pydantic `Field(...)` / type hints ✓ |
**Never hand-edit those files.** If the spec changes, regenerate — just like you'd re-run `cargo build` after editing `Cargo.toml`.
### Why constructor-time validation still applies
Codegen doesn't change the design: the generated models are plain Pydantic, and the **client/server still validate against the spec** at startup. Codegen is a *convenience accelerator* on top of the fail-fast guarantees in [04 – Design](../04_design.md) and [06 – Error Handling](../06_error_handling.md).
---
## 🔁 3. Route Generation / Verification
```bash
# dry-run verification against your routes module
openapi-first codegen routes --module my_routes --input openapi.yaml --check
# scaffold an operation skeleton (generates the handler with a TODO)
openapi-first codegen routes --module my_routes --input openapi.yaml
```
Why bother? Because `bind_routes` (in [02 – Components](../02_components.md)) needs an `operationId` → handler **exactly** matching the spec. Codegen guarantees you never type a handler name wrong — it wears the same `operationId` as the spec says.
> **Note:** `codegen` is build-time tooling. It does **not** run at runtime, and templates (the Bake-your-stuff section of [02 – Templates](02_templates.md)) make scaffolding a server out-of-the-box even simpler for greenfield projects.
---
## 🧪 4. Idempotent Output
Generated output is deterministic w.r.t. the spec:
- Same spec → byte-identical files (unless you hand-edit — you won't)
- Ordering follows spec declaration order
- No timestamps, no machine names, no hidden randomness
That determinism is what lets you `git diff` after a spec change and see **exactly** what moved.
---
## 🧩 Related
- [01 – Overview](../01_overview.md) · [02 – Templates](02_templates.md) · [03 – Client](03_client.md) · [04 – Design](../04_design.md) · [07 – Testing](../07_testing.md)

87
docs/wiki/04_design.md Normal file
View File

@@ -0,0 +1,87 @@
# Design — Guarantees, Startup Pipeline, and the Contract Model
This page is the architecture reference: the fail-fast startup surface, the guarantees that hold by construction, and the trade-offs baked into every decision.
---
## 🏗️ 1. The Startup Pipeline
Everything happens **once, eagerly, at construction time** — for the app at `OpenAPIFirstApp(...)`, for the client at `OpenAPIClient(...)`. There is no lazy loading, no deferred binding, no "it'll work on first request".
```text
openapi.yaml ──► loader.load_openapi
├─ parse (json/yaml by extension)
├─ validate (strict OpenAPI 3.x validator)
└─► dict ──▶ OpenAPISpecLoadError
│
OpenAPIFirstApp(openapi_path=..., routes_module=routes)
├─ 1. load + validate spec (loader)
├─ 2. parse securitySchemes (security)
├─ 3. make_security_dependencies (security)
├─ 4. bind_routes: every operationId ──► routes.<operationId>
│ └─ missing op / missing handler ──► MissingOperationHandler
└─► FastAPI app: routes registry + /openapi.json + Swagger UI
```
Client — same spec, same guarantees:
```text
OpenAPIClient(spec)
├─ require servers[]
├─ require paths
├─ one callable per operationId (client.<operationId>)
│ └─ missing / duplicate opId ──► OpenAPIClientError
└─► ready
```
---
## 📜 2. Guarantees That Hold by Construction
These are not conventions — they are enforced at startup or client construction:
1. **Every route is spec-declared.** Routes are registered *only* from `paths`; there is no decorator-driven or implicit routing.
2. **Every operation is handled.** Each `operationId` resolves to an exact handler name in `routes_module`; a missing one aborts bootstrap.
3. **Every operation has an `operationId`.** An operation without one cannot be bound and fails fast.
4. **Client and server use the same spec.** One document drives both sides, so they cannot drift.
5. **Auth is spec-driven.** `security` dependencies come from `securitySchemes` + per-operation `security`; no manual middleware.
6. **Spec is valid before use.** Invalid, malformed, or unloadable specs are rejected at load, not at first request.
### The startup "no half-states" property
Because binding, validation, and security resolution all run at construction, a booted app is a **provably-complete app**. If the contract is violated in any way, the process refuses to start — the failure is loud, immediate, and tells you exactly what to fix.
---
## 🧩 3. Component Responsibilities
| Module | Owns | Refuses to |
|--------|------|------------|
| `loader` | parse + validate the spec | modify or "fix" the spec |
| `app` | assemble FastAPI from spec + handlers | routing decisions, decorators |
| `binder` | `operationId` → handler mapping | infer handlers from paths |
| `client` | build one callable per `operationId` | guess URLs, deserialize implicitly |
| `security` | schemes → FastAPI `Depends` | manual middleware |
| `errors` | the error hierarchy | swallow failures |
| `codegen` | build-time model/routes generation | runtime codegen |
| `templates` | copyable scaffolds | production data stores |
**Rule of thumb for contributors:** keep modules coercive (they raise if the contract is wrong) and narrow (one responsibility each). Pydoclint + the test suite keep that contract honest.
---
## ⚖️ 4. Design Trade-offs (Accepted)
| Decision | Chosen because | What you give up |
|----------|----------------|------------------|
| Handlers are plain callables | Framework-agnostic, testable, grep-able | No decorator sugar |
| Fail-fast at startup | Drift caught in CI, not at 3am | Slightly heavier boot |
| Raw `httpx.Response` from client | No hidden deserialization/validation | You read `.json()` yourself |
| Build-time codegen | One-way generation, no runtime generator dep | Spec must already be spec-valid |
| Templates copy verbatim | Scaffold, not magic | Templates never updated in place |
---
## 🔗 Related
- [01 – Overview](01_overview.md) · [02 – Components](02_components.md) · [05 – Security](05_security.md) · [06 – Error Handling](06_error_handling.md) · [07 – Testing](07_testing.md)

144
docs/wiki/05_security.md Normal file
View File

@@ -0,0 +1,144 @@
# Security — Auth Driven by the Spec, Not by Hand
The most distinctive thing about `openapi-first` security is that you **never write middleware or `Depends(authenticate)` calls yourself**. All authentication is *declared* in the OpenAPI document and *enforced* automatically.
---
## 🗺️ 1. Where Security Lives
Two places in the spec, both respected:
### 1.1 `components.securitySchemes` — the inventory
```yaml
components:
securitySchemes:
internalBearer:
type: http
scheme: bearer
bearerFormat: JWT
x-server-url: "{AUTH_SERVER_URL}"
x-introspect-path: "/introspect"
```
`x-server-url` and `x-introspect-path` are library extensions that point at a JWT introspection endpoint (see [Introspection](#-5-introspection)).
### 1.2 `security` — per-operation (or global) requirements
```yaml
security:
- internalBearer: [] # applied to every operation by default
paths:
/pets:
get:
operationId: list_pets
# inherits: security: [{internalBearer: []}]
post:
operationId: create_pet
security: [] # override — public endpoint
```
OpenAPI dynamic-scoping rules apply: an operation-level `security` **replaces** the global list (it does not merge).
---
## 📖 2. Reading the Spec
`security.py` exposes two functions:
| Function | Returns | Purpose |
|----------|---------|---------|
| `parse_security_schemes(spec)` | `dict[str, dict]` | Collect schemes, resolve `{ENV_VAR}` placeholders |
| `make_security_dependencies(spec, schemes)` | `dict[str, list[Depends]]` (keyed `METHOD:/path`) | Effective per-operation security deps |
Env placeholders of the form `{NAME}` are resolved **once at startup** from `os.environ`. This is how you avoid embedding credentials or auth-service URLs in the committed spec.
---
## 🔐 3. The Bearer Dependency
`make_security_dependencies` builds a FastAPI dependency for `type: http, scheme: bearer`.
Two modes, decided by whether an introspection endpoint is configured:
### 3.1 With introspection (`x-introspect-path`)
- Reads `Authorization: Bearer <token>`
- POSTs `{"token": "<token>"}` to `{x-server-url}{x-introspect-path}` synchronously via the bundled httpx client
- Expects a response with `active: true`
- On valid: `{"user": ...}` from the introspection body → `request.state.user`
- On failure: `401` (missing/invalid token) or `503` (auth service unreachable)
### 3.2 Without introspection
- Validates only that a Bearer token is present
- Stores it on `request.state.token`; no remote call
---
## 🔗 4. Wiring It Together
```python
# server-side
from openapi_first.loader import load_openapi
from openapi_first.security import (
parse_security_schemes,
make_security_dependencies,
)
from openapi_first.app import OpenAPIFirstApp
import routes
spec = load_openapi("openapi.yaml")
schemes = parse_security_schemes(spec)
security_deps = make_security_dependencies(spec, schemes)
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=routes,
)
```
`OpenAPIFirstApp` already does this internally — the snippet above shows what it encapsulates (and what you use directly if you assemble the pieces by hand).
---
## 🧪 5. Testing Security
Because handlers are plain callables, security is the *one* place FastAPI's `TestClient` earns its keep:
```python
from fastapi.testclient import TestClient
def test_unauthenticated_is_401(app, overrides):
with TestClient(app) as client:
r = client.get("/pets")
assert r.status_code == 401
def test_invalid_token_using_fake_introspector(app):
# Point x-introspect-path at a stub uvicorn/TestServer returning active:false
with TestClient(app) as client:
r = client.get("/pets", headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
```
See [Testing](07_testing.md) for the full recipe, including how tests stub the introspection server.
---
## 🛡️ 7. Common Patterns
| Pattern | How |
|---------|-----|
| Public endpoint | `security: []` on the operation |
| Whole-spec auth | top-level `security:` (applies to all) |
| Route-specific scheme | replace `security` on that operation |
| Env-driven auth URL | `x-server-url: "{AUTH_SERVER_URL}"` |
| Offline token carry | scheme without `x-introspect-path` → `request.state.token` |
| Auth on the client side | pass the token via `client.<operationId>(headers={"Authorization": ...})` |
---
## Related
- [02 – Components](02_components.md) · [04 – Design](04_design.md) · [06 – Error Handling](06_error_handling.md) · [07 – Testing](07_testing.md)

View File

@@ -0,0 +1,121 @@
# Error Handling — Fail Loud, Fail Early
`openapi-first` treats errors as **first-class contract documents**: every failure mode is a named exception with a stable import pathhare, and every one surfaces as early as possible.
---
## 🧬 1. The Hierarchy
All errors derive from `OpenAPIFirstError` (in `openapi_first/errors.py`), so a single `except OpenAPIFirstError` catches every first-party failure:
```text
OpenAPIFirstError
├── OpenAPISpecError # spec-level problems
│ └── OpenAPISpecLoadError # load / parse / validation (loader)
├── OpenAPIClientError # client-side contract issues (client)
└── MissingOperationHandler # spec op with no handler (binder)
# Security / loader layers raise through OpenAPISpecError subclasses too
```
| Exception | Module | Raised when |
|----------------------------------|-----------|-------------|
| `OpenAPISpecLoadError` | `loader` | Path missing, file unreadable, YAML/JSON invalid, or spec fails OpenAPI 3.x validation |
| `OpenAPIClientError` | `client` | No `servers`, no `paths`, missing/duplicate `operationId`, missing required params at construction |
| `MissingOperationHandler` | `errors` | An operation is declared whose `operationId` has no matching handler in `routes_module` |
---
## ⏱️ 2. When Things Fail
The single most important rule: **violations are eager, not lazy.**
### 2.1 At application startup
```python
# openapi.yaml missing ────────────────────────────► OpenAPISpecLoadError
# operationId without a handler ───────────────────► MissingOperationHandler
# operation with no operationId declared ──────────► MissingOperationHandler
```
Because these raise during `OpenAPIFirstApp(...)` construction, CI catches them the moment a spec and its routes drift — before a single request is served.
### 2.2 At client construction
```python
OpenAPIClient(spec) # fails fast, same philosophy
```
- Spec with no `servers` → `OpenAPIClientError`
- Spec with no `paths` → `OpenAPIClientError`
- Duplicate `operationId`s → `OpenAPIClientError` (client methods must be unambiguous)
- Operation missing `operationId` → `OpenAPIClientError`
### 2.3 At call time (client)
Runtime transport errors surface as `httpx` exceptions (`httpx.RequestError` family), not swallowed or remapped. Missing required args fail before any HTTP request is made:
```python
client.get_user(path_params={"user_id": ...}) # OK
client.get_user() # ValueError — user_id required
```
---
## 🧰 3. Handling in Your App
### 3.1 Server-side
Handlers raise FastAPI `HTTPException` for expected operation-level failures (404/422), and the `OperationId`-binding errors only exist at startup:
```python
from fastapi import HTTPException
def get_item(item_id: int):
"""Retrieve an item by ID.
Implements the OpenAPI operation ``get_item``.
Args:
item_id (int): Identifier of the item.
Raises:
HTTPException: If the item does not exist (404).
"""
try:
return _get_item(item_id)
except KeyError:
raise HTTPException(status_code=404, detail="Item not found")
```
### 3.2 Client-side
```python
import httpx
try:
response = client.get_item(path_params={"item_id": 1})
except httpx.HTTPStatusError as exc:
... # 4xx/5xx from the server
```
`httpx.HTTPStatusError` isn't raised by the library — it's the standard `httpx.raise_for_status()` you can opt into per call. The library never masks a response code.
---
## 🛡️ 4. Fail-Fast Guarantees Recap
| Layer | You write | The library guarantees |
|-------|-----------|------------------------|
| Loader | a spec path | unreadable/invalid specs never reach your app |
| Binder | handler functions | every operation must resolve, or the app won't start |
| Client | a spec | every operationId becomes a callable; missing ones fail at construction |
| Runtime | handler code | FastAPI + Pydantic handle coercion; contract checks already happened at startup |
None of these can silently degrade: a violation is an **exception at construction**, not a 500 at request time.
---
## Related
- [01 – Overview](01_overview.md) · [05 – Security](05_security.md) · [07 – Testing](07_testing.md)

137
docs/wiki/07_testing.md Normal file
View File

@@ -0,0 +1,137 @@
# Testing — Smoke-First, Contract-First
Everything `openapi-first` ships is tested against **real specs, real handlers, and real HTTP** through FastAPI's `TestClient` and the bundled templates. There are no fakes of the library itself.
---
## 🧪 1. Suite Overview
```
tests/
├── conftest.py # fixtures: spec_file, routes_module, app, client
├── test_app.py # OpenAPIFirstApp: routes served, overrides, fail-fast
├── test_binder.py # bind_routes: opId resolution + missing-handler failures
├── test_loader.py # load_openapi: json/yaml, env resolution, validation
└── test_client.py # OpenAPIClient: opId→callable, params, error cases
```
Run with:
```bash
pytest # 23 tests, zero mocks of the library
pytest -q
pytest tests/test_loader.py
```
---
## 🏗️ 2. The Fixture Pattern
```python
# conftest.py (abridged)
@pytest.fixture
def spec_file(tmp_path):
path = tmp_path / "openapi.json"
path.write_text(json.dumps(SPEC), encoding="utf-8")
return str(path)
@pytest.fixture
def app(spec_file):
return OpenAPIFirstApp(
openapi_path=spec_file,
routes_module=routes_module(),
)
@pytest.fixture
def client(spec_file):
return OpenAPIClient(json.loads(Path(spec_file).read_text()))
```
---
## 🧪 3. What's Actually Asserted
### 3.1 App-level smoke
```python
def test_app_routes_served(spec_file):
app = OpenAPIFirstApp(openapi_path=spec_file, routes_module=routes)
client = TestClient(app)
assert client.get("/health").json() == {"status": "ok"}
```
### 3.2 Fail-fast (the heart)
```python
def test_app_missing_handler_fails_at_startup(spec_file):
with pytest.raises(MissingOperationHandler):
OpenAPIFirstApp(openapi_path=spec_file, routes_module=empty_routes)
```
### 3.3 Loader validation
```python
def test_loader_invalid_spec_fails():
with pytest.raises(OpenAPISpecLoadError):
load_openapi("broken.yaml")
```
### 3.4 Client contract drift
```python
def test_client_duplicate_operation_id_raises():
with pytest.raises(OpenAPIClientError):
OpenAPIClient(dup_spec)
```
---
## 🏛️ 4. Testing the Templates
Each bundled template ships its own test + in-memory store, so you get a runnable contract test the moment you scaffold:
```bash
openapi-first scaffold crud_app my-service
cd my-service
pytest -q
```
`test_crud_app.py` / `test_model_app.py` / `test_vet_app.py` exercise the full CRUD surface through `TestClient` — including `201`/`204` status codes, 404s, and (in `vet_app`) SSE streaming via `StreamingResponse`.
---
## 🕹️ 5. Quality Gates (CI)
The same gates the library itself must pass are what keep the docs honest:
| Gate | Purpose |
|------|---------|
| `black --check` | formatting parity |
| `ruff check` | lint hygiene |
| `mypy` | type safety (strict, `--disable-error-code` where intentional) |
| `pytest` | 23 tests, green |
| coverage | tracked via pytest-cov (HTML + XML + term) |
Run the whole gate locally:
```bash
black --check openapi_first tests
ruff check openapi_first tests
mypy openapi_first
pytest
```
---
## 💡 6. Testing Tips
- **Start from startup:** assert `OpenAPIFirstApp(...)` *raises* for the broken contracts — those are your most valuable tests
- **Client ↔ server:** smoke a client against the same spec the app was built from — one spec, two sides, zero drift
- **Templates are scaffolds:** their tests are copyable starting points, not canonical suites
- **No mocking of the library:** exercise loader → binder → app → client as a real pipeline
---
## Related
- [01 – Overview](01_overview.md) · [02 – Components](02_components.md) · [04 – Error Handling](06_error_handling.md)

62
docs/wiki/index.md Normal file
View File

@@ -0,0 +1,62 @@
# 🧩 openapi-first — OpenAPI as the Single Source of Truth
`openapi-first` is a small, strict library for **OpenAPI-first FastAPI bootstrapping**: the OpenAPI document *is* the application contract — not an afterthought generated from decorators)Skip. Routes, schemas, security, and even the HTTP client are all derived from one specification.
> **Doc model:** this wiki is written for humans — how-to guides, examples, and usage recipes. The authoritative API contracts live in the code (docstrings) and the machine-readable bundle under `docs/mcp/`.
---
## 🚀 Key Features
* 📜 **One spec, two sides** — the same OpenAPI document boots both a FastAPI server (`OpenAPIFirstApp`) and a strict HTTP client (`OpenAPIClient`)
* 🔗 **`operationId` binding** — handler functions are bound to routes purely by `operationId`; no routing decorators
* 🧱 **Fail-fast contracts** — startup and client construction fail loudly when a handler, operation, or security scheme is missing
* 🛡️ **Spec-driven security** — `securitySchemes` + per-operation `security` auto-injected as FastAPI dependencies (Bearer JWT introspection included)
* 🧠 **Contract-first codegen** — `models` (Pydantic) and `routes` (resource stubs) generated from a spec
* 🧩 **Bundled templates** — `health_app`, `crud_app`, `model_app`, `vet_app` scaffolds for the whole lifecycle, from `/health` to SSE + multi-resource CRUD
---
## 📦 Installation
From your internal PyPI:
```bash
pip install --extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pip.aetoskia.com/simple openapi-first
```
From local source:
```bash
pip install -e .
```
---
## 📁 Documentation Structure
| Section | Description |
|---------|-------------|
| [Overview](01_overview.md) | The mental model: spec as contract, `operationId` binding, fail-fast guarantees |
| [Components](02_components.md) | `app`, `binder`, `loader`, `client`, `errors`, `security`, `codegen`, `cli` |
| **Use cases** | Step-by-step recipes |
| · [01 – Quickstart](03_use_cases/01_quickstart.md) | Scaffold your first OpenAPI-first service |
| · [02 – Templates](03_use_cases/02_templates.md) | The bundled application templates and how to use them |
| · [03 – Client setup](03_use_cases/03_client.md) | Build an `operationId`-driven HTTP client |
| · [04 – Codegen](03_use_cases/04_codegen.md) | Generate Pydantic models and route stubs from a spec |
| [Design](04_design.md) | Architecture, responsibilities, and startup pipeline |
| [Security](05_security.md) | `securitySchemes`, env resolution, and JWT introspection |
| [Error Handling](06_error_handling.md) | The error hierarchy and when each error is raised |
| [Testing](07_testing.md) | Test strategy, quality gates, and coverage |
---
## 🔗 Related Resources
* **Source Code:** [Gitea Repository](https://git.aetoskia.com/aetos/openapi-first)
* **Internal PyPI:** [pip.aetoskia.com/simple/openapi-first](https://pip.aetoskia.com/simple/openapi-first)
* **Drone CI:** Auto-builds and publishes tagged releases, gated on black / ruff / mypy / pytest.
---
© Aetoskia Internal — `openapi-first` 0.0.6

View File

@@ -70,6 +70,23 @@ class OpenAPIFirstApp(FastAPI):
""" """
FastAPI application enforcing OpenAPI-first design. FastAPI application enforcing OpenAPI-first design.
Args:
openapi_path (str):
Filesystem path to the OpenAPI 3.x specification file. This
specification is treated as the authoritative API contract.
routes_module (ModuleType):
Python module containing handler functions whose names correspond
exactly to OpenAPI ``operationId`` values.
**fastapi_kwargs (Any):
Additional keyword arguments passed directly to
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
handlers).
Raises:
OpenAPIFirstError:
If the OpenAPI specification is invalid, or if any declared
``operationId`` does not have a corresponding handler function.
Notes: Notes:
**Responsibilities:** **Responsibilities:**
@@ -92,7 +109,7 @@ class OpenAPIFirstApp(FastAPI):
Example: Example:
```python ```python
from openapi_first import OpenAPIFirstApp from openapi_first.app import OpenAPIFirstApp
import app.routes as routes import app.routes as routes
app = OpenAPIFirstApp( app = OpenAPIFirstApp(
@@ -110,26 +127,6 @@ class OpenAPIFirstApp(FastAPI):
routes_module: ModuleType, routes_module: ModuleType,
**fastapi_kwargs: Any, **fastapi_kwargs: Any,
): ):
"""
Initialize the application.
Args:
openapi_path (str):
Filesystem path to the OpenAPI 3.x specification file. This
specification is treated as the authoritative API contract.
routes_module (ModuleType):
Python module containing handler functions whose names correspond
exactly to OpenAPI ``operationId`` values.
**fastapi_kwargs (Any):
Additional keyword arguments passed directly to
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
handlers).
Raises:
OpenAPIFirstError:
If the OpenAPI specification is invalid, or if any declared
``operationId`` does not have a corresponding handler function.
"""
# Initialize FastAPI normally # Initialize FastAPI normally
super().__init__(**fastapi_kwargs) super().__init__(**fastapi_kwargs)

View File

@@ -50,6 +50,20 @@ class OpenAPIClient:
""" """
OpenAPI-first HTTP client (`httpx`-based). OpenAPI-first HTTP client (`httpx`-based).
Args:
spec (dict[str, Any]):
Parsed OpenAPI 3.x specification.
base_url (str | None):
Base URL of the target service. If omitted, the first entry in the
OpenAPI `servers` list is used.
client (httpx.Client | None):
Optional preconfigured httpx client instance.
Raises:
OpenAPIClientError:
If no servers are defined, spec has no paths, operationIds are
missing/duplicate, or required parameters are missing.
Notes: Notes:
**Responsibilities:** **Responsibilities:**
@@ -92,21 +106,6 @@ class OpenAPIClient:
base_url: str | None = None, base_url: str | None = None,
client: httpx.Client | None = None, client: httpx.Client | None = None,
) -> None: ) -> None:
"""
Initialize the OpenAPI client.
Args:
spec (dict[str, Any]):
Parsed OpenAPI 3.x specification.
base_url (str | None):
Base URL of the target service. If omitted, the first entry in the OpenAPI `servers` list is used.
client (httpx.Client | None):
Optional preconfigured httpx client instance.
Raises:
OpenAPIClientError:
If no servers are defined, spec has no paths, operationIds are missing/duplicate, or required parameters are missing.
"""
self.spec = spec self.spec = spec
self.base_url = base_url or self._resolve_base_url(spec) self.base_url = base_url or self._resolve_base_url(spec)
self.client = client or httpx.Client(base_url=self.base_url) self.client = client or httpx.Client(base_url=self.base_url)

View File

@@ -40,6 +40,14 @@ class MissingOperationHandler(OpenAPIFirstError):
""" """
Raised when an OpenAPI operation cannot be resolved to a handler. Raised when an OpenAPI operation cannot be resolved to a handler.
Args:
path (str):
The HTTP path declared in the OpenAPI specification.
method (str):
The HTTP method (as declared in the OpenAPI spec).
operation_id (str | None):
The operationId declared in the OpenAPI spec, if present.
Notes: Notes:
**Scenarios:** **Scenarios:**
@@ -55,17 +63,6 @@ class MissingOperationHandler(OpenAPIFirstError):
""" """
def __init__(self, *, path: str, method: str, operation_id: str | None = None): def __init__(self, *, path: str, method: str, operation_id: str | None = None):
"""
Initialize the error.
Args:
path (str):
The HTTP path declared in the OpenAPI specification.
method (str):
The HTTP method (as declared in the OpenAPI spec).
operation_id (str | None):
The operationId declared in the OpenAPI spec, if present.
"""
if operation_id: if operation_id:
message = ( message = (
f"Missing handler for operationId '{operation_id}' " f"Missing handler for operationId '{operation_id}' "

View File

@@ -25,15 +25,15 @@ Scaffolding via CLI
Create a new CRUD example service using the bundled template: Create a new CRUD example service using the bundled template:
openapi-first crud_app openapi-first scaffold crud_app
Create the service in a custom directory: Create the service in a custom directory:
openapi-first crud_app my-crud-service openapi-first scaffold crud_app my-crud-service
List all available application templates: List all available application templates:
openapi-first --list openapi-first scaffold --list
The CLI copies template files verbatim into the target directory. The CLI copies template files verbatim into the target directory.
No code is generated or modified beyond the copied scaffold. No code is generated or modified beyond the copied scaffold.

View File

@@ -33,7 +33,7 @@ _items: Dict[int, dict] = {
_next_id = 3 _next_id = 3
def list_items(): def list_items() -> list[dict]:
""" """
Return all items in the data store. Return all items in the data store.
@@ -48,7 +48,7 @@ def list_items():
return list(_items.values()) return list(_items.values())
def get_item(item_id: int): def get_item(item_id: int) -> dict:
""" """
Retrieve a single item by ID. Retrieve a single item by ID.
@@ -68,7 +68,7 @@ def get_item(item_id: int):
return _items[item_id] return _items[item_id]
def create_item(payload: dict): def create_item(payload: dict) -> dict:
""" """
Create a new item in the data store. Create a new item in the data store.
@@ -92,7 +92,7 @@ def create_item(payload: dict):
return item return item
def update_item(item_id: int, payload: dict): def update_item(item_id: int, payload: dict) -> dict:
""" """
Replace an existing item in the data store. Replace an existing item in the data store.

View File

@@ -29,7 +29,7 @@ from data import (
) )
def list_items(): def list_items() -> list[dict]:
""" """
List all items. List all items.
@@ -44,7 +44,7 @@ def list_items():
return _list_items() return _list_items()
def get_item(item_id: int): def get_item(item_id: int) -> dict:
""" """
Retrieve a single item by ID. Retrieve a single item by ID.
@@ -72,7 +72,7 @@ def get_item(item_id: int):
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
def create_item(payload: dict, response: Response): def create_item(payload: dict, response: Response) -> dict:
""" """
Create a new item. Create a new item.
@@ -83,7 +83,7 @@ def create_item(payload: dict, response: Response):
---------- ----------
payload : dict payload : dict
Item attributes excluding the ``id`` field. Item attributes excluding the ``id`` field.
response : fastapi.Response response : Response
Response object used to set the HTTP status code. Response object used to set the HTTP status code.
Returns Returns
@@ -96,7 +96,7 @@ def create_item(payload: dict, response: Response):
return item return item
def update_item(item_id: int, payload: dict): def update_item(item_id: int, payload: dict) -> dict:
""" """
Update an existing item. Update an existing item.
@@ -126,7 +126,7 @@ def update_item(item_id: int, payload: dict):
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
def delete_item(item_id: int, response: Response): def delete_item(item_id: int, response: Response) -> None:
""" """
Delete an existing item. Delete an existing item.
@@ -137,7 +137,7 @@ def delete_item(item_id: int, response: Response):
---------- ----------
item_id : int item_id : int
Identifier of the item to delete. Identifier of the item to delete.
response : fastapi.Response response : Response
Response object used to set the HTTP status code. Response object used to set the HTTP status code.
Returns Returns

View File

@@ -24,15 +24,15 @@ Scaffolding via CLI
Create a new OpenAPI-first health check service using the bundled Create a new OpenAPI-first health check service using the bundled
template: template:
openapi-first health_app openapi-first scaffold health_app
Create the service in a custom directory: Create the service in a custom directory:
openapi-first health_app my-health-service openapi-first scaffold health_app my-health-service
List all available application templates: List all available application templates:
openapi-first --list openapi-first scaffold --list
The CLI copies template files verbatim into the target directory. The CLI copies template files verbatim into the target directory.
No code is generated or modified beyond the copied scaffold. No code is generated or modified beyond the copied scaffold.

View File

@@ -13,7 +13,7 @@ This module serves solely as an operationId namespace.
""" """
def get_health(): def get_health() -> dict:
""" """
Health check operation handler. Health check operation handler.

View File

@@ -28,15 +28,15 @@ Scaffolding via CLI
Create a new model-based CRUD example service using the bundled template: Create a new model-based CRUD example service using the bundled template:
openapi-first model_app openapi-first scaffold model_app
Create the service in a custom directory: Create the service in a custom directory:
openapi-first model_app my-model-service openapi-first scaffold model_app my-model-service
List all available application templates: List all available application templates:
openapi-first --list openapi-first scaffold --list
The CLI copies template files verbatim into the target directory. The CLI copies template files verbatim into the target directory.
No code is generated or modified beyond the copied scaffold. No code is generated or modified beyond the copied scaffold.

View File

@@ -62,6 +62,8 @@ def get_item(item_id: int) -> Item:
KeyError KeyError
If the item does not exist. If the item does not exist.
""" """
if item_id not in _items:
raise KeyError(item_id)
return _items[item_id] return _items[item_id]
@@ -134,4 +136,6 @@ def delete_item(item_id: int) -> None:
KeyError KeyError
If the item does not exist. If the item does not exist.
""" """
if item_id not in _items:
raise KeyError(item_id)
del _items[item_id] del _items[item_id]

View File

@@ -26,7 +26,7 @@ from data import (
) )
def list_items(): def list_items() -> list[Item]:
""" """
List all items. List all items.
@@ -41,7 +41,7 @@ def list_items():
return _list_items() return _list_items()
def get_item(item_id: int): def get_item(item_id: int) -> Item:
""" """
Retrieve a single item by ID. Retrieve a single item by ID.
@@ -69,7 +69,7 @@ def get_item(item_id: int):
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
def create_item(payload: ItemCreate, response: Response): def create_item(payload: ItemCreate, response: Response) -> Item:
""" """
Create a new item. Create a new item.
@@ -80,7 +80,7 @@ def create_item(payload: ItemCreate, response: Response):
---------- ----------
payload : ItemCreate payload : ItemCreate
Request body describing the item to create. Request body describing the item to create.
response : fastapi.Response response : Response
Response object used to set the HTTP status code. Response object used to set the HTTP status code.
Returns Returns
@@ -93,7 +93,7 @@ def create_item(payload: ItemCreate, response: Response):
return item return item
def update_item(item_id: int, payload: ItemCreate): def update_item(item_id: int, payload: ItemCreate) -> Item:
""" """
Update an existing item. Update an existing item.
@@ -123,7 +123,7 @@ def update_item(item_id: int, payload: ItemCreate):
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
def delete_item(item_id: int, response: Response): def delete_item(item_id: int, response: Response) -> None:
""" """
Delete an existing item. Delete an existing item.
@@ -134,7 +134,7 @@ def delete_item(item_id: int, response: Response):
---------- ----------
item_id : int item_id : int
Identifier of the item to delete. Identifier of the item to delete.
response : fastapi.Response response : Response
Response object used to set the HTTP status code. Response object used to set the HTTP status code.
Returns Returns

View File

@@ -50,11 +50,11 @@ Scaffolding via CLI
Create a new vet clinic service using the bundled template: Create a new vet clinic service using the bundled template:
openapi-first vet_app openapi-first scaffold vet_app
Create the service in a custom directory: Create the service in a custom directory:
openapi-first vet_app my-vet-clinic openapi-first scaffold vet_app my-vet-clinic
---------------------------------------------------------------------- ----------------------------------------------------------------------
Client Usage Example Client Usage Example

View File

@@ -43,7 +43,6 @@ from data import (
create_pet as _create_pet, create_pet as _create_pet,
update_pet as _update_pet, update_pet as _update_pet,
delete_pet as _delete_pet, delete_pet as _delete_pet,
get_pet as _get_pet,
list_appointments as _list_appointments, list_appointments as _list_appointments,
get_appointment as _get_appointment, get_appointment as _get_appointment,
create_appointment as _create_appointment, create_appointment as _create_appointment,
@@ -57,7 +56,7 @@ from data import (
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def list_parents(limit: int = 20, offset: int = 0): def list_parents(limit: int = 20, offset: int = 0) -> dict:
"""List parents (paginated). """List parents (paginated).
Parameters Parameters
@@ -76,13 +75,15 @@ def list_parents(limit: int = 20, offset: int = 0):
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]} return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
def create_parent(payload: ParentCreate, response: Response): def create_parent(payload: ParentCreate, response: Response) -> Parent:
"""Create a parent. """Create a parent.
Parameters Parameters
---------- ----------
payload : ParentCreate payload : ParentCreate
Parent data excluding the ``id`` field. Parent data excluding the ``id`` field.
response : Response
Response object used to set the HTTP status code.
Returns Returns
------- -------
@@ -94,7 +95,7 @@ def create_parent(payload: ParentCreate, response: Response):
return parent return parent
def get_parent(id: int): def get_parent(id: int) -> Parent:
"""Retrieve a single parent by ID. """Retrieve a single parent by ID.
Parameters Parameters
@@ -118,7 +119,7 @@ def get_parent(id: int):
raise HTTPException(status_code=404, detail="Parent not found") raise HTTPException(status_code=404, detail="Parent not found")
def update_parent(id: int, payload: ParentCreate): def update_parent(id: int, payload: ParentCreate) -> Parent:
"""Update an existing parent. """Update an existing parent.
Parameters Parameters
@@ -144,13 +145,15 @@ def update_parent(id: int, payload: ParentCreate):
raise HTTPException(status_code=404, detail="Parent not found") raise HTTPException(status_code=404, detail="Parent not found")
def delete_parent(id: int, response: Response): def delete_parent(id: int, response: Response) -> None:
"""Delete an existing parent. """Delete an existing parent.
Parameters Parameters
---------- ----------
id : int id : int
Identifier of the parent. Identifier of the parent.
response : Response
Response object used to set the HTTP status code.
Raises Raises
------ ------
@@ -212,7 +215,7 @@ def delete_vet(id: int, response: Response):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def list_treatments(): def list_treatments() -> list[Treatment]:
"""List treatments (catalogue). """List treatments (catalogue).
Returns Returns
@@ -298,7 +301,7 @@ def delete_pet(id: int, response: Response):
response.status_code = 204 response.status_code = 204
def upload_pet_photo(id: int, file: UploadFile): def upload_pet_photo(id: int, file: UploadFile) -> dict:
"""Upload a pet photo. """Upload a pet photo.
Parameters Parameters