Compare commits
11 Commits
2f444a93ad
...
0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c1579f40 | |||
| a3c063b569 | |||
| 72b5be6976 | |||
| 6180443327 | |||
| 1b26021725 | |||
| a74e3d0d01 | |||
| 31bf1b1b6b | |||
| 7b4583f305 | |||
| fc8346fcda | |||
| 40d91bc52b | |||
| 2ac342240b |
3
docs/openapi_first/client.md
Normal file
3
docs/openapi_first/client.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Client
|
||||||
|
|
||||||
|
::: openapi_first.client
|
||||||
3
docs/openapi_first/templates/crud_app/data.md
Normal file
3
docs/openapi_first/templates/crud_app/data.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Data
|
||||||
|
|
||||||
|
::: openapi_first.templates.crud_app.data
|
||||||
3
docs/openapi_first/templates/crud_app/index.md
Normal file
3
docs/openapi_first/templates/crud_app/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Crud App
|
||||||
|
|
||||||
|
::: openapi_first.templates.crud_app
|
||||||
3
docs/openapi_first/templates/crud_app/main.md
Normal file
3
docs/openapi_first/templates/crud_app/main.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Main
|
||||||
|
|
||||||
|
::: openapi_first.templates.crud_app.main
|
||||||
3
docs/openapi_first/templates/crud_app/routes.md
Normal file
3
docs/openapi_first/templates/crud_app/routes.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Routes
|
||||||
|
|
||||||
|
::: openapi_first.templates.crud_app.routes
|
||||||
3
docs/openapi_first/templates/crud_app/test_crud_app.md
Normal file
3
docs/openapi_first/templates/crud_app/test_crud_app.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Test Crud App
|
||||||
|
|
||||||
|
::: openapi_first.templates.crud_app.test_crud_app
|
||||||
3
docs/openapi_first/templates/health_app/index.md
Normal file
3
docs/openapi_first/templates/health_app/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Health App
|
||||||
|
|
||||||
|
::: openapi_first.templates.health_app
|
||||||
3
docs/openapi_first/templates/health_app/main.md
Normal file
3
docs/openapi_first/templates/health_app/main.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Main
|
||||||
|
|
||||||
|
::: openapi_first.templates.health_app.main
|
||||||
3
docs/openapi_first/templates/health_app/routes.md
Normal file
3
docs/openapi_first/templates/health_app/routes.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Routes
|
||||||
|
|
||||||
|
::: openapi_first.templates.health_app.routes
|
||||||
3
docs/openapi_first/templates/index.md
Normal file
3
docs/openapi_first/templates/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Templates
|
||||||
|
|
||||||
|
::: openapi_first.templates
|
||||||
3
docs/openapi_first/templates/model_app/data.md
Normal file
3
docs/openapi_first/templates/model_app/data.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Data
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app.data
|
||||||
3
docs/openapi_first/templates/model_app/index.md
Normal file
3
docs/openapi_first/templates/model_app/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Model App
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app
|
||||||
3
docs/openapi_first/templates/model_app/main.md
Normal file
3
docs/openapi_first/templates/model_app/main.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Main
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app.main
|
||||||
3
docs/openapi_first/templates/model_app/models.md
Normal file
3
docs/openapi_first/templates/model_app/models.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Models
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app.models
|
||||||
3
docs/openapi_first/templates/model_app/routes.md
Normal file
3
docs/openapi_first/templates/model_app/routes.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Routes
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app.routes
|
||||||
3
docs/openapi_first/templates/model_app/test_model_app.md
Normal file
3
docs/openapi_first/templates/model_app/test_model_app.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Test Model App
|
||||||
|
|
||||||
|
::: openapi_first.templates.model_app.test_model_app
|
||||||
@@ -68,28 +68,42 @@ def generate_docs_from_nav(
|
|||||||
|
|
||||||
docs_root.mkdir(parents=True, exist_ok=True)
|
docs_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for py_file in package_dir.rglob("*.py"):
|
# Collect all package directories (those containing __init__.py)
|
||||||
rel = py_file.relative_to(project_root)
|
package_dirs: set[Path] = {
|
||||||
|
p.parent
|
||||||
|
for p in package_dir.rglob("__init__.py")
|
||||||
|
}
|
||||||
|
|
||||||
if py_file.name == "__init__.py":
|
for pkg_dir in sorted(package_dirs):
|
||||||
# Package → index.md
|
rel_pkg = pkg_dir.relative_to(project_root)
|
||||||
module_path = ".".join(rel.parent.parts)
|
module_base = ".".join(rel_pkg.parts)
|
||||||
md_path = docs_root / rel.parent / "index.md"
|
|
||||||
title = rel.parent.name.replace("_", " ").title()
|
|
||||||
else:
|
|
||||||
# Regular module → <module>.md
|
|
||||||
module_path = ".".join(rel.with_suffix("").parts)
|
|
||||||
md_path = docs_root / rel.with_suffix(".md")
|
|
||||||
title = md_path.stem.replace("_", " ").title()
|
|
||||||
|
|
||||||
md_path.parent.mkdir(parents=True, exist_ok=True)
|
# index.md for the package itself
|
||||||
|
index_md = docs_root / rel_pkg / "index.md"
|
||||||
|
index_md.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
content = f"""# {title}
|
title = pkg_dir.name.replace("_", " ").title()
|
||||||
|
index_md.write_text(
|
||||||
|
f"# {title}\n\n::: {module_base}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
::: {module_path}
|
# Document modules inside this package only
|
||||||
"""
|
for py_file in pkg_dir.iterdir():
|
||||||
|
if (
|
||||||
|
py_file.suffix != ".py"
|
||||||
|
or py_file.name == "__init__.py"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
md_path.write_text(content, encoding="utf-8")
|
module_path = f"{module_base}.{py_file.stem}"
|
||||||
|
md_path = docs_root / rel_pkg / f"{py_file.stem}.md"
|
||||||
|
|
||||||
|
title = py_file.stem.replace("_", " ").title()
|
||||||
|
md_path.write_text(
|
||||||
|
f"# {title}\n\n::: {module_path}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_mkdocs_config():
|
def load_mkdocs_config():
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ nav:
|
|||||||
- OpenAPI-First App: openapi_first/app.md
|
- OpenAPI-First App: openapi_first/app.md
|
||||||
- Route Binder: openapi_first/binder.md
|
- Route Binder: openapi_first/binder.md
|
||||||
- Spec Loaders: openapi_first/loader.md
|
- Spec Loaders: openapi_first/loader.md
|
||||||
|
- Client: openapi_first/client.md
|
||||||
|
|
||||||
- CLI:
|
- CLI:
|
||||||
- Home: openapi_first/cli.md
|
- Home: openapi_first/cli.md
|
||||||
|
|
||||||
|
- Templates:
|
||||||
|
- Home: openapi_first/templates/index.md
|
||||||
|
- Health App: openapi_first/templates/health_app/index.md
|
||||||
|
- CRUD App: openapi_first/templates/crud_app/index.md
|
||||||
|
- Model App: openapi_first/templates/model_app/index.md
|
||||||
|
|
||||||
- Errors:
|
- Errors:
|
||||||
- Error Hierarchy: openapi_first/errors.md
|
- Error Hierarchy: openapi_first/errors.md
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ convenience facades.
|
|||||||
Architecture Overview
|
Architecture Overview
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
The library is structured around three core responsibilities:
|
The library is structured around four core responsibilities:
|
||||||
|
|
||||||
- loader: load and validate OpenAPI 3.x specifications (JSON/YAML)
|
- loader: load and validate OpenAPI 3.x specifications (JSON/YAML)
|
||||||
- binder: bind OpenAPI operations to FastAPI routes via operationId
|
- binder: bind OpenAPI operations to FastAPI routes via operationId
|
||||||
- app: OpenAPI-first FastAPI application bootstrap
|
- app: OpenAPI-first FastAPI application bootstrap
|
||||||
|
- client: OpenAPI-first HTTP client driven by the same specification
|
||||||
- errors: explicit error hierarchy for contract violations
|
- errors: explicit error hierarchy for contract violations
|
||||||
|
|
||||||
The package root acts as a **namespace**, not a facade. Consumers are
|
The package root acts as a **namespace**, not a facade. Consumers are
|
||||||
@@ -40,7 +41,8 @@ Or with Poetry:
|
|||||||
poetry add openapi-first
|
poetry add openapi-first
|
||||||
|
|
||||||
Runtime dependencies are intentionally minimal:
|
Runtime dependencies are intentionally minimal:
|
||||||
- fastapi
|
- fastapi (server-side)
|
||||||
|
- httpx (client-side)
|
||||||
- openapi-spec-validator
|
- openapi-spec-validator
|
||||||
- pyyaml (optional, for YAML specs)
|
- pyyaml (optional, for YAML specs)
|
||||||
|
|
||||||
@@ -48,7 +50,33 @@ The ASGI server (e.g., uvicorn) is an application-level dependency and is
|
|||||||
not bundled with this library.
|
not bundled with this library.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
Basic Usage
|
Command-Line Interface (Scaffolding, Templates)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
FastAPI OpenAPI First ships with a small CLI for bootstrapping
|
||||||
|
OpenAPI-first FastAPI applications from bundled templates.
|
||||||
|
|
||||||
|
List available application templates:
|
||||||
|
|
||||||
|
openapi-first --list
|
||||||
|
|
||||||
|
Create a new application using the default template:
|
||||||
|
|
||||||
|
openapi-first
|
||||||
|
|
||||||
|
Create a new application using a specific template:
|
||||||
|
|
||||||
|
openapi-first health_app
|
||||||
|
|
||||||
|
Create a new application in a custom directory:
|
||||||
|
|
||||||
|
openapi-first health_app my-service
|
||||||
|
|
||||||
|
The CLI copies template files verbatim into the target directory.
|
||||||
|
No code is generated or modified beyond the copied scaffold.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Server-Side Usage (OpenAPI → FastAPI)
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
Minimal OpenAPI-first FastAPI application:
|
Minimal OpenAPI-first FastAPI application:
|
||||||
@@ -57,7 +85,7 @@ Minimal OpenAPI-first FastAPI application:
|
|||||||
import my_service.routes as routes
|
import my_service.routes as routes
|
||||||
|
|
||||||
api = app.OpenAPIFirstApp(
|
api = app.OpenAPIFirstApp(
|
||||||
openapi_path="openapi.json",
|
openapi_path="openapi.yaml",
|
||||||
routes_module=routes,
|
routes_module=routes,
|
||||||
title="My Service",
|
title="My Service",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -81,6 +109,56 @@ OpenAPI snippet:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
The binder guarantees:
|
||||||
|
- Every OpenAPI operationId has exactly one handler
|
||||||
|
- No undocumented routes exist
|
||||||
|
- All mismatches fail at application startup
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Client-Side Usage (OpenAPI → HTTP Client)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The same OpenAPI specification can be used to construct a strict,
|
||||||
|
operationId-driven HTTP client.
|
||||||
|
|
||||||
|
Client construction:
|
||||||
|
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
|
||||||
|
client = OpenAPIClient(spec)
|
||||||
|
|
||||||
|
Calling operations (operationId is the API):
|
||||||
|
|
||||||
|
response = client.get_health()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
Path parameters must match the OpenAPI specification exactly:
|
||||||
|
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
Request bodies are passed explicitly:
|
||||||
|
|
||||||
|
response = client.create_item(
|
||||||
|
body={"name": "Orange", "price": 0.8}
|
||||||
|
)
|
||||||
|
|
||||||
|
Client guarantees:
|
||||||
|
- One callable per OpenAPI operationId
|
||||||
|
- No hardcoded URLs or HTTP methods in user code
|
||||||
|
- Path and body parameters must match the spec exactly
|
||||||
|
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||||
|
- No schema inference or mutation is performed
|
||||||
|
|
||||||
|
The client is transport-level only and returns `httpx.Response`
|
||||||
|
objects directly. Response interpretation and validation are left to
|
||||||
|
the consumer or higher-level layers.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
Extensibility Model
|
Extensibility Model
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
@@ -107,6 +185,7 @@ The supported public API consists of the following top-level modules:
|
|||||||
- openapi_first.app
|
- openapi_first.app
|
||||||
- openapi_first.binder
|
- openapi_first.binder
|
||||||
- openapi_first.loader
|
- openapi_first.loader
|
||||||
|
- openapi_first.client
|
||||||
- openapi_first.errors
|
- openapi_first.errors
|
||||||
|
|
||||||
Classes and functions should be imported explicitly from these modules.
|
Classes and functions should be imported explicitly from these modules.
|
||||||
@@ -118,8 +197,8 @@ Design Guarantees
|
|||||||
|
|
||||||
- OpenAPI is the single source of truth
|
- OpenAPI is the single source of truth
|
||||||
- No undocumented routes can exist
|
- No undocumented routes can exist
|
||||||
- No OpenAPI operation can exist without a handler
|
- No OpenAPI operation can exist without a handler or client callable
|
||||||
- All contract violations fail at application startup
|
- All contract violations fail at application startup or client creation
|
||||||
- No hidden FastAPI magic or implicit behavior
|
- No hidden FastAPI magic or implicit behavior
|
||||||
- Deterministic, testable application assembly
|
- Deterministic, testable application assembly
|
||||||
- CI-friendly failure modes
|
- CI-friendly failure modes
|
||||||
@@ -131,11 +210,13 @@ enforcement over convenience shortcuts.
|
|||||||
from . import app
|
from . import app
|
||||||
from . import binder
|
from . import binder
|
||||||
from . import loader
|
from . import loader
|
||||||
|
from . import client
|
||||||
from . import errors
|
from . import errors
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"app",
|
"app",
|
||||||
"binder",
|
"binder",
|
||||||
"loader",
|
"loader",
|
||||||
|
"client",
|
||||||
"errors",
|
"errors",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,16 +4,8 @@ openapi_first.cli
|
|||||||
|
|
||||||
Command-line interface for FastAPI OpenAPI-first scaffolding utilities.
|
Command-line interface for FastAPI OpenAPI-first scaffolding utilities.
|
||||||
|
|
||||||
This module provides a small, focused CLI intended to help developers
|
This CLI bootstraps OpenAPI-first FastAPI applications from versioned,
|
||||||
quickly bootstrap OpenAPI-first FastAPI services using bundled project
|
bundled templates packaged with the library.
|
||||||
templates.
|
|
||||||
|
|
||||||
Currently supported scaffolds:
|
|
||||||
- Health check service (minimal OpenAPI-first application)
|
|
||||||
|
|
||||||
The CLI copies versioned templates packaged with the library into a
|
|
||||||
user-specified directory, allowing rapid local development without
|
|
||||||
manual setup.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -22,68 +14,75 @@ from pathlib import Path
|
|||||||
from importlib import resources
|
from importlib import resources
|
||||||
|
|
||||||
|
|
||||||
def copy_health_app_template(target_dir: Path) -> None:
|
DEFAULT_TEMPLATE = "health_app"
|
||||||
|
|
||||||
|
|
||||||
|
def available_templates() -> list[str]:
|
||||||
"""
|
"""
|
||||||
Copy the bundled OpenAPI-first health app template into a directory.
|
Return a list of available application templates.
|
||||||
|
"""
|
||||||
|
root = resources.files("openapi_first.templates")
|
||||||
|
return sorted(
|
||||||
|
item.name
|
||||||
|
for item in root.iterdir()
|
||||||
|
if item.is_dir() and not item.name.startswith("_")
|
||||||
|
)
|
||||||
|
|
||||||
This function copies a fully working, minimal OpenAPI-first FastAPI
|
|
||||||
health check application from the package's embedded templates into
|
|
||||||
the specified target directory.
|
|
||||||
|
|
||||||
The target directory will be created if it does not already exist.
|
def copy_template(template: str, target_dir: Path) -> None:
|
||||||
Existing files may be overwritten.
|
"""
|
||||||
|
Copy a bundled OpenAPI-first application template into a directory.
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
target_dir : pathlib.Path
|
|
||||||
Destination directory into which the health app template
|
|
||||||
should be copied.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
FileNotFoundError
|
|
||||||
If the bundled health app template cannot be located.
|
|
||||||
"""
|
"""
|
||||||
target_dir = target_dir.resolve()
|
target_dir = target_dir.resolve()
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with resources.files("openapi_first.templates").joinpath(
|
root = resources.files("openapi_first.templates")
|
||||||
"health_app"
|
src = root / template
|
||||||
) as src:
|
|
||||||
shutil.copytree(src, target_dir, dirs_exist_ok=True)
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Template '{template}' not found. "
|
||||||
|
f"Available templates: {', '.join(available_templates())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with resources.as_file(src) as path:
|
||||||
|
shutil.copytree(path, target_dir, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""
|
|
||||||
Entry point for the FastAPI OpenAPI-first CLI.
|
|
||||||
|
|
||||||
Parses command-line arguments and initializes a new OpenAPI-first
|
|
||||||
health check application by copying the bundled template into the
|
|
||||||
specified directory.
|
|
||||||
|
|
||||||
If no target path is provided, the scaffold is created in a directory
|
|
||||||
named ``health-app`` in the current working directory.
|
|
||||||
|
|
||||||
Example
|
|
||||||
-------
|
|
||||||
Create a health app in the default directory::
|
|
||||||
|
|
||||||
openapi-first
|
|
||||||
|
|
||||||
Create a health app in a custom directory::
|
|
||||||
|
|
||||||
openapi-first my-service
|
|
||||||
"""
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="FastAPI OpenAPI-first scaffolding tools"
|
description="FastAPI OpenAPI-first scaffolding tools"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"template",
|
||||||
|
nargs="?",
|
||||||
|
default=DEFAULT_TEMPLATE,
|
||||||
|
help=f"Template name (default: {DEFAULT_TEMPLATE})",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"path",
|
"path",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default="health-app",
|
default=None,
|
||||||
help="Target directory for the health app",
|
help="Target directory (defaults to template name)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list",
|
||||||
|
action="store_true",
|
||||||
|
help="List available templates and exit",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
copy_health_app_template(Path(args.path))
|
|
||||||
print(f"Health app created at {args.path}")
|
if args.list:
|
||||||
|
for name in available_templates():
|
||||||
|
print(name)
|
||||||
|
return
|
||||||
|
|
||||||
|
target = Path(args.path or args.template.replace("_", "-"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
copy_template(args.template, target)
|
||||||
|
except Exception as exc:
|
||||||
|
raise SystemExit(str(exc)) from exc
|
||||||
|
|
||||||
|
print(f"Template '{args.template}' created at {target}")
|
||||||
|
|||||||
176
openapi_first/client.py
Normal file
176
openapi_first/client.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import OpenAPIFirstError
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClientError(OpenAPIFirstError):
|
||||||
|
"""Raised when an OpenAPI client operation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClient:
|
||||||
|
"""
|
||||||
|
OpenAPI-first HTTP client (httpx-based).
|
||||||
|
|
||||||
|
- One callable per operationId
|
||||||
|
- Explicit parameters (path, query, headers, body)
|
||||||
|
- No implicit schema inference or mutation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
spec: dict[str, Any],
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
client: Optional[httpx.Client] = None,
|
||||||
|
) -> None:
|
||||||
|
self.spec = spec
|
||||||
|
self.base_url = base_url or self._resolve_base_url(spec)
|
||||||
|
self.client = client or httpx.Client(base_url=self.base_url)
|
||||||
|
|
||||||
|
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
||||||
|
self._build_operations()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Callable[..., httpx.Response]:
|
||||||
|
try:
|
||||||
|
return self._operations[name]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(f"No such operationId: {name}") from None
|
||||||
|
|
||||||
|
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
||||||
|
return dict(self._operations)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internal mechanics
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _resolve_base_url(self, spec: dict[str, Any]) -> str:
|
||||||
|
servers = spec.get("servers")
|
||||||
|
if not servers:
|
||||||
|
raise OpenAPIClientError("No servers defined in OpenAPI spec")
|
||||||
|
|
||||||
|
url = servers[0].get("url")
|
||||||
|
if not url:
|
||||||
|
raise OpenAPIClientError("Server entry missing 'url'")
|
||||||
|
|
||||||
|
return url.rstrip("/") + "/"
|
||||||
|
|
||||||
|
def _build_operations(self) -> None:
|
||||||
|
paths = self.spec.get("paths", {})
|
||||||
|
if not paths:
|
||||||
|
raise OpenAPIClientError("OpenAPI spec contains no paths")
|
||||||
|
|
||||||
|
for path, path_item in paths.items():
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
if method.lower() not in {
|
||||||
|
"get", "post", "put", "patch", "delete", "head", "options"
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
operation_id = operation.get("operationId")
|
||||||
|
if not operation_id:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Missing operationId for {method.upper()} {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if operation_id in self._operations:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Duplicate operationId detected: {operation_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._operations[operation_id] = self._make_operation(
|
||||||
|
method=method.upper(),
|
||||||
|
path=path,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_operation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
operation: dict[str, Any],
|
||||||
|
) -> Callable[..., httpx.Response]:
|
||||||
|
request_body = operation.get("requestBody")
|
||||||
|
|
||||||
|
def call(
|
||||||
|
*,
|
||||||
|
path_params: Optional[dict[str, Any]] = None,
|
||||||
|
query: Optional[dict[str, Any]] = None,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
body: Optional[Any] = None,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
url = self._build_url(path, path_params or {})
|
||||||
|
|
||||||
|
req_headers = headers.copy() if headers else {}
|
||||||
|
|
||||||
|
json_data = None
|
||||||
|
content = None
|
||||||
|
|
||||||
|
if request_body is not None:
|
||||||
|
if body is None:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Request body required for operation {operation['operationId']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
media_types = request_body.get("content", {})
|
||||||
|
if "application/json" in media_types:
|
||||||
|
json_data = body
|
||||||
|
req_headers.setdefault("Content-Type", "application/json")
|
||||||
|
else:
|
||||||
|
content = body
|
||||||
|
|
||||||
|
response = self.client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=query,
|
||||||
|
headers=req_headers,
|
||||||
|
json=json_data,
|
||||||
|
content=content,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
call.__name__ = operation["operationId"]
|
||||||
|
call.__doc__ = self._build_docstring(method, path, operation)
|
||||||
|
|
||||||
|
return call
|
||||||
|
|
||||||
|
def _build_url(self, path: str, path_params: dict[str, Any]) -> str:
|
||||||
|
try:
|
||||||
|
formatted_path = path.format(**path_params)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Missing path parameter: {exc.args[0]}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
||||||
|
|
||||||
|
def _build_docstring(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
operation: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
f"{method} {path}",
|
||||||
|
"",
|
||||||
|
operation.get("summary", ""),
|
||||||
|
"",
|
||||||
|
"Parameters:",
|
||||||
|
" path_params: dict | None",
|
||||||
|
" query: dict | None",
|
||||||
|
" headers: dict | None",
|
||||||
|
" body: Any | None",
|
||||||
|
"",
|
||||||
|
"Returns:",
|
||||||
|
" httpx.Response",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -25,8 +25,6 @@ This module intentionally does NOT:
|
|||||||
- Perform request/response validation at runtime
|
- Perform request/response validation at runtime
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
18
openapi_first/templates/__init__.py
Normal file
18
openapi_first/templates/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Application templates for FastAPI OpenAPI First.
|
||||||
|
|
||||||
|
This package contains example and scaffolding templates intended to be
|
||||||
|
copied into user projects via the ``openapi-first`` CLI.
|
||||||
|
|
||||||
|
Templates in this package are:
|
||||||
|
- Reference implementations of OpenAPI-first services
|
||||||
|
- Not part of the ``openapi_first`` public or internal API
|
||||||
|
- Not intended to be imported as runtime dependencies
|
||||||
|
|
||||||
|
The presence of this file exists solely to:
|
||||||
|
- Mark the directory as an explicit Python package
|
||||||
|
- Enable deterministic tooling behavior (documentation, packaging)
|
||||||
|
- Avoid accidental traversal of non-package directories
|
||||||
|
|
||||||
|
No code in this package should be imported by library consumers.
|
||||||
|
"""
|
||||||
99
openapi_first/templates/crud_app/__init__.py
Normal file
99
openapi_first/templates/crud_app/__init__.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
OpenAPI-first CRUD application template.
|
||||||
|
|
||||||
|
This package contains a complete, minimal example of an OpenAPI-first
|
||||||
|
CRUD service built using the ``openapi_first`` library.
|
||||||
|
|
||||||
|
The application is assembled exclusively from:
|
||||||
|
- an OpenAPI specification (``openapi.yaml``)
|
||||||
|
- a handler namespace implementing CRUD operations (``routes``)
|
||||||
|
- an in-memory mock data store (``data``)
|
||||||
|
|
||||||
|
All HTTP routes, methods, schemas, and operation bindings are defined
|
||||||
|
in the OpenAPI specification and enforced at application startup.
|
||||||
|
No decorator-driven routing or implicit framework behavior is used.
|
||||||
|
|
||||||
|
This template demonstrates:
|
||||||
|
- operationId-driven server-side route binding
|
||||||
|
- explicit HTTP status code control in handlers
|
||||||
|
- operationId-driven client usage against the same OpenAPI contract
|
||||||
|
- end-to-end validation using in-memory data and tests
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Scaffolding via CLI
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Create a new CRUD example service using the bundled template:
|
||||||
|
|
||||||
|
openapi-first crud_app
|
||||||
|
|
||||||
|
Create the service in a custom directory:
|
||||||
|
|
||||||
|
openapi-first crud_app my-crud-service
|
||||||
|
|
||||||
|
List all available application templates:
|
||||||
|
|
||||||
|
openapi-first --list
|
||||||
|
|
||||||
|
The CLI copies template files verbatim into the target directory.
|
||||||
|
No code is generated or modified beyond the copied scaffold.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Client Usage Example
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The same OpenAPI specification used by the server can be used to
|
||||||
|
construct a strict, operationId-driven HTTP client.
|
||||||
|
|
||||||
|
Example client calls for CRUD operations:
|
||||||
|
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(spec)
|
||||||
|
|
||||||
|
# List items
|
||||||
|
response = client.list_items()
|
||||||
|
|
||||||
|
# Get item by ID
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create item
|
||||||
|
response = client.create_item(
|
||||||
|
body={"name": "Orange", "price": 0.8}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update item
|
||||||
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body={"name": "Green Apple", "price": 0.6},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete item
|
||||||
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
Client guarantees:
|
||||||
|
- One callable per OpenAPI ``operationId``
|
||||||
|
- No hardcoded URLs or HTTP methods in user code
|
||||||
|
- Path and request parameters must match the OpenAPI specification
|
||||||
|
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Non-Goals
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
This template is intentionally minimal and is NOT:
|
||||||
|
- production-ready
|
||||||
|
- persistent or concurrency-safe
|
||||||
|
- a reference architecture for data storage
|
||||||
|
|
||||||
|
It exists solely as a copyable example for learning, testing, and
|
||||||
|
bootstrapping OpenAPI-first services.
|
||||||
|
|
||||||
|
This package is not part of the ``openapi_first`` library API surface.
|
||||||
|
"""
|
||||||
132
openapi_first/templates/crud_app/data.py
Normal file
132
openapi_first/templates/crud_app/data.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
In-memory mock data store for CRUD example.
|
||||||
|
|
||||||
|
This module intentionally avoids persistence and concurrency guarantees.
|
||||||
|
It is suitable for demos, tests, and scaffolding only.
|
||||||
|
|
||||||
|
It intentionally avoids
|
||||||
|
- persistence
|
||||||
|
- concurrency guarantees
|
||||||
|
- validation
|
||||||
|
- error handling
|
||||||
|
|
||||||
|
The implementation is suitable for:
|
||||||
|
- demonstrations
|
||||||
|
- tests
|
||||||
|
- scaffolding and example services
|
||||||
|
|
||||||
|
It is explicitly NOT suitable for production use.
|
||||||
|
|
||||||
|
This module is not part of the ``openapi_first`` library API surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory storage keyed by item ID.
|
||||||
|
_items: Dict[int, dict] = {
|
||||||
|
1: {"id": 1, "name": "Apple", "price": 0.5},
|
||||||
|
2: {"id": 2, "name": "Banana", "price": 0.3},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-incrementing ID counter.
|
||||||
|
_next_id = 3
|
||||||
|
|
||||||
|
|
||||||
|
def list_items():
|
||||||
|
"""
|
||||||
|
Return all items in the data store.
|
||||||
|
|
||||||
|
This function performs no filtering, pagination, or sorting.
|
||||||
|
The returned collection reflects the current in-memory state.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict]
|
||||||
|
A list of item representations.
|
||||||
|
"""
|
||||||
|
return list(_items.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: int):
|
||||||
|
"""
|
||||||
|
Retrieve a single item by ID.
|
||||||
|
|
||||||
|
This function assumes the item exists and will raise ``KeyError``
|
||||||
|
if the ID is not present in the store.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to retrieve.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The stored item representation.
|
||||||
|
"""
|
||||||
|
return _items[item_id]
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(payload: dict):
|
||||||
|
"""
|
||||||
|
Create a new item in the data store.
|
||||||
|
|
||||||
|
A new integer ID is assigned automatically. No validation is
|
||||||
|
performed on the provided payload.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload : dict
|
||||||
|
Item attributes excluding the ``id`` field.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The newly created item, including its assigned ID.
|
||||||
|
"""
|
||||||
|
global _next_id
|
||||||
|
item = {"id": _next_id, **payload}
|
||||||
|
_items[_next_id] = item
|
||||||
|
_next_id += 1
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item_id: int, payload: dict):
|
||||||
|
"""
|
||||||
|
Replace an existing item in the data store.
|
||||||
|
|
||||||
|
This function overwrites the existing item entirely and does not
|
||||||
|
perform partial updates or validation. If the item does not exist,
|
||||||
|
it will be created implicitly.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to update.
|
||||||
|
payload : dict
|
||||||
|
Item attributes excluding the ``id`` field.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The updated item representation.
|
||||||
|
"""
|
||||||
|
item = {"id": item_id, **payload}
|
||||||
|
_items[item_id] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: int):
|
||||||
|
"""
|
||||||
|
Remove an item from the data store.
|
||||||
|
|
||||||
|
This function assumes the item exists and will raise ``KeyError``
|
||||||
|
if the ID is not present.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to delete.
|
||||||
|
"""
|
||||||
|
del _items[item_id]
|
||||||
35
openapi_first/templates/crud_app/main.py
Normal file
35
openapi_first/templates/crud_app/main.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Application entry point for an OpenAPI-first CRUD example service.
|
||||||
|
|
||||||
|
This module constructs a FastAPI application exclusively from an
|
||||||
|
OpenAPI specification and a handler namespace, without using
|
||||||
|
decorator-driven routing.
|
||||||
|
|
||||||
|
All HTTP routes, methods, request/response schemas, and operation
|
||||||
|
bindings are defined in the OpenAPI document referenced by
|
||||||
|
``openapi_path``. Python callables defined in the ``routes`` module are
|
||||||
|
bound to OpenAPI operations strictly via ``operationId``.
|
||||||
|
|
||||||
|
This module contains no routing logic, persistence concerns, or
|
||||||
|
framework configuration beyond application assembly.
|
||||||
|
|
||||||
|
Design guarantees:
|
||||||
|
- OpenAPI is the single source of truth
|
||||||
|
- No undocumented routes can exist
|
||||||
|
- Every OpenAPI operationId must resolve to exactly one handler
|
||||||
|
- All contract violations fail at application startup
|
||||||
|
|
||||||
|
This file is intended to be used as the ASGI entry point.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
uvicorn main:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
import routes
|
||||||
|
|
||||||
|
app = OpenAPIFirstApp(
|
||||||
|
openapi_path="openapi.yaml",
|
||||||
|
routes_module=routes,
|
||||||
|
title="CRUD Example Service",
|
||||||
|
)
|
||||||
115
openapi_first/templates/crud_app/openapi.yaml
Normal file
115
openapi_first/templates/crud_app/openapi.yaml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: CRUD Example Service
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Minimal OpenAPI-first CRUD service with in-memory mock data.
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: list_items
|
||||||
|
summary: List all items
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of items
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
post:
|
||||||
|
operationId: create_item
|
||||||
|
summary: Create a new item
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ItemCreate"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Item created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
/items/{item_id}:
|
||||||
|
get:
|
||||||
|
operationId: get_item
|
||||||
|
summary: Get item by ID
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Item found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
put:
|
||||||
|
operationId: update_item
|
||||||
|
summary: Update an item
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ItemCreate"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Item updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
delete:
|
||||||
|
operationId: delete_item
|
||||||
|
summary: Delete an item
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Item deleted
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Item:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
required: [id, name, price]
|
||||||
|
|
||||||
|
ItemCreate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
required: [name, price]
|
||||||
158
openapi_first/templates/crud_app/routes.py
Normal file
158
openapi_first/templates/crud_app/routes.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
CRUD route handlers bound via OpenAPI operationId.
|
||||||
|
|
||||||
|
These handlers explicitly control HTTP status codes to ensure
|
||||||
|
runtime behavior matches the OpenAPI contract.
|
||||||
|
|
||||||
|
This module defines OpenAPI-bound operation handlers for a simple CRUD
|
||||||
|
service. Functions in this module are bound to HTTP routes exclusively
|
||||||
|
via OpenAPI ``operationId`` values.
|
||||||
|
|
||||||
|
Handlers explicitly control HTTP response status codes to ensure runtime
|
||||||
|
behavior matches the OpenAPI contract. Error conditions are translated
|
||||||
|
into explicit HTTP responses rather than relying on implicit framework
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
No routing decorators or path definitions appear in this module. All
|
||||||
|
routing, HTTP methods, and schemas are defined in the OpenAPI
|
||||||
|
specification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Response, HTTPException
|
||||||
|
|
||||||
|
from data import (
|
||||||
|
list_items as _list_items,
|
||||||
|
get_item as _get_item,
|
||||||
|
create_item as _create_item,
|
||||||
|
update_item as _update_item,
|
||||||
|
delete_item as _delete_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_items():
|
||||||
|
"""
|
||||||
|
List all items.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: list_items``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict]
|
||||||
|
A list of item representations.
|
||||||
|
"""
|
||||||
|
return _list_items()
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: int):
|
||||||
|
"""
|
||||||
|
Retrieve a single item by ID.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: get_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to retrieve.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The requested item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _get_item(item_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(payload: dict, response: Response):
|
||||||
|
"""
|
||||||
|
Create a new item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: create_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload : dict
|
||||||
|
Item attributes excluding the ``id`` field.
|
||||||
|
response : fastapi.Response
|
||||||
|
Response object used to set the HTTP status code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The newly created item.
|
||||||
|
"""
|
||||||
|
item = _create_item(payload)
|
||||||
|
response.status_code = 201
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item_id: int, payload: dict):
|
||||||
|
"""
|
||||||
|
Update an existing item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: update_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to update.
|
||||||
|
payload : dict
|
||||||
|
Item attributes excluding the ``id`` field.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The updated item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _update_item(item_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: int, response: Response):
|
||||||
|
"""
|
||||||
|
Delete an existing item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: delete_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to delete.
|
||||||
|
response : fastapi.Response
|
||||||
|
Response object used to set the HTTP status code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
None
|
||||||
|
No content.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_delete_item(item_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
response.status_code = 204
|
||||||
125
openapi_first/templates/crud_app/test_crud_app.py
Normal file
125
openapi_first/templates/crud_app/test_crud_app.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for the OpenAPI-first CRUD example app.
|
||||||
|
|
||||||
|
These tests validate that all CRUD operations behave correctly
|
||||||
|
against the in-memory mock data store.
|
||||||
|
- OpenAPI specification loading
|
||||||
|
- OperationId-driven route binding on the server
|
||||||
|
- OperationId-driven client invocation
|
||||||
|
- Correct HTTP status codes and response payloads
|
||||||
|
|
||||||
|
The tests exercise all CRUD operations against an in-memory mock data
|
||||||
|
store and assume deterministic behavior within a single process.
|
||||||
|
|
||||||
|
The tests assume:
|
||||||
|
- OpenAPI-first route binding
|
||||||
|
- In-memory storage (no persistence guarantees)
|
||||||
|
- Deterministic behavior in a single process
|
||||||
|
- One-to-one correspondence between OpenAPI operationId values and
|
||||||
|
server/client callables
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(
|
||||||
|
spec=spec,
|
||||||
|
base_url="http://testserver",
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_items_initial():
|
||||||
|
"""Initial items should be present."""
|
||||||
|
response = client.list_items()
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert len(data) >= 2
|
||||||
|
|
||||||
|
ids = {item["id"] for item in data}
|
||||||
|
assert 1 in ids
|
||||||
|
assert 2 in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_item():
|
||||||
|
"""Existing item should be retrievable by ID."""
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert item["id"] == 1
|
||||||
|
assert "name" in item
|
||||||
|
assert "price" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_item():
|
||||||
|
"""Creating a new item should return the created entity."""
|
||||||
|
payload = {
|
||||||
|
"name": "Orange",
|
||||||
|
"price": 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.create_item(
|
||||||
|
body=payload
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert "id" in item
|
||||||
|
assert item["name"] == payload["name"]
|
||||||
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
|
# Verify it appears in list
|
||||||
|
list_response = client.list_items()
|
||||||
|
ids = {i["id"] for i in list_response.json()}
|
||||||
|
assert item["id"] in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_item():
|
||||||
|
"""Updating an item should replace its values."""
|
||||||
|
payload = {
|
||||||
|
"name": "Green Apple",
|
||||||
|
"price": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert item["id"] == 1
|
||||||
|
assert item["name"] == payload["name"]
|
||||||
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
|
# Verify persisted update
|
||||||
|
get_response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
updated = get_response.json()
|
||||||
|
assert updated["name"] == payload["name"]
|
||||||
|
assert updated["price"] == payload["price"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_item():
|
||||||
|
"""Deleting an item should remove it from the store."""
|
||||||
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 2}
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify deletion
|
||||||
|
list_response = client.list_items()
|
||||||
|
ids = {item["id"] for item in list_response.json()}
|
||||||
|
assert 2 not in ids
|
||||||
65
openapi_first/templates/health_app/__init__.py
Normal file
65
openapi_first/templates/health_app/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
OpenAPI-first FastAPI application template.
|
||||||
|
|
||||||
|
This package contains a minimal, fully working example of an
|
||||||
|
OpenAPI-first FastAPI service built using the ``openapi_first`` library.
|
||||||
|
|
||||||
|
The application is assembled exclusively from:
|
||||||
|
- an OpenAPI specification (``openapi.yaml``)
|
||||||
|
- a handler namespace (``routes``)
|
||||||
|
|
||||||
|
No routing decorators, implicit behavior, or framework-specific
|
||||||
|
convenience abstractions are used. All HTTP routes, methods, and
|
||||||
|
operation bindings are defined in OpenAPI and enforced at application
|
||||||
|
startup.
|
||||||
|
|
||||||
|
This package is intended to be copied as a starting point for new
|
||||||
|
services via the ``openapi-first`` CLI. It is not part of the
|
||||||
|
``openapi_first`` library API surface.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Scaffolding via CLI
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Create a new OpenAPI-first health check service using the bundled
|
||||||
|
template:
|
||||||
|
|
||||||
|
openapi-first health_app
|
||||||
|
|
||||||
|
Create the service in a custom directory:
|
||||||
|
|
||||||
|
openapi-first health_app my-health-service
|
||||||
|
|
||||||
|
List all available application templates:
|
||||||
|
|
||||||
|
openapi-first --list
|
||||||
|
|
||||||
|
The CLI copies template files verbatim into the target directory.
|
||||||
|
No code is generated or modified beyond the copied scaffold.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Client Usage Example
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The same OpenAPI specification used by the server can be used to
|
||||||
|
construct a strict, operationId-driven HTTP client.
|
||||||
|
|
||||||
|
Example client call for the ``get_health`` operation:
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
Client guarantees:
|
||||||
|
- One callable per OpenAPI ``operationId``
|
||||||
|
- No hardcoded URLs or HTTP methods in user code
|
||||||
|
- Path and request parameters must match the OpenAPI specification
|
||||||
|
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||||
|
"""
|
||||||
@@ -1,3 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Application entry point for an OpenAPI-first FastAPI service.
|
||||||
|
|
||||||
|
This module constructs a FastAPI application exclusively from an
|
||||||
|
OpenAPI specification and a handler namespace, without using
|
||||||
|
decorator-driven routing.
|
||||||
|
|
||||||
|
All HTTP routes, methods, and operation bindings are defined in the
|
||||||
|
OpenAPI document referenced by ``openapi_path``. Python callables
|
||||||
|
defined in the ``routes`` module are bound to OpenAPI operations
|
||||||
|
strictly via ``operationId``.
|
||||||
|
|
||||||
|
This module contains no routing logic, request handling, or framework
|
||||||
|
configuration beyond application assembly.
|
||||||
|
|
||||||
|
Design guarantees:
|
||||||
|
- OpenAPI is the single source of truth
|
||||||
|
- No undocumented routes can exist
|
||||||
|
- Every OpenAPI operationId must resolve to exactly one handler
|
||||||
|
- All contract violations fail at application startup
|
||||||
|
|
||||||
|
This file is intended to be used as the ASGI entry point.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
uvicorn main:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
import routes
|
import routes
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,32 @@
|
|||||||
|
"""
|
||||||
|
OpenAPI operation handlers.
|
||||||
|
|
||||||
|
This module defines pure Python callables that implement OpenAPI
|
||||||
|
operations for this service. Functions in this module are bound to HTTP
|
||||||
|
routes exclusively via OpenAPI ``operationId`` values.
|
||||||
|
|
||||||
|
No routing decorators, HTTP metadata, or framework-specific logic
|
||||||
|
should appear here. All request/response semantics are defined in the
|
||||||
|
OpenAPI specification.
|
||||||
|
|
||||||
|
This module serves solely as an operationId namespace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_health():
|
def get_health():
|
||||||
|
"""
|
||||||
|
Health check operation handler.
|
||||||
|
|
||||||
|
This function implements the OpenAPI operation identified by
|
||||||
|
``operationId: get_health``.
|
||||||
|
|
||||||
|
It contains no routing metadata or framework-specific logic.
|
||||||
|
Request binding, HTTP method, and response semantics are defined
|
||||||
|
exclusively by the OpenAPI specification.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
A minimal liveness payload indicating service health.
|
||||||
|
"""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
102
openapi_first/templates/model_app/__init__.py
Normal file
102
openapi_first/templates/model_app/__init__.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
OpenAPI-first model-based CRUD application template.
|
||||||
|
|
||||||
|
This package contains a complete, minimal example of an OpenAPI-first
|
||||||
|
CRUD service that uses explicit Pydantic domain models for request and
|
||||||
|
response schemas.
|
||||||
|
|
||||||
|
The application is assembled exclusively from:
|
||||||
|
- an OpenAPI specification (``openapi.yaml``)
|
||||||
|
- a handler namespace implementing CRUD operations (``routes``)
|
||||||
|
- Pydantic domain models (``models``)
|
||||||
|
- an in-memory mock data store (``data``)
|
||||||
|
|
||||||
|
All HTTP routes, methods, schemas, and operation bindings are defined
|
||||||
|
in the OpenAPI specification and enforced at application startup.
|
||||||
|
No decorator-driven routing or implicit framework behavior is used.
|
||||||
|
|
||||||
|
This template demonstrates:
|
||||||
|
- operationId-driven server-side route binding
|
||||||
|
- explicit request and response modeling with Pydantic
|
||||||
|
- explicit HTTP status code control in handlers
|
||||||
|
- operationId-driven client usage against the same OpenAPI contract
|
||||||
|
- end-to-end validation using in-memory data and tests
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Scaffolding via CLI
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Create a new model-based CRUD example service using the bundled template:
|
||||||
|
|
||||||
|
openapi-first model_app
|
||||||
|
|
||||||
|
Create the service in a custom directory:
|
||||||
|
|
||||||
|
openapi-first model_app my-model-service
|
||||||
|
|
||||||
|
List all available application templates:
|
||||||
|
|
||||||
|
openapi-first --list
|
||||||
|
|
||||||
|
The CLI copies template files verbatim into the target directory.
|
||||||
|
No code is generated or modified beyond the copied scaffold.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Client Usage Example
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The same OpenAPI specification used by the server can be used to
|
||||||
|
construct a strict, operationId-driven HTTP client.
|
||||||
|
|
||||||
|
Example client calls for model-based CRUD operations:
|
||||||
|
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(spec)
|
||||||
|
|
||||||
|
# List items
|
||||||
|
response = client.list_items()
|
||||||
|
|
||||||
|
# Get item by ID
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create item
|
||||||
|
response = client.create_item(
|
||||||
|
body={"name": "Orange", "price": 0.8}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update item
|
||||||
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body={"name": "Green Apple", "price": 0.6},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete item
|
||||||
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
Client guarantees:
|
||||||
|
- One callable per OpenAPI ``operationId``
|
||||||
|
- No hardcoded URLs or HTTP methods in user code
|
||||||
|
- Request and response payloads conform to Pydantic models
|
||||||
|
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Non-Goals
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
This template is intentionally minimal and is NOT:
|
||||||
|
- production-ready
|
||||||
|
- persistent or concurrency-safe
|
||||||
|
- a reference architecture for data storage
|
||||||
|
|
||||||
|
It exists solely as a copyable example for learning, testing, and
|
||||||
|
bootstrapping OpenAPI-first services.
|
||||||
|
|
||||||
|
This package is not part of the ``openapi_first`` library API surface.
|
||||||
|
"""
|
||||||
137
openapi_first/templates/model_app/data.py
Normal file
137
openapi_first/templates/model_app/data.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
In-memory data store using Pydantic models.
|
||||||
|
|
||||||
|
This module is NOT thread-safe and is intended for demos and scaffolds only.
|
||||||
|
This module provides a minimal, process-local data store for the
|
||||||
|
model-based CRUD example application. It stores and returns domain
|
||||||
|
objects defined using Pydantic models and is intended solely for
|
||||||
|
demonstration and scaffolding purposes.
|
||||||
|
|
||||||
|
The implementation intentionally avoids:
|
||||||
|
- persistence
|
||||||
|
- concurrency guarantees
|
||||||
|
- transactional semantics
|
||||||
|
- validation beyond what Pydantic provides
|
||||||
|
|
||||||
|
It is not part of the ``openapi_first`` library API surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from models import Item, ItemCreate
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory storage keyed by item ID.
|
||||||
|
_items: Dict[int, Item] = {
|
||||||
|
1: Item(id=1, name="Apple", price=0.5),
|
||||||
|
2: Item(id=2, name="Banana", price=0.3),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-incrementing identifier.
|
||||||
|
_next_id = 3
|
||||||
|
|
||||||
|
|
||||||
|
def list_items() -> list[Item]:
|
||||||
|
"""
|
||||||
|
Return all items in the data store.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[Item]
|
||||||
|
A list of item domain objects.
|
||||||
|
"""
|
||||||
|
return list(_items.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: int) -> Item:
|
||||||
|
"""
|
||||||
|
Retrieve a single item by ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to retrieve.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The requested item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If the item does not exist.
|
||||||
|
"""
|
||||||
|
return _items[item_id]
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(payload: ItemCreate) -> Item:
|
||||||
|
"""
|
||||||
|
Create a new item in the data store.
|
||||||
|
|
||||||
|
A new identifier is assigned automatically. No additional validation
|
||||||
|
is performed beyond Pydantic model validation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload : ItemCreate
|
||||||
|
Data required to create a new item.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The newly created item.
|
||||||
|
"""
|
||||||
|
global _next_id
|
||||||
|
item = Item(id=_next_id, **payload.model_dump())
|
||||||
|
_items[_next_id] = item
|
||||||
|
_next_id += 1
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item_id: int, payload: ItemCreate) -> Item:
|
||||||
|
"""
|
||||||
|
Replace an existing item in the data store.
|
||||||
|
|
||||||
|
This function performs a full replacement of the stored item.
|
||||||
|
Partial updates are not supported.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to update.
|
||||||
|
payload : ItemCreate
|
||||||
|
New item data.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The updated item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If the item does not exist.
|
||||||
|
"""
|
||||||
|
if item_id not in _items:
|
||||||
|
raise KeyError(item_id)
|
||||||
|
item = Item(id=item_id, **payload.model_dump())
|
||||||
|
_items[item_id] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove an item from the data store.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to delete.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If the item does not exist.
|
||||||
|
"""
|
||||||
|
del _items[item_id]
|
||||||
35
openapi_first/templates/model_app/main.py
Normal file
35
openapi_first/templates/model_app/main.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Application entry point for an OpenAPI-first model-based CRUD example service.
|
||||||
|
|
||||||
|
This module constructs a FastAPI application exclusively from an
|
||||||
|
OpenAPI specification and a handler namespace, without using
|
||||||
|
decorator-driven routing.
|
||||||
|
|
||||||
|
All HTTP routes, methods, request/response schemas, and operation
|
||||||
|
bindings are defined in the OpenAPI document referenced by
|
||||||
|
``openapi_path``. Python callables defined in the ``routes`` module are
|
||||||
|
bound to OpenAPI operations strictly via ``operationId``.
|
||||||
|
|
||||||
|
This module contains no routing logic, persistence concerns, or
|
||||||
|
framework configuration beyond application assembly.
|
||||||
|
|
||||||
|
Design guarantees:
|
||||||
|
- OpenAPI is the single source of truth
|
||||||
|
- No undocumented routes can exist
|
||||||
|
- Every OpenAPI operationId must resolve to exactly one handler
|
||||||
|
- All contract violations fail at application startup
|
||||||
|
|
||||||
|
This file is intended to be used as the ASGI entry point.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
uvicorn main:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
import routes
|
||||||
|
|
||||||
|
app = OpenAPIFirstApp(
|
||||||
|
openapi_path="openapi.yaml",
|
||||||
|
routes_module=routes,
|
||||||
|
title="Model CRUD Example Service",
|
||||||
|
)
|
||||||
50
openapi_first/templates/model_app/models.py
Normal file
50
openapi_first/templates/model_app/models.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Pydantic domain models for the CRUD example.
|
||||||
|
|
||||||
|
This module defines Pydantic models that represent the domain entities
|
||||||
|
used by the service. These models are referenced by the OpenAPI
|
||||||
|
specification for request and response schemas.
|
||||||
|
|
||||||
|
The models are declarative and framework-agnostic. They contain no
|
||||||
|
persistence logic, validation beyond type constraints, or business
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
This module is not part of the ``openapi_first`` library API surface.
|
||||||
|
It exists solely to support the example application template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ItemBase(BaseModel):
|
||||||
|
"""
|
||||||
|
Base domain model for an item.
|
||||||
|
|
||||||
|
Defines fields common to all item representations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
price: float
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreate(ItemBase):
|
||||||
|
"""
|
||||||
|
Domain model for item creation requests.
|
||||||
|
|
||||||
|
This model is used for request bodies when creating new items.
|
||||||
|
It intentionally excludes the ``id`` field, which is assigned
|
||||||
|
by the service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Item(ItemBase):
|
||||||
|
"""
|
||||||
|
Domain model for a persisted item.
|
||||||
|
|
||||||
|
This model represents the full item state returned in responses,
|
||||||
|
including the server-assigned identifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
116
openapi_first/templates/model_app/openapi.yaml
Normal file
116
openapi_first/templates/model_app/openapi.yaml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Model CRUD Example Service
|
||||||
|
version: "1.0.0"
|
||||||
|
description: OpenAPI-first CRUD service with Pydantic models.
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: list_items
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of items
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
post:
|
||||||
|
operationId: create_item
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ItemCreate"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Item created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
|
||||||
|
/items/{item_id}:
|
||||||
|
get:
|
||||||
|
operationId: get_item
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Item found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
"404":
|
||||||
|
description: Item not found
|
||||||
|
|
||||||
|
put:
|
||||||
|
operationId: update_item
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ItemCreate"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Item updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Item"
|
||||||
|
"404":
|
||||||
|
description: Item not found
|
||||||
|
|
||||||
|
delete:
|
||||||
|
operationId: delete_item
|
||||||
|
parameters:
|
||||||
|
- name: item_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Item deleted
|
||||||
|
"404":
|
||||||
|
description: Item not found
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Item:
|
||||||
|
type: object
|
||||||
|
required: [id, name, price]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
|
||||||
|
ItemCreate:
|
||||||
|
type: object
|
||||||
|
required: [name, price]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
156
openapi_first/templates/model_app/routes.py
Normal file
156
openapi_first/templates/model_app/routes.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
CRUD route handlers bound via OpenAPI operationId.
|
||||||
|
|
||||||
|
This module defines OpenAPI-bound operation handlers for a model-based
|
||||||
|
CRUD service. Functions in this module are bound to HTTP routes
|
||||||
|
exclusively via OpenAPI ``operationId`` values.
|
||||||
|
|
||||||
|
Handlers explicitly control HTTP response status codes to ensure runtime
|
||||||
|
behavior matches the OpenAPI contract. Domain models defined using
|
||||||
|
Pydantic are used for request and response payloads.
|
||||||
|
|
||||||
|
No routing decorators, path definitions, or implicit framework behavior
|
||||||
|
appear in this module. All routing, HTTP methods, and schemas are defined
|
||||||
|
in the OpenAPI specification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Response, HTTPException
|
||||||
|
|
||||||
|
from models import ItemCreate
|
||||||
|
from data import (
|
||||||
|
list_items as _list_items,
|
||||||
|
get_item as _get_item,
|
||||||
|
create_item as _create_item,
|
||||||
|
update_item as _update_item,
|
||||||
|
delete_item as _delete_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_items():
|
||||||
|
"""
|
||||||
|
List all items.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: list_items``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[Item]
|
||||||
|
A list of item domain objects.
|
||||||
|
"""
|
||||||
|
return _list_items()
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: int):
|
||||||
|
"""
|
||||||
|
Retrieve a single item by ID.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: get_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to retrieve.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The requested item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _get_item(item_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(payload: ItemCreate, response: Response):
|
||||||
|
"""
|
||||||
|
Create a new item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: create_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
payload : ItemCreate
|
||||||
|
Request body describing the item to create.
|
||||||
|
response : fastapi.Response
|
||||||
|
Response object used to set the HTTP status code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The newly created item.
|
||||||
|
"""
|
||||||
|
item = _create_item(payload)
|
||||||
|
response.status_code = 201
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item_id: int, payload: ItemCreate):
|
||||||
|
"""
|
||||||
|
Update an existing item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: update_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to update.
|
||||||
|
payload : ItemCreate
|
||||||
|
New item data.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Item
|
||||||
|
The updated item.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _update_item(item_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: int, response: Response):
|
||||||
|
"""
|
||||||
|
Delete an existing item.
|
||||||
|
|
||||||
|
Implements the OpenAPI operation identified by
|
||||||
|
``operationId: delete_item``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
item_id : int
|
||||||
|
Identifier of the item to delete.
|
||||||
|
response : fastapi.Response
|
||||||
|
Response object used to set the HTTP status code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
None
|
||||||
|
No content.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
HTTPException
|
||||||
|
404 if the item does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_delete_item(item_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
response.status_code = 204
|
||||||
|
return None
|
||||||
124
openapi_first/templates/model_app/test_model_app.py
Normal file
124
openapi_first/templates/model_app/test_model_app.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for the OpenAPI-first model CRUD example app.
|
||||||
|
|
||||||
|
These tests validate that all CRUD operations behave correctly
|
||||||
|
against the in-memory mock data store using Pydantic models.
|
||||||
|
- OpenAPI specification loading
|
||||||
|
- OperationId-driven route binding on the server
|
||||||
|
- OperationId-driven client invocation
|
||||||
|
- Pydantic model-based request and response handling
|
||||||
|
|
||||||
|
All CRUD operations are exercised against an in-memory mock data store
|
||||||
|
backed by Pydantic domain models.
|
||||||
|
|
||||||
|
The tests assume:
|
||||||
|
- OpenAPI-first route binding
|
||||||
|
- Pydantic model validation
|
||||||
|
- In-memory storage (no persistence guarantees)
|
||||||
|
- Deterministic behavior in a single process
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(
|
||||||
|
spec=spec,
|
||||||
|
base_url="http://testserver",
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_items_initial():
|
||||||
|
"""Initial items should be present."""
|
||||||
|
response = client.list_items()
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert len(data) >= 2
|
||||||
|
|
||||||
|
ids = {item["id"] for item in data}
|
||||||
|
assert 1 in ids
|
||||||
|
assert 2 in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_item():
|
||||||
|
"""Existing item should be retrievable by ID."""
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert item["id"] == 1
|
||||||
|
assert isinstance(item["name"], str)
|
||||||
|
assert isinstance(item["price"], float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_item():
|
||||||
|
"""Creating a new item should return the created entity."""
|
||||||
|
payload = {
|
||||||
|
"name": "Orange",
|
||||||
|
"price": 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.create_item(
|
||||||
|
body=payload
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert "id" in item
|
||||||
|
assert item["name"] == payload["name"]
|
||||||
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
|
# Verify it appears in list
|
||||||
|
list_response = client.list_items()
|
||||||
|
ids = {i["id"] for i in list_response.json()}
|
||||||
|
assert item["id"] in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_item():
|
||||||
|
"""Updating an item should replace its values."""
|
||||||
|
payload = {
|
||||||
|
"name": "Green Apple",
|
||||||
|
"price": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
item = response.json()
|
||||||
|
assert item["id"] == 1
|
||||||
|
assert item["name"] == payload["name"]
|
||||||
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
|
# Verify persisted update
|
||||||
|
get_response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
updated = get_response.json()
|
||||||
|
assert updated["name"] == payload["name"]
|
||||||
|
assert updated["price"] == payload["price"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_item():
|
||||||
|
"""Deleting an item should remove it from the store."""
|
||||||
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 2}
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify deletion
|
||||||
|
list_response = client.list_items()
|
||||||
|
ids = {item["id"] for item in list_response.json()}
|
||||||
|
assert 2 not in ids
|
||||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "openapi-first"
|
name = "openapi-first"
|
||||||
version = "0.0.1"
|
version = "0.0.4"
|
||||||
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -84,7 +84,7 @@ Versions = "https://git.aetoskia.com/aetos/openapi-first/tags"
|
|||||||
packages = { find = { include = ["openapi_first*"] } }
|
packages = { find = { include = ["openapi_first*"] } }
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
fastapi_openapi_first = ["templates/**/*"]
|
openapi_first = ["templates/**/*"]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
fastapi==0.128.0,
|
fastapi==0.128.0,
|
||||||
openapi-spec-validator==0.7.2,
|
openapi-spec-validator==0.7.2
|
||||||
pyyaml==6.0.3,
|
pyyaml==6.0.3
|
||||||
uvicorn==0.40.0
|
uvicorn==0.40.0
|
||||||
|
pydantic==2.12.5
|
||||||
|
httpx==0.28.1
|
||||||
|
|
||||||
# Test Packages
|
# Test Packages
|
||||||
pytest==7.4.0
|
pytest==7.4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user