Compare commits
16 Commits
d912053368
...
0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| c95de5a6e9 | |||
| b3f3068f8d | |||
| 7d075b3904 | |||
| 28c6d9e964 | |||
| 628c3e97f5 | |||
| 4bf358734a | |||
| f5b3c2bb11 | |||
| 3da419fed5 | |||
| a417563ab5 | |||
| 1940e33bc7 | |||
| 5da0a688a8 | |||
| f1a7a556fd | |||
| 4a7a76e330 | |||
| 083fb6923d | |||
| 69b795f9ca | |||
| 61de233745 |
@@ -102,6 +102,7 @@ from . import client
|
|||||||
from . import errors
|
from . import errors
|
||||||
from . import codegen
|
from . import codegen
|
||||||
from . import codegen_routes
|
from . import codegen_routes
|
||||||
|
from . import security
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"app",
|
"app",
|
||||||
@@ -111,4 +112,5 @@ __all__ = [
|
|||||||
"errors",
|
"errors",
|
||||||
"codegen",
|
"codegen",
|
||||||
"codegen_routes",
|
"codegen_routes",
|
||||||
|
"security",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,10 +32,34 @@ Notes:
|
|||||||
- Alter FastAPI dependency injection semantics.
|
- Alter FastAPI dependency injection semantics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .loader import load_openapi
|
|
||||||
from .binder import bind_routes
|
from .binder import bind_routes
|
||||||
|
from .loader import load_openapi
|
||||||
|
from .security import make_security_dependencies, parse_security_schemes
|
||||||
|
|
||||||
|
_env_pattern = re.compile(r"\{(\w+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env(value: str) -> str:
|
||||||
|
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||||
|
def _replace(m: re.Match) -> str:
|
||||||
|
return os.environ.get(m.group(1), m.group(0))
|
||||||
|
return _env_pattern.sub(_replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_spec_env_vars(obj):
|
||||||
|
"""Recursively resolve {ENV_VAR} in all string values of the spec."""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return _resolve_env(obj)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _resolve_spec_env_vars(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_resolve_spec_env_vars(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class OpenAPIFirstApp(FastAPI):
|
class OpenAPIFirstApp(FastAPI):
|
||||||
@@ -48,15 +72,19 @@ class OpenAPIFirstApp(FastAPI):
|
|||||||
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
|
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
|
||||||
registration with OpenAPI-driven binding.
|
registration with OpenAPI-driven binding.
|
||||||
- All routes are derived from the provided OpenAPI specification,
|
- All routes are derived from the provided OpenAPI specification,
|
||||||
and each `operationId` is mapped to a Python function in the
|
and each ``operationId`` is mapped to a Python function in the
|
||||||
supplied routes module.
|
supplied routes module.
|
||||||
|
- Auth dependencies are auto-injected from the spec's
|
||||||
|
``securitySchemes`` and per-operation ``security`` fields.
|
||||||
|
|
||||||
**Guarantees:**
|
**Guarantees:**
|
||||||
|
|
||||||
- No route can exist without an OpenAPI declaration.
|
- No route can exist without an OpenAPI declaration.
|
||||||
- No OpenAPI operation can exist without a handler.
|
- No OpenAPI operation can exist without a handler.
|
||||||
- Swagger UI and `/openapi.json` always reflect the provided spec.
|
- Swagger UI and ``/openapi.json`` always reflect the provided spec.
|
||||||
- Handler functions remain framework-agnostic and testable.
|
- Handler functions remain framework-agnostic and testable.
|
||||||
|
- Auth enforcement is driven entirely by the spec — no manual
|
||||||
|
middleware or decorators required.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -87,28 +115,33 @@ class OpenAPIFirstApp(FastAPI):
|
|||||||
specification is treated as the authoritative API contract.
|
specification is treated as the authoritative API contract.
|
||||||
routes_module (module):
|
routes_module (module):
|
||||||
Python module containing handler functions whose names correspond
|
Python module containing handler functions whose names correspond
|
||||||
exactly to OpenAPI `operationId` values.
|
exactly to OpenAPI ``operationId`` values.
|
||||||
**fastapi_kwargs (Any):
|
**fastapi_kwargs (Any):
|
||||||
Additional keyword arguments passed directly to
|
Additional keyword arguments passed directly to
|
||||||
`fastapi.FastAPI` (e.g., title, version, middleware, lifespan
|
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
|
||||||
handlers).
|
handlers).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
OpenAPIFirstError:
|
OpenAPIFirstError:
|
||||||
If the OpenAPI specification is invalid, or if any declared
|
If the OpenAPI specification is invalid, or if any declared
|
||||||
`operationId` does not have a corresponding handler function.
|
``operationId`` does not have a corresponding handler function.
|
||||||
"""
|
"""
|
||||||
# Initialize FastAPI normally
|
# Initialize FastAPI normally
|
||||||
super().__init__(**fastapi_kwargs)
|
super().__init__(**fastapi_kwargs)
|
||||||
|
|
||||||
# Load and validate OpenAPI specification
|
# Load and validate OpenAPI specification
|
||||||
self._openapi_spec = load_openapi(openapi_path)
|
self._openapi_spec = _resolve_spec_env_vars(load_openapi(openapi_path))
|
||||||
|
|
||||||
# Bind routes strictly from OpenAPI spec
|
# Parse security schemes and build per-route dependencies
|
||||||
|
security_schemes = parse_security_schemes(self._openapi_spec)
|
||||||
|
security_deps = make_security_dependencies(self._openapi_spec, security_schemes)
|
||||||
|
|
||||||
|
# Bind routes strictly from OpenAPI spec (with security deps)
|
||||||
bind_routes(
|
bind_routes(
|
||||||
app=self,
|
app=self,
|
||||||
spec=self._openapi_spec,
|
spec=self._openapi_spec,
|
||||||
routes_module=routes_module,
|
routes_module=routes_module,
|
||||||
|
security_deps=security_deps,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Override FastAPI's OpenAPI generation
|
# Override FastAPI's OpenAPI generation
|
||||||
|
|||||||
@@ -32,12 +32,19 @@ Notes:
|
|||||||
- Interpret OpenAPI semantics beyond routing metadata.
|
- Interpret OpenAPI semantics beyond routing metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
from .errors import MissingOperationHandler
|
from .errors import MissingOperationHandler
|
||||||
|
|
||||||
|
|
||||||
def bind_routes(app, spec: dict, routes_module) -> None:
|
def bind_routes(
|
||||||
|
app,
|
||||||
|
spec: dict,
|
||||||
|
routes_module,
|
||||||
|
security_deps: dict[str, list[Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Bind OpenAPI operations to FastAPI routes.
|
Bind OpenAPI operations to FastAPI routes.
|
||||||
|
|
||||||
@@ -49,18 +56,24 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
|||||||
routes_module (module):
|
routes_module (module):
|
||||||
Python module containing handler functions. Each handler's name MUST
|
Python module containing handler functions. Each handler's name MUST
|
||||||
exactly match an OpenAPI `operationId`.
|
exactly match an OpenAPI `operationId`.
|
||||||
|
security_deps (dict | None):
|
||||||
|
Optional mapping of ``METHOD:/path`` → ``list[Depends(...)]``
|
||||||
|
generated from the spec's ``securitySchemes`` and per-operation
|
||||||
|
``security`` fields.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
MissingOperationHandler:
|
MissingOperationHandler:
|
||||||
If an `operationId` is missing from the spec or if no corresponding
|
If an ``operationId`` is missing from the spec or if no corresponding
|
||||||
handler function exists in the routes module.
|
handler function exists in the routes module.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
|
|
||||||
- Iterates through the OpenAPI specification paths and methods.
|
- Iterates through the OpenAPI specification paths and methods.
|
||||||
- Resolves each `operationId` to a handler function, and registers
|
- Resolves each ``operationId`` to a handler function, and registers
|
||||||
a corresponding `APIRoute` on the FastAPI application.
|
a corresponding ``APIRoute`` on the FastAPI application.
|
||||||
|
- Injects FastAPI ``Depends()`` for each security requirement found
|
||||||
|
on the operation or inherited from the top-level ``security`` field.
|
||||||
|
|
||||||
**Guarantees:**
|
**Guarantees:**
|
||||||
|
|
||||||
@@ -70,6 +83,7 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
paths = spec.get("paths", {})
|
paths = spec.get("paths", {})
|
||||||
|
security_deps = security_deps or {}
|
||||||
|
|
||||||
for path, methods in paths.items():
|
for path, methods in paths.items():
|
||||||
for http_method, operation in methods.items():
|
for http_method, operation in methods.items():
|
||||||
@@ -90,10 +104,14 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
|||||||
operation_id=operation_id,
|
operation_id=operation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
key = f"{http_method.upper()}:{path}"
|
||||||
|
deps = security_deps.get(key, [])
|
||||||
|
|
||||||
route = APIRoute(
|
route = APIRoute(
|
||||||
path=path,
|
path=path,
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
methods=[http_method.upper()],
|
methods=[http_method.upper()],
|
||||||
|
dependencies=deps,
|
||||||
name=operation_id,
|
name=operation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
145
openapi_first/security.py
Normal file
145
openapi_first/security.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
OpenAPI security scheme parsing and auto-generated auth dependencies.
|
||||||
|
|
||||||
|
Reads `securitySchemes` and per-operation `security` from an OpenAPI spec,
|
||||||
|
resolves `{ENV_VAR}` placeholders in `x-` extension fields, and generates
|
||||||
|
FastAPI dependencies for token validation (e.g., Bearer JWT introspection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import Depends, Request, HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
_env_pattern = re.compile(r"\{(\w+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env(value: str) -> str:
|
||||||
|
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||||
|
def _replace(m: re.Match) -> str:
|
||||||
|
return os.environ.get(m.group(1), "")
|
||||||
|
return _env_pattern.sub(_replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scheme(scheme: dict) -> dict:
|
||||||
|
"""Recursively resolve env vars in all string-valued fields of a scheme."""
|
||||||
|
resolved = {}
|
||||||
|
for key, value in scheme.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
resolved[key] = _resolve_env(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
resolved[key] = _resolve_scheme(value)
|
||||||
|
else:
|
||||||
|
resolved[key] = value
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def parse_security_schemes(spec: dict) -> dict[str, dict]:
|
||||||
|
"""Extract and resolve environment variables in security schemes."""
|
||||||
|
raw = spec.get("components", {}).get("securitySchemes", {})
|
||||||
|
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that validates a Bearer JWT.
|
||||||
|
|
||||||
|
If *introspect_url* is provided the dependency calls that endpoint
|
||||||
|
with ``{"token": "<token>"}`` and expects ``{"active": true, "user": …}``
|
||||||
|
back. The resolved user dict is stored on ``request.state.user``.
|
||||||
|
|
||||||
|
Without an introspection URL the dependency only extracts the raw token
|
||||||
|
and stores it on ``request.state.token`` — no remote validation.
|
||||||
|
"""
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
async def _bearer_dep(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
) -> None:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
|
||||||
|
if introspect_url:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
introspect_url,
|
||||||
|
json={"token": token},
|
||||||
|
)
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Authentication service unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
if not body.get("active", False):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
request.state.user = body.get("user")
|
||||||
|
else:
|
||||||
|
request.state.token = token
|
||||||
|
|
||||||
|
return _bearer_dep
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
||||||
|
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
|
||||||
|
scheme_type = scheme.get("type")
|
||||||
|
|
||||||
|
if scheme_type == "http" and scheme.get("scheme") == "bearer":
|
||||||
|
server_url = scheme.get("x-server-url", "")
|
||||||
|
introspect_path = scheme.get("x-introspect-path", "/introspect")
|
||||||
|
introspect_url = server_url + introspect_path if server_url else None
|
||||||
|
return _make_bearer_dependency(introspect_url)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_security_dependencies(
|
||||||
|
spec: dict,
|
||||||
|
security_schemes: dict[str, dict],
|
||||||
|
) -> dict[str, list[Any]]:
|
||||||
|
"""
|
||||||
|
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
|
||||||
|
|
||||||
|
The effective security for each operation is resolved by:
|
||||||
|
|
||||||
|
1. Using the operation-level ``security`` field if present.
|
||||||
|
2. Falling back to the top-level ``security`` field.
|
||||||
|
3. An empty list means *no auth required* for that operation.
|
||||||
|
"""
|
||||||
|
global_security = spec.get("security", [])
|
||||||
|
paths = spec.get("paths", {})
|
||||||
|
|
||||||
|
result: dict[str, list[Any]] = {}
|
||||||
|
|
||||||
|
for path, methods in paths.items():
|
||||||
|
for http_method, operation in methods.items():
|
||||||
|
if http_method.startswith("x-"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
op_security = operation.get("security", global_security)
|
||||||
|
|
||||||
|
depends_list: list[Any] = []
|
||||||
|
for sec_req in op_security:
|
||||||
|
for scheme_name in sec_req:
|
||||||
|
scheme = security_schemes.get(scheme_name)
|
||||||
|
if scheme:
|
||||||
|
dep = _build_dependency(scheme_name, scheme)
|
||||||
|
if dep:
|
||||||
|
depends_list.append(Depends(dep))
|
||||||
|
|
||||||
|
key = f"{http_method.upper()}:{path}"
|
||||||
|
result[key] = depends_list
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -24,9 +24,8 @@ library API surface.
|
|||||||
OpenAPI x- extension fields demonstrated
|
OpenAPI x- extension fields demonstrated
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
Schema-level extensions (mark a schema as a UI resource):
|
Schema-level extensions (display metadata for resource endpoints):
|
||||||
|
|
||||||
``x-resource`` (REQUIRED) Maps schema to URL path segment
|
|
||||||
``x-primary-key`` (REQUIRED) Primary key property name
|
``x-primary-key`` (REQUIRED) Primary key property name
|
||||||
``x-display-format`` (REQUIRED) Human-readable label template
|
``x-display-format`` (REQUIRED) Human-readable label template
|
||||||
``x-list-columns`` (REQUIRED) Columns for the datatable
|
``x-list-columns`` (REQUIRED) Columns for the datatable
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ from models import (
|
|||||||
Parent, ParentCreate,
|
Parent, ParentCreate,
|
||||||
Vet, VetCreate,
|
Vet, VetCreate,
|
||||||
Treatment, TreatmentCreate,
|
Treatment, TreatmentCreate,
|
||||||
|
Procedure,
|
||||||
|
BasicNote, HeartRateNote, DentalNote,
|
||||||
|
VaccineNote, PreOpNote, SurgeryNote,
|
||||||
Pet, PetCreate,
|
Pet, PetCreate,
|
||||||
Appointment, AppointmentCreate,
|
Appointment, AppointmentCreate,
|
||||||
)
|
)
|
||||||
@@ -268,6 +271,7 @@ def create_appointment(payload: AppointmentCreate) -> Appointment:
|
|||||||
id=_appointments_next_id,
|
id=_appointments_next_id,
|
||||||
date=payload.date,
|
date=payload.date,
|
||||||
notes=payload.notes,
|
notes=payload.notes,
|
||||||
|
procedures=payload.procedures,
|
||||||
pet=_pets[payload.pet_id],
|
pet=_pets[payload.pet_id],
|
||||||
vet=_vets[payload.vet_id],
|
vet=_vets[payload.vet_id],
|
||||||
treatment=_treatments[payload.treatment_id],
|
treatment=_treatments[payload.treatment_id],
|
||||||
@@ -287,6 +291,7 @@ def update_appointment(appointment_id: int, payload: AppointmentCreate) -> Appoi
|
|||||||
id=appointment_id,
|
id=appointment_id,
|
||||||
date=payload.date,
|
date=payload.date,
|
||||||
notes=payload.notes if payload.notes is not None else current.notes,
|
notes=payload.notes if payload.notes is not None else current.notes,
|
||||||
|
procedures=payload.procedures,
|
||||||
pet=_pets.get(payload.pet_id, current.pet),
|
pet=_pets.get(payload.pet_id, current.pet),
|
||||||
vet=_vets.get(payload.vet_id, current.vet),
|
vet=_vets.get(payload.vet_id, current.vet),
|
||||||
treatment=_treatments.get(payload.treatment_id, current.treatment),
|
treatment=_treatments.get(payload.treatment_id, current.treatment),
|
||||||
@@ -310,15 +315,15 @@ def _seed_data():
|
|||||||
global _parents_next_id, _vets_next_id, _treatments_next_id
|
global _parents_next_id, _vets_next_id, _treatments_next_id
|
||||||
global _pets_next_id, _appointments_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[1] = Parent(id=1, name="Alice Johnson", email="alice@example.com", phone="5550101", metadata=meta)
|
||||||
_parents[2] = Parent(id=2, name="Bob Smith", email="bob@example.com", phone="555-0102", metadata=meta)
|
_parents[2] = Parent(id=2, name="Bob Smith", email="bob@example.com", phone="5550102", metadata=meta)
|
||||||
_parents[3] = Parent(id=3, name="Carol Williams", email="carol@example.com", phone="555-0103", metadata=meta)
|
_parents[3] = Parent(id=3, name="Carol Williams", email="carol@example.com", phone="5550103", metadata=meta)
|
||||||
_parents[4] = Parent(id=4, name="Dave Brown", email="dave@example.com", phone="555-0104", metadata=meta)
|
_parents[4] = Parent(id=4, name="Dave Brown", email="dave@example.com", phone="5550104", metadata=meta)
|
||||||
_parents_next_id = 5
|
_parents_next_id = 5
|
||||||
|
|
||||||
_vets[1] = Vet(id=1, name="Sarah Connor", specialty="Surgery", email="sarah@clinic.com", phone="555-0201", metadata=meta)
|
_vets[1] = Vet(id=1, name="Sarah Connor", specialty="Surgery", email="sarah@clinic.com", phone="5550201", metadata=meta)
|
||||||
_vets[2] = Vet(id=2, name="James Wilson", specialty="Dentistry", email="james@clinic.com", phone="555-0202", metadata=meta)
|
_vets[2] = Vet(id=2, name="James Wilson", specialty="Dentistry", email="james@clinic.com", phone="5550202", metadata=meta)
|
||||||
_vets[3] = Vet(id=3, name="Emily Davis", specialty="General Practice", email="emily@clinic.com", phone="555-0203", metadata=meta)
|
_vets[3] = Vet(id=3, name="Emily Davis", specialty="General Practice", email="emily@clinic.com", phone="5550203", metadata=meta)
|
||||||
_vets_next_id = 4
|
_vets_next_id = 4
|
||||||
|
|
||||||
_treatments[1] = Treatment(id=1, label="Annual Checkup", description="Full physical examination", metadata=meta)
|
_treatments[1] = Treatment(id=1, label="Annual Checkup", description="Full physical examination", metadata=meta)
|
||||||
@@ -335,10 +340,31 @@ def _seed_data():
|
|||||||
_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[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
|
_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[1] = Appointment(id=1, date=datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc), notes="Annual checkup",
|
||||||
_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)
|
procedures=[
|
||||||
_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)
|
Procedure(name="Physical Exam", cost=50.0, notes=BasicNote(summary="Normal findings", details="Heart rate and temperature within normal range")),
|
||||||
_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)
|
Procedure(name="Heart Rate", notes=HeartRateNote(summary="Normal rhythm", bpm=65)),
|
||||||
|
],
|
||||||
|
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, notes=DentalNote(summary="Moderate tartar removed", procedureType="scaling", teeth="all")),
|
||||||
|
Procedure(name="Polishing", cost=40.0, notes=DentalNote(summary="High-speed polish applied", procedureType="polishing", teeth="all")),
|
||||||
|
],
|
||||||
|
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="Vaccine", cost=35.0, notes=VaccineNote(summary="Administered", medicine="DHPP", leg="hind_left")),
|
||||||
|
Procedure(name="Vaccine", cost=45.0, notes=VaccineNote(summary="Administered", medicine="Rabies", leg="hind_right")),
|
||||||
|
],
|
||||||
|
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="PreOp", cost=30.0, notes=PreOpNote(summary="Pre-op clearance", heartRate=80, temperature=38.5)),
|
||||||
|
Procedure(name="Neuter", cost=200.0, notes=SurgeryNote(summary="Surgery completed", surgeryType="neuter", complications="None")),
|
||||||
|
Procedure(name="PostOp", cost=50.0, notes=BasicNote(summary="Recovering well", details="Eating and drinking normally")),
|
||||||
|
],
|
||||||
|
pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta)
|
||||||
_appointments_next_id = 5
|
_appointments_next_id = 5
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,25 @@ Example:
|
|||||||
uvicorn main:app
|
uvicorn main:app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
import routes
|
import routes
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = OpenAPIFirstApp(
|
app = OpenAPIFirstApp(
|
||||||
openapi_path="openapi.yaml",
|
openapi_path="openapi.yaml",
|
||||||
routes_module=routes,
|
routes_module=routes,
|
||||||
title="Veterinary Clinic Service",
|
title="Veterinary Clinic Service",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pydantic import BaseModel
|
from typing import Annotated, Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Metadata(BaseModel):
|
class Metadata(BaseModel):
|
||||||
@@ -38,6 +39,57 @@ class Vet(VetBase):
|
|||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureNoteBase(BaseModel):
|
||||||
|
summary: str | None = None
|
||||||
|
details: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BasicNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["basic"] = "basic"
|
||||||
|
|
||||||
|
|
||||||
|
class HeartRateNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["heart_rate"] = "heart_rate"
|
||||||
|
bpm: int
|
||||||
|
|
||||||
|
|
||||||
|
class DentalNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["dental"] = "dental"
|
||||||
|
procedureType: Literal["scaling", "polishing"]
|
||||||
|
teeth: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VaccineNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["vaccine"] = "vaccine"
|
||||||
|
medicine: Literal["Rabies", "DHPP", "Tricat", "Deworming"]
|
||||||
|
leg: Literal["front_left", "front_right", "hind_left", "hind_right"]
|
||||||
|
|
||||||
|
|
||||||
|
class PreOpNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["preop"] = "preop"
|
||||||
|
heartRate: int
|
||||||
|
temperature: float
|
||||||
|
|
||||||
|
|
||||||
|
class SurgeryNote(ProcedureNoteBase):
|
||||||
|
noteType: Literal["surgery"] = "surgery"
|
||||||
|
surgeryType: Literal["neuter", "spay"]
|
||||||
|
complications: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ProcedureNotes = Annotated[
|
||||||
|
BasicNote | HeartRateNote | DentalNote | VaccineNote | PreOpNote | SurgeryNote,
|
||||||
|
Field(discriminator="noteType"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Procedure(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
cost: float | None = None
|
||||||
|
notes: ProcedureNotes | None = None
|
||||||
|
|
||||||
|
|
||||||
class TreatmentBase(BaseModel):
|
class TreatmentBase(BaseModel):
|
||||||
label: str
|
label: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -74,6 +126,7 @@ class Pet(PetBase):
|
|||||||
class AppointmentBase(BaseModel):
|
class AppointmentBase(BaseModel):
|
||||||
date: datetime
|
date: datetime
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
procedures: list[Procedure] = []
|
||||||
metadata: Metadata | None = None
|
metadata: Metadata | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,189 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|
||||||
|
Procedure:
|
||||||
|
type: object
|
||||||
|
x-display-format: "{name} — ${cost}"
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
x-order: 1
|
||||||
|
x-label: "Procedure Name"
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
x-order: 2
|
||||||
|
x-label: "Description"
|
||||||
|
cost:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Cost"
|
||||||
|
notes:
|
||||||
|
$ref: '#/components/schemas/ProcedureNotes'
|
||||||
|
x-order: 4
|
||||||
|
x-label: "Notes"
|
||||||
|
|
||||||
|
ProcedureNoteBase:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
x-order: 1
|
||||||
|
x-label: "Summary"
|
||||||
|
details:
|
||||||
|
type: string
|
||||||
|
x-order: 2
|
||||||
|
x-label: "Details"
|
||||||
|
|
||||||
|
ProcedureNotes:
|
||||||
|
x-display-format: "{summary}"
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/BasicNote'
|
||||||
|
- $ref: '#/components/schemas/HeartRateNote'
|
||||||
|
- $ref: '#/components/schemas/DentalNote'
|
||||||
|
- $ref: '#/components/schemas/VaccineNote'
|
||||||
|
- $ref: '#/components/schemas/PreOpNote'
|
||||||
|
- $ref: '#/components/schemas/SurgeryNote'
|
||||||
|
discriminator:
|
||||||
|
propertyName: noteType
|
||||||
|
mapping:
|
||||||
|
basic: BasicNote
|
||||||
|
heart_rate: HeartRateNote
|
||||||
|
dental: DentalNote
|
||||||
|
vaccine: VaccineNote
|
||||||
|
preop: PreOpNote
|
||||||
|
surgery: SurgeryNote
|
||||||
|
|
||||||
|
BasicNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [basic]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
required: [noteType]
|
||||||
|
|
||||||
|
HeartRateNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [heart_rate]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
bpm:
|
||||||
|
type: integer
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Heart Rate (bpm)"
|
||||||
|
required: [noteType, bpm]
|
||||||
|
|
||||||
|
DentalNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [dental]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
procedureType:
|
||||||
|
type: string
|
||||||
|
enum: [scaling, polishing]
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Procedure"
|
||||||
|
teeth:
|
||||||
|
type: string
|
||||||
|
enum: [all, upper_only, lower_only]
|
||||||
|
x-order: 4
|
||||||
|
x-label: "Teeth"
|
||||||
|
required: [noteType, procedureType]
|
||||||
|
|
||||||
|
VaccineNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [vaccine]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
medicine:
|
||||||
|
type: string
|
||||||
|
enum: [Rabies, DHPP, Tricat, Deworming]
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Medicine"
|
||||||
|
leg:
|
||||||
|
type: string
|
||||||
|
enum: [front_left, front_right, hind_left, hind_right]
|
||||||
|
x-order: 4
|
||||||
|
x-label: "Injection Leg"
|
||||||
|
required: [noteType, medicine, leg]
|
||||||
|
|
||||||
|
PreOpNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [preop]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
heartRate:
|
||||||
|
type: integer
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Heart Rate (bpm)"
|
||||||
|
temperature:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
x-order: 4
|
||||||
|
x-label: "Temperature (°C)"
|
||||||
|
required: [noteType, heartRate, temperature]
|
||||||
|
|
||||||
|
SurgeryNote:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProcedureNoteBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
noteType:
|
||||||
|
type: string
|
||||||
|
enum: [surgery]
|
||||||
|
x-order: 0
|
||||||
|
x-label: "Type"
|
||||||
|
surgeryType:
|
||||||
|
type: string
|
||||||
|
enum: [neuter, spay]
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Surgery Type"
|
||||||
|
complications:
|
||||||
|
type: string
|
||||||
|
x-order: 4
|
||||||
|
x-label: "Complications"
|
||||||
|
required: [noteType, surgeryType]
|
||||||
|
|
||||||
|
Action:
|
||||||
|
type: object
|
||||||
|
x-primary-key: _received_at
|
||||||
|
x-display-format: "{action}"
|
||||||
|
x-list-columns: [action]
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [wagging_tail, blep, stretching, showing_belly, barking, panting, purring, kneading, head_tilt, chirping, wing_flap]
|
||||||
|
x-label: "Action"
|
||||||
|
x-order: 1
|
||||||
|
x-filterable: false
|
||||||
|
required: [action]
|
||||||
|
|
||||||
Parent:
|
Parent:
|
||||||
type: object
|
type: object
|
||||||
x-resource: parents
|
|
||||||
x-primary-key: id
|
x-primary-key: id
|
||||||
x-display-format: "{name}"
|
x-display-format: "{name}"
|
||||||
x-list-columns: [name, email, phone]
|
x-list-columns: [name, email, phone]
|
||||||
@@ -43,6 +223,7 @@ components:
|
|||||||
x-description: "Parent's full name"
|
x-description: "Parent's full name"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
x-sortable: true
|
x-sortable: true
|
||||||
|
x-autocomplete: token
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
@@ -50,12 +231,14 @@ components:
|
|||||||
x-label: "Email"
|
x-label: "Email"
|
||||||
x-description: "Email address"
|
x-description: "Email address"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
|
x-autocomplete: email
|
||||||
phone:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
x-order: 3
|
x-order: 3
|
||||||
x-label: "Phone"
|
x-label: "Phone"
|
||||||
x-description: "Contact phone number"
|
x-description: "Contact phone number"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
|
x-autocomplete: phone
|
||||||
metadata:
|
metadata:
|
||||||
$ref: '#/components/schemas/Metadata'
|
$ref: '#/components/schemas/Metadata'
|
||||||
x-order: 4
|
x-order: 4
|
||||||
@@ -64,7 +247,6 @@ components:
|
|||||||
|
|
||||||
Vet:
|
Vet:
|
||||||
type: object
|
type: object
|
||||||
x-resource: vets
|
|
||||||
x-primary-key: id
|
x-primary-key: id
|
||||||
x-display-format: "Dr. {name}"
|
x-display-format: "Dr. {name}"
|
||||||
x-list-columns: [name, specialty, email, phone]
|
x-list-columns: [name, specialty, email, phone]
|
||||||
@@ -82,12 +264,14 @@ components:
|
|||||||
x-description: "Veterinarian's full name"
|
x-description: "Veterinarian's full name"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
x-sortable: true
|
x-sortable: true
|
||||||
|
x-autocomplete: token
|
||||||
specialty:
|
specialty:
|
||||||
type: string
|
type: string
|
||||||
x-order: 2
|
x-order: 2
|
||||||
x-label: "Specialty"
|
x-label: "Specialty"
|
||||||
x-description: "Area of specialization"
|
x-description: "Area of specialization"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
|
x-autocomplete: token
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
@@ -95,6 +279,7 @@ components:
|
|||||||
x-label: "Email"
|
x-label: "Email"
|
||||||
x-description: "Email address"
|
x-description: "Email address"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
|
x-autocomplete: email
|
||||||
phone:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
x-order: 4
|
x-order: 4
|
||||||
@@ -102,13 +287,12 @@ components:
|
|||||||
x-description: "Contact phone number"
|
x-description: "Contact phone number"
|
||||||
metadata:
|
metadata:
|
||||||
$ref: '#/components/schemas/Metadata'
|
$ref: '#/components/schemas/Metadata'
|
||||||
x-order: 4
|
x-order: 5
|
||||||
x-label: "Metadata"
|
x-label: "Metadata"
|
||||||
required: [id, name]
|
required: [id, name]
|
||||||
|
|
||||||
Treatment:
|
Treatment:
|
||||||
type: object
|
type: object
|
||||||
x-resource: treatments
|
|
||||||
x-primary-key: id
|
x-primary-key: id
|
||||||
x-display-format: "{label}"
|
x-display-format: "{label}"
|
||||||
x-list-columns: [label, description]
|
x-list-columns: [label, description]
|
||||||
@@ -126,11 +310,14 @@ components:
|
|||||||
x-description: "Name of the treatment"
|
x-description: "Name of the treatment"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
x-sortable: true
|
x-sortable: true
|
||||||
|
x-autocomplete: token
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
x-order: 2
|
x-order: 2
|
||||||
x-label: "Description"
|
x-label: "Description"
|
||||||
x-description: "Detailed description of the treatment"
|
x-description: "Detailed description of the treatment"
|
||||||
|
x-filterable: true
|
||||||
|
x-autocomplete: text
|
||||||
metadata:
|
metadata:
|
||||||
$ref: '#/components/schemas/Metadata'
|
$ref: '#/components/schemas/Metadata'
|
||||||
x-order: 4
|
x-order: 4
|
||||||
@@ -139,7 +326,6 @@ components:
|
|||||||
|
|
||||||
Pet:
|
Pet:
|
||||||
type: object
|
type: object
|
||||||
x-resource: pets
|
|
||||||
x-primary-key: id
|
x-primary-key: id
|
||||||
x-display-format: "{name} – #{id}"
|
x-display-format: "{name} – #{id}"
|
||||||
x-list-columns: [name, species, age, weight, birthDate, parents]
|
x-list-columns: [name, species, age, weight, birthDate, parents]
|
||||||
@@ -157,6 +343,7 @@ components:
|
|||||||
x-description: "Name of the pet"
|
x-description: "Name of the pet"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
x-sortable: true
|
x-sortable: true
|
||||||
|
x-autocomplete: token
|
||||||
species:
|
species:
|
||||||
type: string
|
type: string
|
||||||
enum: [dog, cat, bird]
|
enum: [dog, cat, bird]
|
||||||
@@ -204,13 +391,12 @@ components:
|
|||||||
x-filterable: true
|
x-filterable: true
|
||||||
metadata:
|
metadata:
|
||||||
$ref: '#/components/schemas/Metadata'
|
$ref: '#/components/schemas/Metadata'
|
||||||
x-order: 4
|
x-order: 8
|
||||||
x-label: "Metadata"
|
x-label: "Metadata"
|
||||||
required: [id, name, parents]
|
required: [id, name, parents]
|
||||||
|
|
||||||
Appointment:
|
Appointment:
|
||||||
type: object
|
type: object
|
||||||
x-resource: appointments
|
|
||||||
x-primary-key: id
|
x-primary-key: id
|
||||||
x-display-format: "Appt #{id} – {date}"
|
x-display-format: "Appt #{id} – {date}"
|
||||||
x-list-columns: [date, pet, vet, treatment, notes]
|
x-list-columns: [date, pet, vet, treatment, notes]
|
||||||
@@ -234,11 +420,18 @@ components:
|
|||||||
x-order: 2
|
x-order: 2
|
||||||
x-label: "Notes"
|
x-label: "Notes"
|
||||||
x-description: "Any additional notes"
|
x-description: "Any additional notes"
|
||||||
|
procedures:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Procedure'
|
||||||
|
x-order: 3
|
||||||
|
x-label: "Procedures"
|
||||||
|
x-description: "List of procedures performed during this appointment"
|
||||||
pet:
|
pet:
|
||||||
$ref: '#/components/schemas/Pet'
|
$ref: '#/components/schemas/Pet'
|
||||||
x-fk:
|
x-fk:
|
||||||
resource: pets
|
resource: pets
|
||||||
x-order: 3
|
x-order: 4
|
||||||
x-label: "Pet"
|
x-label: "Pet"
|
||||||
x-description: "Select a pet"
|
x-description: "Select a pet"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
@@ -247,7 +440,7 @@ components:
|
|||||||
x-fk:
|
x-fk:
|
||||||
resource: vets
|
resource: vets
|
||||||
prefetch: true
|
prefetch: true
|
||||||
x-order: 4
|
x-order: 5
|
||||||
x-label: "Veterinarian"
|
x-label: "Veterinarian"
|
||||||
x-description: "Select a veterinarian"
|
x-description: "Select a veterinarian"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
@@ -256,13 +449,13 @@ components:
|
|||||||
x-fk:
|
x-fk:
|
||||||
resource: treatments
|
resource: treatments
|
||||||
prefetch: true
|
prefetch: true
|
||||||
x-order: 5
|
x-order: 6
|
||||||
x-label: "Treatment"
|
x-label: "Treatment"
|
||||||
x-description: "Select a treatment"
|
x-description: "Select a treatment"
|
||||||
x-filterable: true
|
x-filterable: true
|
||||||
metadata:
|
metadata:
|
||||||
$ref: '#/components/schemas/Metadata'
|
$ref: '#/components/schemas/Metadata'
|
||||||
x-order: 4
|
x-order: 7
|
||||||
x-label: "Metadata"
|
x-label: "Metadata"
|
||||||
required: [id, date, pet, vet, treatment]
|
required: [id, date, pet, vet, treatment]
|
||||||
|
|
||||||
@@ -875,6 +1068,23 @@ paths:
|
|||||||
$ref: '#/components/responses/ValidationError'
|
$ref: '#/components/responses/ValidationError'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
/pets/{id}/actions:
|
||||||
|
get:
|
||||||
|
summary: Stream animal actions via SSE, scoped to a pet's species
|
||||||
|
operationId: stream_actions
|
||||||
|
x-sse: true
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: {type: integer}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: SSE stream of random animal actions (behaviors)
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Action'
|
||||||
|
|
||||||
/appointments:
|
/appointments:
|
||||||
get:
|
get:
|
||||||
@@ -942,6 +1152,7 @@ paths:
|
|||||||
$ref: '#/components/responses/ValidationError'
|
$ref: '#/components/responses/ValidationError'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
/appointments/{id}:
|
/appointments/{id}:
|
||||||
get:
|
get:
|
||||||
operationId: get_appointment
|
operationId: get_appointment
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ in the OpenAPI specification.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Response, HTTPException, UploadFile
|
from fastapi import Response, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from sse import subscribe, unsubscribe
|
||||||
|
|
||||||
from models import (
|
from models import (
|
||||||
ParentCreate,
|
ParentCreate,
|
||||||
@@ -40,6 +43,7 @@ from data import (
|
|||||||
create_pet as _create_pet,
|
create_pet as _create_pet,
|
||||||
update_pet as _update_pet,
|
update_pet as _update_pet,
|
||||||
delete_pet as _delete_pet,
|
delete_pet as _delete_pet,
|
||||||
|
get_pet as _get_pet,
|
||||||
list_appointments as _list_appointments,
|
list_appointments as _list_appointments,
|
||||||
get_appointment as _get_appointment,
|
get_appointment as _get_appointment,
|
||||||
create_appointment as _create_appointment,
|
create_appointment as _create_appointment,
|
||||||
@@ -363,3 +367,23 @@ def delete_appointment(id: int, response: Response):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||||
response.status_code = 204
|
response.status_code = 204
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_actions(id: int):
|
||||||
|
"""Stream animal actions via SSE, scoped to a pet's species."""
|
||||||
|
try:
|
||||||
|
pet = _get_pet(id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Pet not found")
|
||||||
|
species = pet.species
|
||||||
|
q = await subscribe(id, species)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await q.get()
|
||||||
|
yield f"data: {data}\n\n"
|
||||||
|
finally:
|
||||||
|
unsubscribe(id, q)
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|||||||
48
openapi_first/templates/vet_app/sse.py
Normal file
48
openapi_first/templates/vet_app/sse.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import json
|
||||||
|
|
||||||
|
_behaviors_by_species = {
|
||||||
|
"dog": ["wagging_tail", "stretching", "showing_belly", "barking", "panting"],
|
||||||
|
"cat": ["blep", "stretching", "showing_belly", "purring", "kneading"],
|
||||||
|
"bird": ["head_tilt", "stretching", "chirping", "wing_flap"],
|
||||||
|
}
|
||||||
|
_subscribers: dict[int, list[asyncio.Queue]] = {}
|
||||||
|
_worker_tasks: dict[int, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _behavior_worker(pet_id: int, species: str):
|
||||||
|
behaviors = _behaviors_by_species.get(species, ["wagging_tail"])
|
||||||
|
while True:
|
||||||
|
action = random.choice(behaviors)
|
||||||
|
data = json.dumps({"action": action})
|
||||||
|
queues = _subscribers.get(pet_id, [])
|
||||||
|
for q in queues:
|
||||||
|
await q.put(data)
|
||||||
|
await asyncio.sleep(random.uniform(1, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_worker(pet_id: int, species: str):
|
||||||
|
if pet_id not in _worker_tasks or _worker_tasks[pet_id].done():
|
||||||
|
_subscribers.setdefault(pet_id, [])
|
||||||
|
_worker_tasks[pet_id] = asyncio.create_task(
|
||||||
|
_behavior_worker(pet_id, species)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe(pet_id: int, species: str) -> asyncio.Queue:
|
||||||
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
|
_subscribers.setdefault(pet_id, []).append(q)
|
||||||
|
_ensure_worker(pet_id, species)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def unsubscribe(pet_id: int, q: asyncio.Queue):
|
||||||
|
queues = _subscribers.get(pet_id, [])
|
||||||
|
if q in queues:
|
||||||
|
queues.remove(q)
|
||||||
|
if not queues:
|
||||||
|
task = _worker_tasks.pop(pet_id, None)
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
_subscribers.pop(pet_id, None)
|
||||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "openapi-first"
|
name = "openapi-first"
|
||||||
version = "0.0.4"
|
version = "0.0.6"
|
||||||
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -55,6 +55,9 @@ dependencies = [
|
|||||||
|
|
||||||
# Code generation
|
# Code generation
|
||||||
"datamodel-code-generator>=0.25.0",
|
"datamodel-code-generator>=0.25.0",
|
||||||
|
|
||||||
|
# HTTP client for service-to-service (token introspection)
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Reference in New Issue
Block a user