diff --git a/docs/lib/index.md b/docs/lib/index.md index 767439d..ee4b379 100644 --- a/docs/lib/index.md +++ b/docs/lib/index.md @@ -1,4 +1,3 @@ # openapi_first ::: openapi_first -- [Openapi First](openapi_first/) diff --git a/docs/mkdocs.wiki.yml b/docs/mkdocs.wiki.yml new file mode 100644 index 0000000..d27639e --- /dev/null +++ b/docs/mkdocs.wiki.yml @@ -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 diff --git a/docs/wiki/01_overview.md b/docs/wiki/01_overview.md new file mode 100644 index 0000000..acd4b1b --- /dev/null +++ b/docs/wiki/01_overview.md @@ -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) diff --git a/docs/wiki/02_components.md b/docs/wiki/02_components.md new file mode 100644 index 0000000..0e06f84 --- /dev/null +++ b/docs/wiki/02_components.md @@ -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.` β€” 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 -o ` + +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 -o [--use-models] [--models-module models]` + +Generates one `routes_.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