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:
@@ -1,4 +1,3 @@
|
||||
# openapi_first
|
||||
|
||||
::: openapi_first
|
||||
- [Openapi First](openapi_first/)
|
||||
|
||||
74
docs/mkdocs.wiki.yml
Normal file
74
docs/mkdocs.wiki.yml
Normal 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
157
docs/wiki/01_overview.md
Normal 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
208
docs/wiki/02_components.md
Normal 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)
|
||||
122
docs/wiki/03_use_cases/01_quickstart.md
Normal file
122
docs/wiki/03_use_cases/01_quickstart.md
Normal 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)
|
||||
167
docs/wiki/03_use_cases/02_templates.md
Normal file
167
docs/wiki/03_use_cases/02_templates.md
Normal 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)
|
||||
156
docs/wiki/03_use_cases/03_client.md
Normal file
156
docs/wiki/03_use_cases/03_client.md
Normal 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)
|
||||
70
docs/wiki/03_use_cases/04_codegen.md
Normal file
70
docs/wiki/03_use_cases/04_codegen.md
Normal 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
87
docs/wiki/04_design.md
Normal 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
144
docs/wiki/05_security.md
Normal 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)
|
||||
121
docs/wiki/06_error_handling.md
Normal file
121
docs/wiki/06_error_handling.md
Normal 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
137
docs/wiki/07_testing.md
Normal 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
62
docs/wiki/index.md
Normal 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
|
||||
Reference in New Issue
Block a user