Compare commits
13 Commits
1c48f58578
...
richer-ope
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a7a76e330 | |||
| 083fb6923d | |||
| 69b795f9ca | |||
| 61de233745 | |||
| d912053368 | |||
| 0f591b666b | |||
| 808ffa8fed | |||
| 1e1d7fcde9 | |||
| d4b64d630a | |||
| 85aac955ac | |||
| 8299445b68 | |||
| eb845c5bf4 | |||
| 514f6e5f7c |
@@ -100,6 +100,8 @@ from . import binder
|
||||
from . import loader
|
||||
from . import client
|
||||
from . import errors
|
||||
from . import codegen
|
||||
from . import codegen_routes
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
@@ -107,4 +109,6 @@ __all__ = [
|
||||
"loader",
|
||||
"client",
|
||||
"errors",
|
||||
"codegen",
|
||||
"codegen_routes",
|
||||
]
|
||||
|
||||
@@ -3,5 +3,7 @@ from . import binder as binder
|
||||
from . import loader as loader
|
||||
from . import client as client
|
||||
from . import errors as errors
|
||||
from . import codegen as codegen
|
||||
from . import codegen_routes as codegen_routes
|
||||
|
||||
__all__ = ["app", "binder", "loader", "client", "errors"]
|
||||
__all__ = ["app", "binder", "loader", "client", "errors", "codegen", "codegen_routes"]
|
||||
|
||||
@@ -66,38 +66,135 @@ def copy_template(template: str, target_dir: Path) -> None:
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="FastAPI OpenAPI-first scaffolding tools"
|
||||
description="FastAPI OpenAPI-first developer tools"
|
||||
)
|
||||
parser.add_argument(
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
||||
|
||||
# Scaffold command
|
||||
scaffold_parser = subparsers.add_parser(
|
||||
"scaffold", help="Scaffold a new OpenAPI-first FastAPI application"
|
||||
)
|
||||
scaffold_parser.add_argument(
|
||||
"template",
|
||||
nargs="?",
|
||||
default=DEFAULT_TEMPLATE,
|
||||
help=f"Template name (default: {DEFAULT_TEMPLATE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
scaffold_parser.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Target directory (defaults to template name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
scaffold_parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available templates and exit",
|
||||
)
|
||||
|
||||
# Models command
|
||||
models_parser = subparsers.add_parser(
|
||||
"models", help="Generate Pydantic models from an OpenAPI specification"
|
||||
)
|
||||
models_parser.add_argument(
|
||||
"spec",
|
||||
help="Path to the OpenAPI specification file",
|
||||
)
|
||||
models_parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
required=True,
|
||||
help="Path to the output Python file",
|
||||
)
|
||||
|
||||
# Routes command
|
||||
routes_parser = subparsers.add_parser(
|
||||
"routes",
|
||||
help="Generate route handler stubs from an OpenAPI specification",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"spec",
|
||||
help="Path to the OpenAPI specification file",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"-o",
|
||||
"--output-dir",
|
||||
required=True,
|
||||
help="Directory where route files will be created",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"--use-models",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Import Pydantic models for request bodies",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"--models-module",
|
||||
default="models",
|
||||
help="Python module path for models (default: models)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
for name in available_templates():
|
||||
print(name)
|
||||
if args.command == "models":
|
||||
from .codegen import generate_models
|
||||
|
||||
try:
|
||||
generate_models(Path(args.spec), Path(args.output))
|
||||
print(f"Models generated successfully at {args.output}")
|
||||
except Exception as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
return
|
||||
|
||||
target = Path(args.path or args.template.replace("_", "-"))
|
||||
if args.command == "routes":
|
||||
from .codegen import generate_routes
|
||||
|
||||
try:
|
||||
copy_template(args.template, target)
|
||||
except Exception as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
try:
|
||||
files = generate_routes(
|
||||
spec_path=Path(args.spec),
|
||||
output_dir=Path(args.output_dir),
|
||||
use_models=args.use_models,
|
||||
models_module=args.models_module,
|
||||
)
|
||||
print(f"Generated {len(files)} route file(s):")
|
||||
for f in files:
|
||||
print(f" {f}")
|
||||
except Exception as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
return
|
||||
|
||||
print(f"Template '{args.template}' created at {target}")
|
||||
# Default to scaffold if no command or scaffold command
|
||||
if args.command == "scaffold" or args.command is None:
|
||||
# Handle the case where someone uses the old CLI style (openapi-first template path)
|
||||
# argparse with subparsers might not automatically handle this if positional args are passed
|
||||
# but let's assume we want to encourage the new style.
|
||||
|
||||
# If no command was provided but positional args were, they might be for scaffolding
|
||||
# This is a bit tricky with argparse subparsers.
|
||||
# For simplicity, let's just support the new explicit commands.
|
||||
|
||||
if args.command is None and not any(vars(args).values()):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if getattr(args, "list", False):
|
||||
for name in available_templates():
|
||||
print(name)
|
||||
return
|
||||
|
||||
template = getattr(args, "template", DEFAULT_TEMPLATE)
|
||||
path_arg = getattr(args, "path", None)
|
||||
target = Path(path_arg or template.replace("_", "-"))
|
||||
|
||||
try:
|
||||
copy_template(template, target)
|
||||
print(f"Template '{template}' created at {target}")
|
||||
except Exception as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
return
|
||||
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
53
openapi_first/codegen.py
Normal file
53
openapi_first/codegen.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
# Summary
|
||||
|
||||
Core logic for generating Python source code from OpenAPI specifications.
|
||||
|
||||
This module provides reusable utilities for code generation, specifically
|
||||
generating Pydantic models and route handler stubs from OpenAPI 3.x schema
|
||||
definitions.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from datamodel_code_generator import (
|
||||
InputFileType,
|
||||
PythonVersion,
|
||||
generate,
|
||||
)
|
||||
|
||||
from .codegen_routes import generate_routes
|
||||
|
||||
|
||||
def generate_models(
|
||||
spec_path: Path,
|
||||
output_path: Path,
|
||||
pydantic_version: int = 2,
|
||||
) -> None:
|
||||
"""
|
||||
Generate Pydantic models from an OpenAPI specification.
|
||||
|
||||
Args:
|
||||
spec_path (Path):
|
||||
Path to the OpenAPI specification file (YAML or JSON).
|
||||
output_path (Path):
|
||||
Path where the generated Python code should be written.
|
||||
pydantic_version (int, optional):
|
||||
The Pydantic version to target (1 or 2). Defaults to 2.
|
||||
|
||||
Notes:
|
||||
**Reusability:**
|
||||
This function is designed to be used by the CLI and can be
|
||||
exposed as an MCP tool without modification.
|
||||
"""
|
||||
generate(
|
||||
input_=spec_path,
|
||||
input_file_type=InputFileType.OpenAPI,
|
||||
output=output_path,
|
||||
target_python_version=PythonVersion.PY_310,
|
||||
use_schema_description=True,
|
||||
use_standard_collections=True,
|
||||
use_union_operator=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["generate_models", "generate_routes"]
|
||||
312
openapi_first/codegen_routes.py
Normal file
312
openapi_first/codegen_routes.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Route handler code generation from OpenAPI specifications.
|
||||
|
||||
This module generates Python route handler stubs from an OpenAPI 3.x
|
||||
specification. Each resource (derived from the first path segment)
|
||||
gets its own file under the output directory. Every OpenAPI operation
|
||||
must define an ``operationId``, which becomes the handler function name.
|
||||
|
||||
Notes:
|
||||
**Design constraints:**
|
||||
|
||||
- ``operationId`` is required on every operation (matching
|
||||
``binder.bind_routes``).
|
||||
- Handlers are stubs raising ``NotImplementedError``.
|
||||
- Sub-resources (e.g. ``/pets/{id}/photo``) are grouped with their
|
||||
parent resource (``pets``).
|
||||
- Parameter types and defaults are inferred from the spec.
|
||||
- ``response: Response`` is injected for non-200 success codes.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .loader import load_openapi
|
||||
|
||||
|
||||
def generate_routes(
|
||||
spec_path: Path,
|
||||
output_dir: Path,
|
||||
*,
|
||||
use_models: bool = False,
|
||||
models_module: str = "models",
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Generate route handler stubs from an OpenAPI specification.
|
||||
|
||||
Creates one ``<resource>.py`` file per resource in *output_dir*.
|
||||
Resources are derived from the first path segment (e.g. ``/pets``
|
||||
and ``/pets/{id}`` both group under ``pets``).
|
||||
|
||||
Args:
|
||||
spec_path:
|
||||
Path to the OpenAPI specification file (YAML or JSON).
|
||||
output_dir:
|
||||
Directory where the generated route files are written.
|
||||
Created automatically if it does not exist.
|
||||
use_models:
|
||||
If ``True``, import Pydantic models from *models_module*
|
||||
for request-body schemas referenced via ``$ref``.
|
||||
models_module:
|
||||
Dotted Python module path from which to import models
|
||||
(e.g. ``"models"``, ``"app.models"``).
|
||||
|
||||
Returns:
|
||||
list[Path]:
|
||||
Absolute paths of every generated route file.
|
||||
|
||||
Raises:
|
||||
OpenAPISpecLoadError:
|
||||
If the spec cannot be loaded or validated.
|
||||
ValueError:
|
||||
If any operation is missing ``operationId``.
|
||||
"""
|
||||
spec = load_openapi(spec_path)
|
||||
output_dir = Path(output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Group paths by resource (first non-param path segment)
|
||||
resources: dict[str, list[tuple[str, str, dict]]] = {}
|
||||
|
||||
paths = spec.get("paths", {})
|
||||
for path, methods in paths.items():
|
||||
segments = [s for s in path.split("/") if s and not s.startswith("{")]
|
||||
if not segments:
|
||||
continue
|
||||
resource = segments[0]
|
||||
if resource not in resources:
|
||||
resources[resource] = []
|
||||
|
||||
for http_method, operation in methods.items():
|
||||
if http_method.startswith("x-"):
|
||||
continue
|
||||
resources[resource].append((path, http_method, operation))
|
||||
|
||||
generated_files: list[Path] = []
|
||||
|
||||
for resource in sorted(resources):
|
||||
operations = resources[resource]
|
||||
_validate_operations(resource, operations)
|
||||
|
||||
file_path = output_dir / f"{resource}.py"
|
||||
content = _generate_resource_file(
|
||||
resource=resource,
|
||||
operations=operations,
|
||||
spec_path=str(spec_path),
|
||||
use_models=use_models,
|
||||
models_module=models_module,
|
||||
)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
generated_files.append(file_path)
|
||||
|
||||
return generated_files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TYPE_MAP: dict[str, str] = {
|
||||
"integer": "int",
|
||||
"number": "float",
|
||||
"boolean": "bool",
|
||||
"string": "str",
|
||||
"array": "list",
|
||||
"object": "dict",
|
||||
}
|
||||
|
||||
|
||||
def _validate_operations(resource: str, operations: list[tuple[str, str, dict]]) -> None:
|
||||
"""Ensure every operation has an operationId."""
|
||||
for path, http_method, operation in operations:
|
||||
if not operation.get("operationId"):
|
||||
raise ValueError(
|
||||
f"Missing operationId for {http_method.upper()} {path} "
|
||||
f"in resource '{resource}'. "
|
||||
"All operations must have an explicit operationId."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_type(schema: dict[str, Any]) -> str:
|
||||
"""Map an OpenAPI schema type to a Python type annotation."""
|
||||
openapi_type = schema.get("type", "")
|
||||
return _TYPE_MAP.get(openapi_type, "str")
|
||||
|
||||
|
||||
def _get_request_body_schema(operation: dict[str, Any]) -> str | None:
|
||||
"""Extract the ``$ref`` schema name from a request body, if any."""
|
||||
request_body = operation.get("requestBody")
|
||||
if not request_body:
|
||||
return None
|
||||
|
||||
content = request_body.get("content", {})
|
||||
for media_info in content.values():
|
||||
schema = media_info.get("schema", {})
|
||||
ref = schema.get("$ref", "")
|
||||
if ref:
|
||||
return ref.rsplit("/", 1)[-1]
|
||||
return None
|
||||
|
||||
|
||||
def _get_success_status(operation: dict[str, Any]) -> str:
|
||||
"""Return the primary success status code (200, 201, 204, or default)."""
|
||||
responses = operation.get("responses", {})
|
||||
for code in ("201", "200", "204"):
|
||||
if code in responses:
|
||||
return code
|
||||
if "default" in responses:
|
||||
return "default"
|
||||
return "200"
|
||||
|
||||
|
||||
def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
||||
"""Check if any operation uses a type that requires `from typing import Any`."""
|
||||
for _, _, op in operations:
|
||||
for param in op.get("parameters", []):
|
||||
schema = param.get("schema", {})
|
||||
if schema.get("type", "") not in _TYPE_MAP:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File / function generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generate_resource_file(
|
||||
resource: str,
|
||||
operations: list[tuple[str, str, dict]],
|
||||
spec_path: str,
|
||||
use_models: bool,
|
||||
models_module: str,
|
||||
) -> str:
|
||||
"""Assemble the full Python source for a single resource file."""
|
||||
op_ids = [op.get("operationId", "") for _, _, op in operations]
|
||||
lines: list[str] = [
|
||||
f'"""Route handlers for the {resource} resource.',
|
||||
"",
|
||||
f"Generated from OpenAPI spec: {spec_path}",
|
||||
f'Bind via operationIds: {", ".join(op_ids)}',
|
||||
'"""',
|
||||
"",
|
||||
"from fastapi import Response, HTTPException",
|
||||
]
|
||||
|
||||
# Conditional typing import
|
||||
if _needs_any(operations):
|
||||
lines.append("from typing import Any")
|
||||
|
||||
# Conditional model imports
|
||||
if use_models:
|
||||
schemas_needed: set[str] = set()
|
||||
for _, _, op in operations:
|
||||
schema = _get_request_body_schema(op)
|
||||
if schema:
|
||||
schemas_needed.add(schema)
|
||||
if schemas_needed:
|
||||
lines.append(f"from {models_module} import {', '.join(sorted(schemas_needed))}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
for path, http_method, operation in operations:
|
||||
lines.append("")
|
||||
lines.extend(_generate_handler(path, http_method, operation, use_models))
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_handler(
|
||||
path: str,
|
||||
http_method: str,
|
||||
operation: dict[str, Any],
|
||||
use_models: bool,
|
||||
) -> list[str]:
|
||||
"""Build the source lines for a single handler function."""
|
||||
operation_id = operation.get("operationId", "")
|
||||
summary = operation.get("summary", f"{http_method.upper()} {path}")
|
||||
|
||||
params: list[str] = []
|
||||
path_params: list[str] = []
|
||||
query_params: list[str] = []
|
||||
|
||||
for param in operation.get("parameters", []):
|
||||
name: str = param.get("name", "")
|
||||
param_in: str = param.get("in", "")
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
required: bool = param.get("required", False)
|
||||
description: str = schema.get("description", schema.get("x-description", ""))
|
||||
default_raw = schema.get("default")
|
||||
|
||||
if param_in == "path":
|
||||
path_params.append(f"{name}: {param_type}")
|
||||
elif param_in == "query":
|
||||
if default_raw is not None:
|
||||
default_repr = repr(default_raw)
|
||||
query_params.append(f"{name}: {param_type} = {default_repr}")
|
||||
elif required:
|
||||
query_params.append(f"{name}: {param_type}")
|
||||
else:
|
||||
query_params.append(f"{name}: {param_type} = None")
|
||||
# header / cookie params could be extended here
|
||||
|
||||
params.extend(path_params)
|
||||
params.extend(query_params)
|
||||
|
||||
# Request body
|
||||
schema_name = _get_request_body_schema(operation)
|
||||
if operation.get("requestBody"):
|
||||
if use_models and schema_name:
|
||||
params.append(f"payload: {schema_name}")
|
||||
else:
|
||||
params.append("payload: dict")
|
||||
|
||||
# Inject Response for non-200 success codes
|
||||
success_status = _get_success_status(operation)
|
||||
if success_status in ("201", "204"):
|
||||
params.append("response: Response")
|
||||
|
||||
# Build function body
|
||||
lines: list[str] = []
|
||||
param_str = ", ".join(params)
|
||||
lines.append(f"def {operation_id}({param_str}):")
|
||||
lines.append(f' """{summary}')
|
||||
|
||||
# Document parameters
|
||||
doc_params: list[tuple[str, str, str]] = []
|
||||
for param in operation.get("parameters", []):
|
||||
name: str = param.get("name", "")
|
||||
param_in: str = param.get("in", "")
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
description: str = param.get("description", schema.get("x-description", ""))
|
||||
if param_in in ("path", "query"):
|
||||
doc_params.append((name, param_type, description))
|
||||
|
||||
if doc_params:
|
||||
lines.append("")
|
||||
lines.append(" Parameters")
|
||||
lines.append(" ----------")
|
||||
for pname, ptype, desc in doc_params:
|
||||
if desc:
|
||||
lines.append(f" {pname} : {ptype}")
|
||||
lines.append(f" {desc}")
|
||||
else:
|
||||
lines.append(f" {pname} : {ptype}")
|
||||
|
||||
if operation.get("requestBody"):
|
||||
if use_models and schema_name:
|
||||
lines.append("")
|
||||
lines.append(f" payload : {schema_name}")
|
||||
lines.append(" Request body.")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append(" payload : dict")
|
||||
lines.append(" Request body.")
|
||||
|
||||
lines.append(' """')
|
||||
lines.append(" raise NotImplementedError")
|
||||
|
||||
return lines
|
||||
@@ -70,7 +70,7 @@ def test_create_item():
|
||||
}
|
||||
|
||||
response = client.create_item(
|
||||
body=payload
|
||||
body=payload,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_update_item():
|
||||
def test_delete_item():
|
||||
"""Deleting an item should remove it from the store."""
|
||||
response = client.delete_item(
|
||||
path_params={"item_id": 2}
|
||||
path_params={"item_id": 2},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_create_item():
|
||||
}
|
||||
|
||||
response = client.create_item(
|
||||
body=payload
|
||||
body=payload,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -114,7 +114,7 @@ def test_update_item():
|
||||
def test_delete_item():
|
||||
"""Deleting an item should remove it from the store."""
|
||||
response = client.delete_item(
|
||||
path_params={"item_id": 2}
|
||||
path_params={"item_id": 2},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
97
openapi_first/templates/vet_app/__init__.py
Normal file
97
openapi_first/templates/vet_app/__init__.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
OpenAPI-first Veterinary Clinic application template.
|
||||
|
||||
This package contains a complete, runnable example of an OpenAPI-first
|
||||
veterinary clinic management service. It demonstrates all ``x-`` extension
|
||||
fields consumed by the ``react-openapi`` admin panel renderer.
|
||||
|
||||
The application manages five resources:
|
||||
|
||||
- **Parents** — pet owners with contact details
|
||||
- **Vets** — veterinarians with specializations
|
||||
- **Treatments** — medical procedure catalog
|
||||
- **Pets** — animals with species, age, weight, and photos
|
||||
- **Appointments** — scheduled visits linking pets, vets, and treatments
|
||||
|
||||
All HTTP routes, methods, schemas, and operation bindings are defined
|
||||
in the OpenAPI specification (``openapi.yaml``). Every operation has an
|
||||
explicit ``operationId`` that maps to a Python handler in ``routes.py``.
|
||||
|
||||
This file is a copyable template. It is not part of the ``openapi_first``
|
||||
library API surface.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
OpenAPI x- extension fields demonstrated
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Schema-level extensions (mark a schema as a UI resource):
|
||||
|
||||
``x-resource`` (REQUIRED) Maps schema to URL path segment
|
||||
``x-primary-key`` (REQUIRED) Primary key property name
|
||||
``x-display-format`` (REQUIRED) Human-readable label template
|
||||
``x-list-columns`` (REQUIRED) Columns for the datatable
|
||||
|
||||
Property-level extensions (control UI rendering):
|
||||
|
||||
``x-label`` (REQUIRED) Human-readable field label
|
||||
``x-order`` (REQUIRED) Field ordering in forms/detail
|
||||
``x-description`` (optional) Helper text below form fields
|
||||
``x-hidden`` (optional) Visibility in form / list / detail
|
||||
``x-filterable`` (optional) Allows column filtering
|
||||
``x-sortable`` (optional) Allows column sorting
|
||||
``x-fk`` (optional) Foreign key — renders as dropdown
|
||||
``x-fk.resource`` (REQUIRED for FK) Target resource name
|
||||
``x-fk.prefetch`` (optional) Preload all FK options on mount
|
||||
``x-ui-type`` (optional) Custom UI type (e.g. image upload)
|
||||
``x-upload-url`` (optional) Upload endpoint for binary fields
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Scaffolding via CLI
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Create a new vet clinic service using the bundled template:
|
||||
|
||||
openapi-first vet_app
|
||||
|
||||
Create the service in a custom directory:
|
||||
|
||||
openapi-first vet_app my-vet-clinic
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Client Usage Example
|
||||
----------------------------------------------------------------------
|
||||
|
||||
from openapi_first.loader import load_openapi
|
||||
from openapi_first.client import OpenAPIClient
|
||||
|
||||
spec = load_openapi("openapi.yaml")
|
||||
client = OpenAPIClient(spec)
|
||||
|
||||
# List pets with pagination
|
||||
response = client.list_pets(query_params={"limit": 10, "offset": 0})
|
||||
|
||||
# Create a pet with FK references
|
||||
response = client.create_pet(
|
||||
body={"name": "Fido", "species": "dog", "parents": [1, 2]}
|
||||
)
|
||||
|
||||
# Upload a pet photo
|
||||
response = client.upload_pet_photo(
|
||||
path_params={"id": 1},
|
||||
body={"file": open("photo.jpg", "rb")},
|
||||
)
|
||||
|
||||
----------------------------------------------------------------------
|
||||
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.
|
||||
"""
|
||||
356
openapi_first/templates/vet_app/data.py
Normal file
356
openapi_first/templates/vet_app/data.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
In-memory data store for the Veterinary Clinic example.
|
||||
|
||||
This module is NOT thread-safe and is intended for demos and scaffolds only.
|
||||
|
||||
It provides minimal, process-local data stores for the five veterinary
|
||||
clinic entities. Each store exposes standard CRUD operations backed by
|
||||
a simple dictionary.
|
||||
|
||||
This module intentionally avoids:
|
||||
- persistence
|
||||
- concurrency guarantees
|
||||
- transactional semantics
|
||||
- validation beyond what Pydantic provides
|
||||
|
||||
This module is not part of the ``openapi_first`` library API surface.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from models import (
|
||||
Parent, ParentCreate,
|
||||
Vet, VetCreate,
|
||||
Treatment, TreatmentCreate,
|
||||
Procedure, ProcedureNotes,
|
||||
Pet, PetCreate,
|
||||
Appointment, AppointmentCreate,
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_parents: dict[int, Parent] = {}
|
||||
_parents_next_id = 1
|
||||
|
||||
|
||||
def list_parents() -> list[Parent]:
|
||||
return list(_parents.values())
|
||||
|
||||
|
||||
def get_parent(parent_id: int) -> Parent:
|
||||
return _parents[parent_id]
|
||||
|
||||
|
||||
def create_parent(payload: ParentCreate) -> Parent:
|
||||
global _parents_next_id
|
||||
now = _now()
|
||||
parent = Parent(
|
||||
id=_parents_next_id,
|
||||
name=payload.name,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None,
|
||||
)
|
||||
_parents[_parents_next_id] = parent
|
||||
_parents_next_id += 1
|
||||
return parent
|
||||
|
||||
|
||||
def update_parent(parent_id: int, payload: ParentCreate) -> Parent:
|
||||
if parent_id not in _parents:
|
||||
raise KeyError(parent_id)
|
||||
now = _now()
|
||||
current = _parents[parent_id]
|
||||
updated = Parent(
|
||||
id=parent_id,
|
||||
name=payload.name,
|
||||
email=payload.email,
|
||||
phone=payload.phone if payload.phone is not None else current.phone,
|
||||
metadata={"createdOn": current.metadata["createdOn"] if current.metadata else None, "updatedOn": now},
|
||||
)
|
||||
_parents[parent_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def delete_parent(parent_id: int) -> None:
|
||||
del _parents[parent_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_vets: dict[int, Vet] = {}
|
||||
_vets_next_id = 1
|
||||
|
||||
|
||||
def list_vets() -> list[Vet]:
|
||||
return list(_vets.values())
|
||||
|
||||
|
||||
def get_vet(vet_id: int) -> Vet:
|
||||
return _vets[vet_id]
|
||||
|
||||
|
||||
def create_vet(payload: VetCreate) -> Vet:
|
||||
global _vets_next_id
|
||||
now = _now()
|
||||
vet = Vet(
|
||||
id=_vets_next_id,
|
||||
name=payload.name,
|
||||
specialty=payload.specialty,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None,
|
||||
)
|
||||
_vets[_vets_next_id] = vet
|
||||
_vets_next_id += 1
|
||||
return vet
|
||||
|
||||
|
||||
def update_vet(vet_id: int, payload: VetCreate) -> Vet:
|
||||
if vet_id not in _vets:
|
||||
raise KeyError(vet_id)
|
||||
now = _now()
|
||||
current = _vets[vet_id]
|
||||
updated = Vet(
|
||||
id=vet_id,
|
||||
name=payload.name,
|
||||
specialty=payload.specialty if payload.specialty is not None else current.specialty,
|
||||
email=payload.email,
|
||||
phone=payload.phone if payload.phone is not None else current.phone,
|
||||
metadata={"createdOn": current.metadata["createdOn"] if current.metadata else None, "updatedOn": now},
|
||||
)
|
||||
_vets[vet_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def delete_vet(vet_id: int) -> None:
|
||||
del _vets[vet_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Treatments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_treatments: dict[int, Treatment] = {}
|
||||
_treatments_next_id = 1
|
||||
|
||||
|
||||
def list_treatments() -> list[Treatment]:
|
||||
return list(_treatments.values())
|
||||
|
||||
|
||||
def get_treatment(treatment_id: int) -> Treatment:
|
||||
return _treatments[treatment_id]
|
||||
|
||||
|
||||
def create_treatment(payload: TreatmentCreate) -> Treatment:
|
||||
global _treatments_next_id
|
||||
now = _now()
|
||||
treatment = Treatment(
|
||||
id=_treatments_next_id,
|
||||
label=payload.label,
|
||||
description=payload.description,
|
||||
metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None,
|
||||
)
|
||||
_treatments[_treatments_next_id] = treatment
|
||||
_treatments_next_id += 1
|
||||
return treatment
|
||||
|
||||
|
||||
def update_treatment(treatment_id: int, payload: TreatmentCreate) -> Treatment:
|
||||
if treatment_id not in _treatments:
|
||||
raise KeyError(treatment_id)
|
||||
now = _now()
|
||||
current = _treatments[treatment_id]
|
||||
updated = Treatment(
|
||||
id=treatment_id,
|
||||
label=payload.label,
|
||||
description=payload.description if payload.description is not None else current.description,
|
||||
metadata={"createdOn": current.metadata["createdOn"] if current.metadata else None, "updatedOn": now},
|
||||
)
|
||||
_treatments[treatment_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def delete_treatment(treatment_id: int) -> None:
|
||||
del _treatments[treatment_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_pets: dict[int, Pet] = {}
|
||||
_pets_next_id = 1
|
||||
|
||||
|
||||
def list_pets() -> list[Pet]:
|
||||
return list(_pets.values())
|
||||
|
||||
|
||||
def get_pet(pet_id: int) -> Pet:
|
||||
return _pets[pet_id]
|
||||
|
||||
|
||||
def create_pet(payload: PetCreate) -> Pet:
|
||||
global _pets_next_id
|
||||
now = _now()
|
||||
parents = [_parents[pid] for pid in payload.parent_ids]
|
||||
pet = Pet(
|
||||
id=_pets_next_id,
|
||||
name=payload.name,
|
||||
species=payload.species,
|
||||
age=payload.age,
|
||||
weight=payload.weight,
|
||||
birthDate=payload.birthDate,
|
||||
photo=payload.photo,
|
||||
parents=parents,
|
||||
metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None,
|
||||
)
|
||||
_pets[_pets_next_id] = pet
|
||||
_pets_next_id += 1
|
||||
return pet
|
||||
|
||||
|
||||
def update_pet(pet_id: int, payload: PetCreate) -> Pet:
|
||||
if pet_id not in _pets:
|
||||
raise KeyError(pet_id)
|
||||
now = _now()
|
||||
parents = [_parents[pid] for pid in payload.parent_ids]
|
||||
current = _pets[pet_id]
|
||||
updated = Pet(
|
||||
id=pet_id,
|
||||
name=payload.name,
|
||||
species=payload.species,
|
||||
age=payload.age if payload.age is not None else current.age,
|
||||
weight=payload.weight if payload.weight is not None else current.weight,
|
||||
birthDate=payload.birthDate if payload.birthDate is not None else current.birthDate,
|
||||
photo=payload.photo if payload.photo is not None else current.photo,
|
||||
parents=parents,
|
||||
metadata={"createdOn": current.metadata["createdOn"] if current.metadata else None, "updatedOn": now},
|
||||
)
|
||||
_pets[pet_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def delete_pet(pet_id: int) -> None:
|
||||
del _pets[pet_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Appointments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_appointments: dict[int, Appointment] = {}
|
||||
_appointments_next_id = 1
|
||||
|
||||
|
||||
def list_appointments() -> list[Appointment]:
|
||||
return list(_appointments.values())
|
||||
|
||||
|
||||
def get_appointment(appointment_id: int) -> Appointment:
|
||||
return _appointments[appointment_id]
|
||||
|
||||
|
||||
def create_appointment(payload: AppointmentCreate) -> Appointment:
|
||||
global _appointments_next_id
|
||||
now = _now()
|
||||
appointment = Appointment(
|
||||
id=_appointments_next_id,
|
||||
date=payload.date,
|
||||
notes=payload.notes,
|
||||
procedures=payload.procedures,
|
||||
pet=_pets[payload.pet_id],
|
||||
vet=_vets[payload.vet_id],
|
||||
treatment=_treatments[payload.treatment_id],
|
||||
metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None,
|
||||
)
|
||||
_appointments[_appointments_next_id] = appointment
|
||||
_appointments_next_id += 1
|
||||
return appointment
|
||||
|
||||
|
||||
def update_appointment(appointment_id: int, payload: AppointmentCreate) -> Appointment:
|
||||
if appointment_id not in _appointments:
|
||||
raise KeyError(appointment_id)
|
||||
now = _now()
|
||||
current = _appointments[appointment_id]
|
||||
updated = Appointment(
|
||||
id=appointment_id,
|
||||
date=payload.date,
|
||||
notes=payload.notes if payload.notes is not None else current.notes,
|
||||
procedures=payload.procedures,
|
||||
pet=_pets.get(payload.pet_id, current.pet),
|
||||
vet=_vets.get(payload.vet_id, current.vet),
|
||||
treatment=_treatments.get(payload.treatment_id, current.treatment),
|
||||
metadata={"createdOn": current.metadata["createdOn"] if current.metadata else None, "updatedOn": now},
|
||||
)
|
||||
_appointments[appointment_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def delete_appointment(appointment_id: int) -> None:
|
||||
del _appointments[appointment_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed data — populate stores so the UI isn't empty on startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_data():
|
||||
now = _now()
|
||||
meta = {"createdOn": now, "updatedOn": now}
|
||||
global _parents_next_id, _vets_next_id, _treatments_next_id
|
||||
global _pets_next_id, _appointments_next_id
|
||||
|
||||
_parents[1] = Parent(id=1, name="Alice Johnson", email="alice@example.com", phone="555-0101", metadata=meta)
|
||||
_parents[2] = Parent(id=2, name="Bob Smith", email="bob@example.com", phone="555-0102", metadata=meta)
|
||||
_parents[3] = Parent(id=3, name="Carol Williams", email="carol@example.com", phone="555-0103", metadata=meta)
|
||||
_parents[4] = Parent(id=4, name="Dave Brown", email="dave@example.com", phone="555-0104", metadata=meta)
|
||||
_parents_next_id = 5
|
||||
|
||||
_vets[1] = Vet(id=1, name="Sarah Connor", specialty="Surgery", email="sarah@clinic.com", phone="555-0201", metadata=meta)
|
||||
_vets[2] = Vet(id=2, name="James Wilson", specialty="Dentistry", email="james@clinic.com", phone="555-0202", metadata=meta)
|
||||
_vets[3] = Vet(id=3, name="Emily Davis", specialty="General Practice", email="emily@clinic.com", phone="555-0203", metadata=meta)
|
||||
_vets_next_id = 4
|
||||
|
||||
_treatments[1] = Treatment(id=1, label="Annual Checkup", description="Full physical examination", metadata=meta)
|
||||
_treatments[2] = Treatment(id=2, label="Vaccination", description="Core vaccines for common diseases", metadata=meta)
|
||||
_treatments[3] = Treatment(id=3, label="Dental Cleaning", description="Scaling, polishing, and oral exam", metadata=meta)
|
||||
_treatments[4] = Treatment(id=4, label="Spay/Neuter", description="Surgical sterilization", metadata=meta)
|
||||
_treatments[5] = Treatment(id=5, label="Blood Panel", description="Complete blood count and chemistry", metadata=meta)
|
||||
_treatments_next_id = 6
|
||||
|
||||
_pets[1] = Pet(id=1, name="Max", species="dog", age=4, weight=25.5, birthDate=date(2022, 3, 15), parents=[_parents[1]], metadata=meta)
|
||||
_pets[2] = Pet(id=2, name="Luna", species="cat", age=2, weight=4.2, birthDate=date(2024, 1, 10), parents=[_parents[1], _parents[2]], metadata=meta)
|
||||
_pets[3] = Pet(id=3, name="Charlie", species="dog", age=7, weight=18.0, birthDate=date(2019, 8, 22), parents=[_parents[2]], metadata=meta)
|
||||
_pets[4] = Pet(id=4, name="Bella", species="bird", age=1, weight=0.3, birthDate=date(2025, 5, 1), parents=[_parents[3]], metadata=meta)
|
||||
_pets[5] = Pet(id=5, name="Rocky", species="dog", age=3, weight=30.0, birthDate=date(2023, 11, 5), parents=[_parents[4]], metadata=meta)
|
||||
_pets_next_id = 6
|
||||
|
||||
_appointments[1] = Appointment(id=1, date=datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc), notes="Annual checkup",
|
||||
procedures=[Procedure(name="Physical Exam", cost=50.0), Procedure(name="Heart Rate", notes=ProcedureNotes(summary="Normal rhythm"))],
|
||||
pet=_pets[1], vet=_vets[1], treatment=_treatments[1], metadata=meta)
|
||||
_appointments[2] = Appointment(id=2, date=datetime(2026, 6, 18, 10, 30, tzinfo=timezone.utc), notes="Dental cleaning",
|
||||
procedures=[Procedure(name="Scaling", cost=80.0), Procedure(name="Polishing", cost=40.0, notes=ProcedureNotes(summary="High-speed polish"))],
|
||||
pet=_pets[2], vet=_vets[2], treatment=_treatments[3], metadata=meta)
|
||||
_appointments[3] = Appointment(id=3, date=datetime(2026, 6, 19, 11, 0, tzinfo=timezone.utc), notes="Vaccination booster",
|
||||
procedures=[Procedure(name="DHPP Vaccine", cost=35.0), Procedure(name="Rabies Vaccine", cost=45.0)],
|
||||
pet=_pets[3], vet=_vets[3], treatment=_treatments[2], metadata=meta)
|
||||
_appointments[4] = Appointment(id=4, date=datetime(2026, 6, 20, 14, 0, tzinfo=timezone.utc), notes="Follow-up after surgery",
|
||||
procedures=[Procedure(name="Pre-op Exam", cost=30.0), Procedure(name="Surgery", cost=200.0), Procedure(name="Post-op Care", cost=50.0)],
|
||||
pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta)
|
||||
_appointments_next_id = 5
|
||||
|
||||
|
||||
_seed_data()
|
||||
58
openapi_first/templates/vet_app/main.py
Normal file
58
openapi_first/templates/vet_app/main.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Application entry point for an OpenAPI-first Veterinary Clinic 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 contextlib import asynccontextmanager
|
||||
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
import routes
|
||||
from sse import start_worker, stop_worker
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
start_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_worker()
|
||||
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=routes,
|
||||
title="Veterinary Clinic Service",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
103
openapi_first/templates/vet_app/models.py
Normal file
103
openapi_first/templates/vet_app/models.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from datetime import date, datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Metadata(BaseModel):
|
||||
createdOn: datetime | None = None
|
||||
updatedOn: datetime | None = None
|
||||
|
||||
|
||||
class ParentBase(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
phone: str | None = None
|
||||
metadata: Metadata | None = None
|
||||
|
||||
|
||||
class ParentCreate(ParentBase):
|
||||
pass
|
||||
|
||||
|
||||
class Parent(ParentBase):
|
||||
id: int
|
||||
|
||||
|
||||
class VetBase(BaseModel):
|
||||
name: str
|
||||
specialty: str | None = None
|
||||
email: str
|
||||
phone: str | None = None
|
||||
metadata: Metadata | None = None
|
||||
|
||||
|
||||
class VetCreate(VetBase):
|
||||
pass
|
||||
|
||||
|
||||
class Vet(VetBase):
|
||||
id: int
|
||||
|
||||
|
||||
class ProcedureNotes(BaseModel):
|
||||
summary: str | None = None
|
||||
details: str | None = None
|
||||
|
||||
|
||||
class Procedure(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
cost: float | None = None
|
||||
notes: ProcedureNotes | None = None
|
||||
|
||||
|
||||
class TreatmentBase(BaseModel):
|
||||
label: str
|
||||
description: str | None = None
|
||||
metadata: Metadata | None = None
|
||||
|
||||
|
||||
class TreatmentCreate(TreatmentBase):
|
||||
pass
|
||||
|
||||
|
||||
class Treatment(TreatmentBase):
|
||||
id: int
|
||||
|
||||
|
||||
class PetBase(BaseModel):
|
||||
name: str
|
||||
species: str
|
||||
age: int | None = None
|
||||
weight: float | None = None
|
||||
birthDate: date | None = None
|
||||
photo: str | None = None
|
||||
metadata: Metadata | None = None
|
||||
|
||||
|
||||
class PetCreate(PetBase):
|
||||
parent_ids: list[int] = []
|
||||
|
||||
|
||||
class Pet(PetBase):
|
||||
id: int
|
||||
parents: list[Parent] = []
|
||||
|
||||
|
||||
class AppointmentBase(BaseModel):
|
||||
date: datetime
|
||||
notes: str | None = None
|
||||
procedures: list[Procedure] = []
|
||||
metadata: Metadata | None = None
|
||||
|
||||
|
||||
class AppointmentCreate(AppointmentBase):
|
||||
pet_id: int
|
||||
vet_id: int
|
||||
treatment_id: int
|
||||
|
||||
|
||||
class Appointment(AppointmentBase):
|
||||
id: int
|
||||
pet: Pet
|
||||
vet: Vet
|
||||
treatment: Treatment
|
||||
1090
openapi_first/templates/vet_app/openapi.yaml
Normal file
1090
openapi_first/templates/vet_app/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
383
openapi_first/templates/vet_app/routes.py
Normal file
383
openapi_first/templates/vet_app/routes.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Veterinary Clinic route handlers bound via OpenAPI operationId.
|
||||
|
||||
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, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from sse import subscribe, unsubscribe
|
||||
|
||||
from models import (
|
||||
ParentCreate,
|
||||
VetCreate,
|
||||
TreatmentCreate,
|
||||
PetCreate,
|
||||
AppointmentCreate,
|
||||
)
|
||||
from data import (
|
||||
list_parents as _list_parents,
|
||||
get_parent as _get_parent,
|
||||
create_parent as _create_parent,
|
||||
update_parent as _update_parent,
|
||||
delete_parent as _delete_parent,
|
||||
list_vets as _list_vets,
|
||||
get_vet as _get_vet,
|
||||
create_vet as _create_vet,
|
||||
update_vet as _update_vet,
|
||||
delete_vet as _delete_vet,
|
||||
list_treatments as _list_treatments,
|
||||
get_treatment as _get_treatment,
|
||||
create_treatment as _create_treatment,
|
||||
update_treatment as _update_treatment,
|
||||
delete_treatment as _delete_treatment,
|
||||
list_pets as _list_pets,
|
||||
get_pet as _get_pet,
|
||||
create_pet as _create_pet,
|
||||
update_pet as _update_pet,
|
||||
delete_pet as _delete_pet,
|
||||
list_appointments as _list_appointments,
|
||||
get_appointment as _get_appointment,
|
||||
create_appointment as _create_appointment,
|
||||
update_appointment as _update_appointment,
|
||||
delete_appointment as _delete_appointment,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_parents(limit: int = 20, offset: int = 0):
|
||||
"""List parents (paginated).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
limit : int
|
||||
Maximum number of records to return.
|
||||
offset : int
|
||||
Number of records to skip.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Paginated response with ``total`` and ``items``.
|
||||
"""
|
||||
items = _list_parents()
|
||||
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
|
||||
|
||||
|
||||
def create_parent(payload: ParentCreate, response: Response):
|
||||
"""Create a parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload : ParentCreate
|
||||
Parent data excluding the ``id`` field.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Parent
|
||||
The newly created parent.
|
||||
"""
|
||||
parent = _create_parent(payload)
|
||||
response.status_code = 201
|
||||
return parent
|
||||
|
||||
|
||||
def get_parent(id: int):
|
||||
"""Retrieve a single parent by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
id : int
|
||||
Identifier of the parent.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Parent
|
||||
The requested parent.
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
404 if the parent does not exist.
|
||||
"""
|
||||
try:
|
||||
return _get_parent(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Parent not found")
|
||||
|
||||
|
||||
def update_parent(id: int, payload: ParentCreate):
|
||||
"""Update an existing parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
id : int
|
||||
Identifier of the parent.
|
||||
payload : ParentCreate
|
||||
Updated parent data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Parent
|
||||
The updated parent.
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
404 if the parent does not exist.
|
||||
"""
|
||||
try:
|
||||
return _update_parent(id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Parent not found")
|
||||
|
||||
|
||||
def delete_parent(id: int, response: Response):
|
||||
"""Delete an existing parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
id : int
|
||||
Identifier of the parent.
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
404 if the parent does not exist.
|
||||
"""
|
||||
try:
|
||||
_delete_parent(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Parent not found")
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_vets(limit: int = 20, offset: int = 0):
|
||||
"""List vets (paginated)."""
|
||||
items = _list_vets()
|
||||
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
|
||||
|
||||
|
||||
def create_vet(payload: VetCreate, response: Response):
|
||||
"""Create a vet."""
|
||||
vet = _create_vet(payload)
|
||||
response.status_code = 201
|
||||
return vet
|
||||
|
||||
|
||||
def get_vet(id: int):
|
||||
"""Retrieve a single vet by ID."""
|
||||
try:
|
||||
return _get_vet(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Vet not found")
|
||||
|
||||
|
||||
def update_vet(id: int, payload: VetCreate):
|
||||
"""Update an existing vet."""
|
||||
try:
|
||||
return _update_vet(id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Vet not found")
|
||||
|
||||
|
||||
def delete_vet(id: int, response: Response):
|
||||
"""Delete an existing vet."""
|
||||
try:
|
||||
_delete_vet(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Vet not found")
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Treatments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_treatments():
|
||||
"""List treatments (catalogue).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Treatment]
|
||||
A list of treatment domain objects.
|
||||
"""
|
||||
return _list_treatments()
|
||||
|
||||
|
||||
def create_treatment(payload: TreatmentCreate, response: Response):
|
||||
"""Add a treatment (admin only)."""
|
||||
treatment = _create_treatment(payload)
|
||||
response.status_code = 201
|
||||
return treatment
|
||||
|
||||
|
||||
def get_treatment(id: int):
|
||||
"""Retrieve a single treatment by ID."""
|
||||
try:
|
||||
return _get_treatment(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Treatment not found")
|
||||
|
||||
|
||||
def update_treatment(id: int, payload: TreatmentCreate):
|
||||
"""Update an existing treatment."""
|
||||
try:
|
||||
return _update_treatment(id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Treatment not found")
|
||||
|
||||
|
||||
def delete_treatment(id: int, response: Response):
|
||||
"""Delete an existing treatment."""
|
||||
try:
|
||||
_delete_treatment(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Treatment not found")
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_pets(limit: int = 20, offset: int = 0):
|
||||
"""List pets (paginated)."""
|
||||
items = _list_pets()
|
||||
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
|
||||
|
||||
|
||||
def create_pet(payload: PetCreate, response: Response):
|
||||
"""Create a pet."""
|
||||
pet = _create_pet(payload)
|
||||
response.status_code = 201
|
||||
return pet
|
||||
|
||||
|
||||
def get_pet(id: int):
|
||||
"""Retrieve a single pet by ID."""
|
||||
try:
|
||||
return _get_pet(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Pet not found")
|
||||
|
||||
|
||||
def update_pet(id: int, payload: PetCreate):
|
||||
"""Update an existing pet."""
|
||||
try:
|
||||
return _update_pet(id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Pet not found")
|
||||
|
||||
|
||||
def delete_pet(id: int, response: Response):
|
||||
"""Delete an existing pet."""
|
||||
try:
|
||||
_delete_pet(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Pet not found")
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
def upload_pet_photo(id: int, file: UploadFile):
|
||||
"""Upload a pet photo.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
id : int
|
||||
Identifier of the pet.
|
||||
file : UploadFile
|
||||
Image file to upload.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A confirmation with the pet ID.
|
||||
"""
|
||||
_ = file # In a real app, save to disk / object store
|
||||
return {"id": id, "status": "photo_uploaded"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Appointments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_appointments(limit: int = 20, offset: int = 0, date: str = None, vet: int = None, pet: int = None):
|
||||
"""List appointments (paginated, filterable)."""
|
||||
items = _list_appointments()
|
||||
|
||||
# Basic in-memory filtering
|
||||
if date:
|
||||
items = [a for a in items if a.date.startswith(date)]
|
||||
if vet is not None:
|
||||
items = [a for a in items if a.vet.id == vet]
|
||||
if pet is not None:
|
||||
items = [a for a in items if a.pet.id == pet]
|
||||
|
||||
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
|
||||
|
||||
|
||||
def create_appointment(payload: AppointmentCreate, response: Response):
|
||||
"""Create an appointment."""
|
||||
appointment = _create_appointment(payload)
|
||||
response.status_code = 201
|
||||
return appointment
|
||||
|
||||
|
||||
def get_appointment(id: int):
|
||||
"""Retrieve a single appointment by ID."""
|
||||
try:
|
||||
return _get_appointment(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||
|
||||
|
||||
def update_appointment(id: int, payload: AppointmentCreate):
|
||||
"""Update an existing appointment."""
|
||||
try:
|
||||
return _update_appointment(id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||
|
||||
|
||||
def delete_appointment(id: int, response: Response):
|
||||
"""Delete an existing appointment."""
|
||||
try:
|
||||
_delete_appointment(id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
async def stream_calls():
|
||||
"""Stream random animal sounds via SSE."""
|
||||
q = await subscribe()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
data = await q.get()
|
||||
yield f"data: {data}\n\n"
|
||||
finally:
|
||||
unsubscribe(q)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
44
openapi_first/templates/vet_app/sse.py
Normal file
44
openapi_first/templates/vet_app/sse.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
SSE broadcast for the animal-sounds worker.
|
||||
|
||||
Not part of the openapi_first library API surface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import json
|
||||
|
||||
|
||||
_sounds = ["woof", "meow", "coo"]
|
||||
_subscribers: list[asyncio.Queue] = []
|
||||
_worker_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _sound_worker():
|
||||
while True:
|
||||
sound = random.choice(_sounds)
|
||||
data = json.dumps({"sound": sound})
|
||||
for q in _subscribers:
|
||||
await q.put(data)
|
||||
await asyncio.sleep(random.uniform(1, 5))
|
||||
|
||||
|
||||
def start_worker():
|
||||
global _worker_task
|
||||
_worker_task = asyncio.create_task(_sound_worker())
|
||||
|
||||
|
||||
def stop_worker():
|
||||
if _worker_task is not None:
|
||||
_worker_task.cancel()
|
||||
|
||||
|
||||
async def subscribe() -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue):
|
||||
if q in _subscribers:
|
||||
_subscribers.remove(q)
|
||||
151
openapi_first/templates/vet_app/test_vet_app.py
Normal file
151
openapi_first/templates/vet_app/test_vet_app.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
End-to-end tests for the OpenAPI-first Veterinary Clinic example app.
|
||||
|
||||
These tests validate that all CRUD operations behave correctly
|
||||
against the in-memory mock data store using Pydantic models.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from openapi_first.loader import load_openapi
|
||||
from openapi_first.client import OpenAPIClient
|
||||
|
||||
|
||||
test_client = TestClient(app)
|
||||
spec = load_openapi("openapi.yaml")
|
||||
client = OpenAPIClient(
|
||||
spec=spec,
|
||||
base_url="http://testserver",
|
||||
client=test_client,
|
||||
)
|
||||
|
||||
|
||||
def test_list_parents():
|
||||
"""List parents returns paginated response."""
|
||||
response = client.list_parents(query={"limit": 10, "offset": 0})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total" in data
|
||||
assert "items" in data
|
||||
|
||||
|
||||
def test_create_parent():
|
||||
"""Creating a parent returns 201 with the created entity."""
|
||||
payload = {"name": "Alice", "email": "alice@example.com"}
|
||||
response = client.create_parent(body=payload)
|
||||
assert response.status_code == 201
|
||||
parent = response.json()
|
||||
assert parent["name"] == "Alice"
|
||||
assert "id" in parent
|
||||
|
||||
|
||||
def test_get_parent():
|
||||
"""Get parent by ID returns the entity."""
|
||||
parent = client.create_parent(body={"name": "Bob", "email": "bob@example.com"}).json()
|
||||
response = client.get_parent(path_params={"id": parent["id"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Bob"
|
||||
|
||||
|
||||
def test_update_parent():
|
||||
"""Update parent replaces its values."""
|
||||
parent = client.create_parent(body={"name": "Carol", "email": "carol@example.com"}).json()
|
||||
payload = {"name": "Carol Smith", "email": "carol.smith@example.com"}
|
||||
response = client.update_parent(path_params={"id": parent["id"]}, body=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Carol Smith"
|
||||
|
||||
|
||||
def test_delete_parent():
|
||||
"""Delete parent returns 204 and removes the entity."""
|
||||
parent = client.create_parent(body={"name": "Dave", "email": "dave@example.com"}).json()
|
||||
response = client.delete_parent(path_params={"id": parent["id"]})
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_list_vets():
|
||||
"""List vets returns paginated response."""
|
||||
response = client.list_vets(query={"limit": 10, "offset": 0})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total" in data
|
||||
assert "items" in data
|
||||
|
||||
|
||||
def test_create_vet():
|
||||
"""Creating a vet returns 201."""
|
||||
payload = {"name": "Dr. Smith", "specialty": "Surgery", "email": "smith@clinic.com"}
|
||||
response = client.create_vet(body=payload)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["name"] == "Dr. Smith"
|
||||
|
||||
|
||||
def test_list_treatments():
|
||||
"""List treatments returns an array."""
|
||||
response = client.list_treatments()
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
|
||||
def test_create_treatment():
|
||||
"""Creating a treatment returns 201."""
|
||||
payload = {"label": "Vaccination", "description": "Annual vaccination"}
|
||||
response = client.create_treatment(body=payload)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["label"] == "Vaccination"
|
||||
|
||||
|
||||
def test_create_pet():
|
||||
"""Creating a pet links FK references."""
|
||||
parent = client.create_parent(body={"name": "Owner", "email": "owner@example.com"}).json()
|
||||
payload = {"name": "Fido", "species": "dog", "parent_ids": [parent["id"]]}
|
||||
response = client.create_pet(body=payload)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["name"] == "Fido"
|
||||
|
||||
|
||||
def test_upload_pet_photo():
|
||||
"""Upload pet photo returns 200."""
|
||||
pet = client.create_pet(body={"name": "PhotoPet", "species": "cat", "parent_ids": []}).json()
|
||||
response = client.client.post(
|
||||
f"http://testserver/pets/{pet['id']}",
|
||||
files={"file": ("test.jpg", b"fake-image-data", "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_appointments():
|
||||
"""List appointments returns paginated response with filter params."""
|
||||
response = client.list_appointments(query={"limit": 10, "offset": 0})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total" in data
|
||||
assert "items" in data
|
||||
|
||||
|
||||
def test_full_appointment_lifecycle():
|
||||
"""Create a parent, vet, treatment, pet, then an appointment."""
|
||||
parent = client.create_parent(body={"name": "Eve", "email": "eve@example.com"}).json()
|
||||
vet = client.create_vet(body={"name": "Dr. Jones", "specialty": "Dentistry", "email": "jones@clinic.com"}).json()
|
||||
treatment = client.create_treatment(body={"label": "Cleaning", "description": "Teeth cleaning"}).json()
|
||||
pet = client.create_pet(body={"name": "Max", "species": "dog", "parent_ids": [parent["id"]]}).json()
|
||||
|
||||
payload = {
|
||||
"date": "2025-06-01T10:00:00",
|
||||
"pet_id": pet["id"],
|
||||
"vet_id": vet["id"],
|
||||
"treatment_id": treatment["id"],
|
||||
}
|
||||
response = client.create_appointment(body=payload)
|
||||
assert response.status_code == 201
|
||||
appointment = response.json()
|
||||
assert appointment["pet"]["id"] == pet["id"]
|
||||
|
||||
# Fetch it back
|
||||
get_resp = client.get_appointment(path_params={"id": appointment["id"]})
|
||||
assert get_resp.status_code == 200
|
||||
|
||||
# Delete it
|
||||
del_resp = client.delete_appointment(path_params={"id": appointment["id"]})
|
||||
assert del_resp.status_code == 204
|
||||
@@ -52,6 +52,9 @@ dependencies = [
|
||||
|
||||
# YAML support (optional but recommended)
|
||||
"pyyaml>=6.0.1",
|
||||
|
||||
# Code generation
|
||||
"datamodel-code-generator>=0.25.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Reference in New Issue
Block a user