Compare commits
9 Commits
1c48f58578
...
d912053368
| Author | SHA1 | Date | |
|---|---|---|---|
| d912053368 | |||
| 0f591b666b | |||
| 808ffa8fed | |||
| 1e1d7fcde9 | |||
| d4b64d630a | |||
| 85aac955ac | |||
| 8299445b68 | |||
| eb845c5bf4 | |||
| 514f6e5f7c |
@@ -100,6 +100,8 @@ from . import binder
|
|||||||
from . import loader
|
from . import loader
|
||||||
from . import client
|
from . import client
|
||||||
from . import errors
|
from . import errors
|
||||||
|
from . import codegen
|
||||||
|
from . import codegen_routes
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"app",
|
"app",
|
||||||
@@ -107,4 +109,6 @@ __all__ = [
|
|||||||
"loader",
|
"loader",
|
||||||
"client",
|
"client",
|
||||||
"errors",
|
"errors",
|
||||||
|
"codegen",
|
||||||
|
"codegen_routes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ from . import binder as binder
|
|||||||
from . import loader as loader
|
from . import loader as loader
|
||||||
from . import client as client
|
from . import client as client
|
||||||
from . import errors as errors
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
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",
|
"template",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default=DEFAULT_TEMPLATE,
|
default=DEFAULT_TEMPLATE,
|
||||||
help=f"Template name (default: {DEFAULT_TEMPLATE})",
|
help=f"Template name (default: {DEFAULT_TEMPLATE})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
scaffold_parser.add_argument(
|
||||||
"path",
|
"path",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default=None,
|
default=None,
|
||||||
help="Target directory (defaults to template name)",
|
help="Target directory (defaults to template name)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
scaffold_parser.add_argument(
|
||||||
"--list",
|
"--list",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="List available templates and exit",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.list:
|
if args.command == "models":
|
||||||
for name in available_templates():
|
from .codegen import generate_models
|
||||||
print(name)
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
target = Path(args.path or args.template.replace("_", "-"))
|
if args.command == "routes":
|
||||||
|
from .codegen import generate_routes
|
||||||
|
|
||||||
try:
|
try:
|
||||||
copy_template(args.template, target)
|
files = generate_routes(
|
||||||
except Exception as exc:
|
spec_path=Path(args.spec),
|
||||||
raise SystemExit(str(exc)) from exc
|
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(
|
response = client.create_item(
|
||||||
body=payload
|
body=payload,
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ def test_update_item():
|
|||||||
def test_delete_item():
|
def test_delete_item():
|
||||||
"""Deleting an item should remove it from the store."""
|
"""Deleting an item should remove it from the store."""
|
||||||
response = client.delete_item(
|
response = client.delete_item(
|
||||||
path_params={"item_id": 2}
|
path_params={"item_id": 2},
|
||||||
)
|
)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def test_create_item():
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = client.create_item(
|
response = client.create_item(
|
||||||
body=payload
|
body=payload,
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ def test_update_item():
|
|||||||
def test_delete_item():
|
def test_delete_item():
|
||||||
"""Deleting an item should remove it from the store."""
|
"""Deleting an item should remove it from the store."""
|
||||||
response = client.delete_item(
|
response = client.delete_item(
|
||||||
path_params={"item_id": 2}
|
path_params={"item_id": 2},
|
||||||
)
|
)
|
||||||
assert response.status_code == 204
|
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.
|
||||||
|
"""
|
||||||
345
openapi_first/templates/vet_app/data.py
Normal file
345
openapi_first/templates/vet_app/data.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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", 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", 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", 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", pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta)
|
||||||
|
_appointments_next_id = 5
|
||||||
|
|
||||||
|
|
||||||
|
_seed_data()
|
||||||
44
openapi_first/templates/vet_app/main.py
Normal file
44
openapi_first/templates/vet_app/main.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
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 starlette.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
import routes
|
||||||
|
|
||||||
|
app = OpenAPIFirstApp(
|
||||||
|
openapi_path="openapi.yaml",
|
||||||
|
routes_module=routes,
|
||||||
|
title="Veterinary Clinic Service",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
90
openapi_first/templates/vet_app/models.py
Normal file
90
openapi_first/templates/vet_app/models.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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 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
|
||||||
|
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
|
||||||
1019
openapi_first/templates/vet_app/openapi.yaml
Normal file
1019
openapi_first/templates/vet_app/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
365
openapi_first/templates/vet_app/routes.py
Normal file
365
openapi_first/templates/vet_app/routes.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
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 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
|
||||||
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)
|
# YAML support (optional but recommended)
|
||||||
"pyyaml>=6.0.1",
|
"pyyaml>=6.0.1",
|
||||||
|
|
||||||
|
# Code generation
|
||||||
|
"datamodel-code-generator>=0.25.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Reference in New Issue
Block a user