Compare commits
12 Commits
4a7a76e330
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c95de5a6e9 | |||
| b3f3068f8d | |||
| 7d075b3904 | |||
| 28c6d9e964 | |||
| 628c3e97f5 | |||
| 4bf358734a | |||
| f5b3c2bb11 | |||
| 3da419fed5 | |||
| a417563ab5 | |||
| 1940e33bc7 | |||
| 5da0a688a8 | |||
| f1a7a556fd |
@@ -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,7 +22,9 @@ from models import (
|
|||||||
Parent, ParentCreate,
|
Parent, ParentCreate,
|
||||||
Vet, VetCreate,
|
Vet, VetCreate,
|
||||||
Treatment, TreatmentCreate,
|
Treatment, TreatmentCreate,
|
||||||
Procedure, ProcedureNotes,
|
Procedure,
|
||||||
|
BasicNote, HeartRateNote, DentalNote,
|
||||||
|
VaccineNote, PreOpNote, SurgeryNote,
|
||||||
Pet, PetCreate,
|
Pet, PetCreate,
|
||||||
Appointment, AppointmentCreate,
|
Appointment, AppointmentCreate,
|
||||||
)
|
)
|
||||||
@@ -313,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)
|
||||||
@@ -339,16 +341,29 @@ def _seed_data():
|
|||||||
_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",
|
_appointments[1] = Appointment(id=1, date=datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc), notes="Annual checkup",
|
||||||
procedures=[Procedure(name="Physical Exam", cost=50.0), Procedure(name="Heart Rate", notes=ProcedureNotes(summary="Normal rhythm"))],
|
procedures=[
|
||||||
|
Procedure(name="Physical Exam", cost=50.0, notes=BasicNote(summary="Normal findings", details="Heart rate and temperature within normal range")),
|
||||||
|
Procedure(name="Heart Rate", notes=HeartRateNote(summary="Normal rhythm", bpm=65)),
|
||||||
|
],
|
||||||
pet=_pets[1], vet=_vets[1], treatment=_treatments[1], metadata=meta)
|
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",
|
_appointments[2] = Appointment(id=2, date=datetime(2026, 6, 18, 10, 30, tzinfo=timezone.utc), notes="Dental cleaning",
|
||||||
procedures=[Procedure(name="Scaling", cost=80.0), Procedure(name="Polishing", cost=40.0, notes=ProcedureNotes(summary="High-speed polish"))],
|
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)
|
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",
|
_appointments[3] = Appointment(id=3, date=datetime(2026, 6, 19, 11, 0, tzinfo=timezone.utc), notes="Vaccination booster",
|
||||||
procedures=[Procedure(name="DHPP Vaccine", cost=35.0), Procedure(name="Rabies Vaccine", cost=45.0)],
|
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)
|
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",
|
_appointments[4] = Appointment(id=4, date=datetime(2026, 6, 20, 14, 0, tzinfo=timezone.utc), notes="Follow-up after surgery",
|
||||||
procedures=[Procedure(name="Pre-op Exam", cost=30.0), Procedure(name="Surgery", cost=200.0), Procedure(name="Post-op Care", cost=50.0)],
|
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)
|
pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta)
|
||||||
_appointments_next_id = 5
|
_appointments_next_id = 5
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,12 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
import routes
|
import routes
|
||||||
from sse import start_worker, stop_worker
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app):
|
async def lifespan(app):
|
||||||
start_worker()
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
stop_worker()
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = OpenAPIFirstApp(
|
app = OpenAPIFirstApp(
|
||||||
|
|||||||
@@ -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,11 +39,50 @@ class Vet(VetBase):
|
|||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
class ProcedureNotes(BaseModel):
|
class ProcedureNoteBase(BaseModel):
|
||||||
summary: str | None = None
|
summary: str | None = None
|
||||||
details: 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):
|
class Procedure(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ components:
|
|||||||
x-order: 4
|
x-order: 4
|
||||||
x-label: "Notes"
|
x-label: "Notes"
|
||||||
|
|
||||||
ProcedureNotes:
|
ProcedureNoteBase:
|
||||||
type: object
|
type: object
|
||||||
x-display-format: "{summary}"
|
|
||||||
properties:
|
properties:
|
||||||
summary:
|
summary:
|
||||||
type: string
|
type: string
|
||||||
@@ -58,24 +57,155 @@ components:
|
|||||||
x-order: 2
|
x-order: 2
|
||||||
x-label: "Details"
|
x-label: "Details"
|
||||||
|
|
||||||
Call:
|
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
|
type: object
|
||||||
x-resource: calls
|
|
||||||
x-primary-key: _received_at
|
x-primary-key: _received_at
|
||||||
x-display-format: "{sound}"
|
x-display-format: "{action}"
|
||||||
x-list-columns: [sound]
|
x-list-columns: [action]
|
||||||
properties:
|
properties:
|
||||||
sound:
|
action:
|
||||||
type: string
|
type: string
|
||||||
enum: [woof, meow, coo]
|
enum: [wagging_tail, blep, stretching, showing_belly, barking, panting, purring, kneading, head_tilt, chirping, wing_flap]
|
||||||
x-label: "Sound"
|
x-label: "Action"
|
||||||
x-order: 1
|
x-order: 1
|
||||||
x-filterable: false
|
x-filterable: false
|
||||||
required: [sound]
|
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]
|
||||||
@@ -93,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
|
||||||
@@ -100,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
|
||||||
@@ -114,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]
|
||||||
@@ -132,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
|
||||||
@@ -145,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
|
||||||
@@ -152,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]
|
||||||
@@ -176,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
|
||||||
@@ -189,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]
|
||||||
@@ -207,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]
|
||||||
@@ -254,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]
|
||||||
@@ -295,7 +431,7 @@ components:
|
|||||||
$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
|
||||||
@@ -304,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
|
||||||
@@ -313,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]
|
||||||
|
|
||||||
@@ -384,19 +520,6 @@ components:
|
|||||||
$ref: '#/components/schemas/ErrorBody'
|
$ref: '#/components/schemas/ErrorBody'
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/calls:
|
|
||||||
get:
|
|
||||||
summary: Stream random animal sounds via SSE
|
|
||||||
operationId: stream_calls
|
|
||||||
x-sse: true
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: SSE stream of random animal sounds
|
|
||||||
content:
|
|
||||||
text/event-stream:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Call'
|
|
||||||
|
|
||||||
/parents:
|
/parents:
|
||||||
get:
|
get:
|
||||||
summary: List parents (paginated)
|
summary: List parents (paginated)
|
||||||
@@ -945,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:
|
||||||
|
|||||||
@@ -43,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,
|
||||||
@@ -368,9 +369,14 @@ def delete_appointment(id: int, response: Response):
|
|||||||
response.status_code = 204
|
response.status_code = 204
|
||||||
|
|
||||||
|
|
||||||
async def stream_calls():
|
async def stream_actions(id: int):
|
||||||
"""Stream random animal sounds via SSE."""
|
"""Stream animal actions via SSE, scoped to a pet's species."""
|
||||||
q = await subscribe()
|
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():
|
async def event_generator():
|
||||||
try:
|
try:
|
||||||
@@ -378,6 +384,6 @@ async def stream_calls():
|
|||||||
data = await q.get()
|
data = await q.get()
|
||||||
yield f"data: {data}\n\n"
|
yield f"data: {data}\n\n"
|
||||||
finally:
|
finally:
|
||||||
unsubscribe(q)
|
unsubscribe(id, q)
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
"""
|
|
||||||
SSE broadcast for the animal-sounds worker.
|
|
||||||
|
|
||||||
Not part of the openapi_first library API surface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
_behaviors_by_species = {
|
||||||
_sounds = ["woof", "meow", "coo"]
|
"dog": ["wagging_tail", "stretching", "showing_belly", "barking", "panting"],
|
||||||
_subscribers: list[asyncio.Queue] = []
|
"cat": ["blep", "stretching", "showing_belly", "purring", "kneading"],
|
||||||
_worker_task: asyncio.Task | None = None
|
"bird": ["head_tilt", "stretching", "chirping", "wing_flap"],
|
||||||
|
}
|
||||||
|
_subscribers: dict[int, list[asyncio.Queue]] = {}
|
||||||
|
_worker_tasks: dict[int, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _sound_worker():
|
async def _behavior_worker(pet_id: int, species: str):
|
||||||
|
behaviors = _behaviors_by_species.get(species, ["wagging_tail"])
|
||||||
while True:
|
while True:
|
||||||
sound = random.choice(_sounds)
|
action = random.choice(behaviors)
|
||||||
data = json.dumps({"sound": sound})
|
data = json.dumps({"action": action})
|
||||||
for q in _subscribers:
|
queues = _subscribers.get(pet_id, [])
|
||||||
|
for q in queues:
|
||||||
await q.put(data)
|
await q.put(data)
|
||||||
await asyncio.sleep(random.uniform(1, 5))
|
await asyncio.sleep(random.uniform(1, 5))
|
||||||
|
|
||||||
|
|
||||||
def start_worker():
|
def _ensure_worker(pet_id: int, species: str):
|
||||||
global _worker_task
|
if pet_id not in _worker_tasks or _worker_tasks[pet_id].done():
|
||||||
_worker_task = asyncio.create_task(_sound_worker())
|
_subscribers.setdefault(pet_id, [])
|
||||||
|
_worker_tasks[pet_id] = asyncio.create_task(
|
||||||
|
_behavior_worker(pet_id, species)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def stop_worker():
|
async def subscribe(pet_id: int, species: str) -> asyncio.Queue:
|
||||||
if _worker_task is not None:
|
|
||||||
_worker_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
async def subscribe() -> asyncio.Queue:
|
|
||||||
q: asyncio.Queue = asyncio.Queue()
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
_subscribers.append(q)
|
_subscribers.setdefault(pet_id, []).append(q)
|
||||||
|
_ensure_worker(pet_id, species)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe(q: asyncio.Queue):
|
def unsubscribe(pet_id: int, q: asyncio.Queue):
|
||||||
if q in _subscribers:
|
queues = _subscribers.get(pet_id, [])
|
||||||
_subscribers.remove(q)
|
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