16 Commits

Author SHA1 Message Date
c95de5a6e9 bumped up version to 0.0.6 2026-07-19 20:20:42 +05:30
b3f3068f8d auth-fixes (#3)
## Summary

Resolve `{ENV_VAR}` placeholders in the OpenAPI spec before serving it. Replace the monolithic `x-introspect-url` extension with composable `x-server-url` + individual `x-*-path` fields so auth endpoints are configurable per-environment without hardcoding.

## Changes

- **`openapi_first/app.py`** — add `_resolve_spec_env_vars()` that replaces `{ENV_VAR}` patterns (e.g. `{AUTH_SERVER}`) with the corresponding OS environment variable before returning the spec JSON. Called in `__init__` after spec load.
- **`openapi_first/security.py`** — build the introspection URL dynamically from `x-server-url` + `x-introspect-path` extensions on the `bearerAuth` security scheme, instead of reading a single `x-introspect-url`.

## Migration

Existing specs using `x-introspect-url: "https://auth.example.com/introspect"` must switch to the new extension format:

```yaml
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      x-server-url: "{AUTH_SERVER}"
      x-login-path: "/login"
      x-register-path: "/register"
      x-logout-path: "/logout"
      x-me-path: "/me"
      x-introspect-path: "/introspect"

Reviewed-on: #3
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-19 14:47:02 +00:00
7d075b3904 bumped up version
All checks were successful
continuous-integration/drone Build is passing
2026-07-17 12:52:51 +05:30
28c6d9e964 if no ffilterable but autocomplete, thriw error 2026-07-13 04:17:59 +05:30
628c3e97f5 if no ffilterable but autocomplete, thriw error 2026-07-13 04:14:42 +05:30
4bf358734a filterable treatment description 2026-07-13 04:01:50 +05:30
f5b3c2bb11 removed - from phone number 2026-07-13 03:24:05 +05:30
3da419fed5 feat(vet_app): add x-autocomplete annotations to schema fields
- Add x-autocomplete annotations to all qualifying string fields:
  - token: Pet.name, Parent.name, Vet.name, Vet.specialty, Treatment.label, Procedure.name
  - text: Treatment.description, Appointment.notes, Procedure.description
  - email: Parent.email, Vet.email
  - phone: Parent.phone, Vet.phone
2026-07-13 03:22:12 +05:30
a417563ab5 readme and order fixes 2026-07-13 01:36:35 +05:30
1940e33bc7 nested schema fixes 2026-07-12 23:53:42 +05:30
5da0a688a8 allOf and oneOf 2026-07-12 01:19:03 +05:30
f1a7a556fd sse sub resource 2026-07-12 00:51:23 +05:30
4a7a76e330 sse events schema for /calls 2026-06-18 01:52:55 +05:30
083fb6923d sse events sample 2026-06-18 01:21:40 +05:30
69b795f9ca moved procedures field from treatment to appointment and updated schema 2026-06-17 01:25:19 +05:30
61de233745 nested inline schemas 2026-06-17 01:01:35 +05:30
12 changed files with 610 additions and 38 deletions

View File

@@ -102,6 +102,7 @@ from . import client
from . import errors
from . import codegen
from . import codegen_routes
from . import security
__all__ = [
"app",
@@ -111,4 +112,5 @@ __all__ = [
"errors",
"codegen",
"codegen_routes",
"security",
]

View File

@@ -32,10 +32,34 @@ Notes:
- Alter FastAPI dependency injection semantics.
"""
import os
import re
from fastapi import FastAPI
from .loader import load_openapi
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):
@@ -48,15 +72,19 @@ class OpenAPIFirstApp(FastAPI):
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
registration with OpenAPI-driven binding.
- 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.
- Auth dependencies are auto-injected from the spec's
``securitySchemes`` and per-operation ``security`` fields.
**Guarantees:**
- No route can exist without an OpenAPI declaration.
- 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.
- Auth enforcement is driven entirely by the spec — no manual
middleware or decorators required.
Example:
```python
@@ -87,28 +115,33 @@ class OpenAPIFirstApp(FastAPI):
specification is treated as the authoritative API contract.
routes_module (module):
Python module containing handler functions whose names correspond
exactly to OpenAPI `operationId` values.
exactly to OpenAPI ``operationId`` values.
**fastapi_kwargs (Any):
Additional keyword arguments passed directly to
`fastapi.FastAPI` (e.g., title, version, middleware, lifespan
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
handlers).
Raises:
OpenAPIFirstError:
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
super().__init__(**fastapi_kwargs)
# 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(
app=self,
spec=self._openapi_spec,
routes_module=routes_module,
security_deps=security_deps,
)
# Override FastAPI's OpenAPI generation

View File

@@ -32,12 +32,19 @@ Notes:
- Interpret OpenAPI semantics beyond routing metadata.
"""
from typing import Any
from fastapi.routing import APIRoute
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.
@@ -49,18 +56,24 @@ def bind_routes(app, spec: dict, routes_module) -> None:
routes_module (module):
Python module containing handler functions. Each handler's name MUST
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:
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.
Notes:
**Responsibilities:**
- Iterates through the OpenAPI specification paths and methods.
- Resolves each `operationId` to a handler function, and registers
a corresponding `APIRoute` on the FastAPI application.
- Resolves each ``operationId`` to a handler function, and registers
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:**
@@ -70,6 +83,7 @@ def bind_routes(app, spec: dict, routes_module) -> None:
"""
paths = spec.get("paths", {})
security_deps = security_deps or {}
for path, methods in paths.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,
)
key = f"{http_method.upper()}:{path}"
deps = security_deps.get(key, [])
route = APIRoute(
path=path,
endpoint=endpoint,
methods=[http_method.upper()],
dependencies=deps,
name=operation_id,
)

145
openapi_first/security.py Normal file
View 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

View File

@@ -24,9 +24,8 @@ library API surface.
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-display-format`` (REQUIRED) Human-readable label template
``x-list-columns`` (REQUIRED) Columns for the datatable

View File

@@ -22,6 +22,9 @@ from models import (
Parent, ParentCreate,
Vet, VetCreate,
Treatment, TreatmentCreate,
Procedure,
BasicNote, HeartRateNote, DentalNote,
VaccineNote, PreOpNote, SurgeryNote,
Pet, PetCreate,
Appointment, AppointmentCreate,
)
@@ -268,6 +271,7 @@ def create_appointment(payload: AppointmentCreate) -> Appointment:
id=_appointments_next_id,
date=payload.date,
notes=payload.notes,
procedures=payload.procedures,
pet=_pets[payload.pet_id],
vet=_vets[payload.vet_id],
treatment=_treatments[payload.treatment_id],
@@ -287,6 +291,7 @@ def update_appointment(appointment_id: int, payload: AppointmentCreate) -> Appoi
id=appointment_id,
date=payload.date,
notes=payload.notes if payload.notes is not None else current.notes,
procedures=payload.procedures,
pet=_pets.get(payload.pet_id, current.pet),
vet=_vets.get(payload.vet_id, current.vet),
treatment=_treatments.get(payload.treatment_id, current.treatment),
@@ -310,15 +315,15 @@ def _seed_data():
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[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="5550102", 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="5550104", 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[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="5550202", 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
_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_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[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, 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)
_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

View File

@@ -25,15 +25,25 @@ Example:
uvicorn main:app
"""
from contextlib import asynccontextmanager
from starlette.middleware.cors import CORSMiddleware
from openapi_first.app import OpenAPIFirstApp
import routes
@asynccontextmanager
async def lifespan(app):
try:
yield
finally:
pass
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=routes,
title="Veterinary Clinic Service",
lifespan=lifespan,
)
app.add_middleware(

View File

@@ -1,5 +1,6 @@
from datetime import date, datetime
from pydantic import BaseModel
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Metadata(BaseModel):
@@ -38,6 +39,57 @@ class Vet(VetBase):
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):
label: str
description: str | None = None
@@ -74,6 +126,7 @@ class Pet(PetBase):
class AppointmentBase(BaseModel):
date: datetime
notes: str | None = None
procedures: list[Procedure] = []
metadata: Metadata | None = None

View File

@@ -23,9 +23,189 @@ components:
type: string
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:
type: object
x-resource: parents
x-primary-key: id
x-display-format: "{name}"
x-list-columns: [name, email, phone]
@@ -43,6 +223,7 @@ components:
x-description: "Parent's full name"
x-filterable: true
x-sortable: true
x-autocomplete: token
email:
type: string
format: email
@@ -50,12 +231,14 @@ components:
x-label: "Email"
x-description: "Email address"
x-filterable: true
x-autocomplete: email
phone:
type: string
x-order: 3
x-label: "Phone"
x-description: "Contact phone number"
x-filterable: true
x-autocomplete: phone
metadata:
$ref: '#/components/schemas/Metadata'
x-order: 4
@@ -64,7 +247,6 @@ components:
Vet:
type: object
x-resource: vets
x-primary-key: id
x-display-format: "Dr. {name}"
x-list-columns: [name, specialty, email, phone]
@@ -82,12 +264,14 @@ components:
x-description: "Veterinarian's full name"
x-filterable: true
x-sortable: true
x-autocomplete: token
specialty:
type: string
x-order: 2
x-label: "Specialty"
x-description: "Area of specialization"
x-filterable: true
x-autocomplete: token
email:
type: string
format: email
@@ -95,6 +279,7 @@ components:
x-label: "Email"
x-description: "Email address"
x-filterable: true
x-autocomplete: email
phone:
type: string
x-order: 4
@@ -102,13 +287,12 @@ components:
x-description: "Contact phone number"
metadata:
$ref: '#/components/schemas/Metadata'
x-order: 4
x-order: 5
x-label: "Metadata"
required: [id, name]
Treatment:
type: object
x-resource: treatments
x-primary-key: id
x-display-format: "{label}"
x-list-columns: [label, description]
@@ -126,11 +310,14 @@ components:
x-description: "Name of the treatment"
x-filterable: true
x-sortable: true
x-autocomplete: token
description:
type: string
x-order: 2
x-label: "Description"
x-description: "Detailed description of the treatment"
x-filterable: true
x-autocomplete: text
metadata:
$ref: '#/components/schemas/Metadata'
x-order: 4
@@ -139,7 +326,6 @@ components:
Pet:
type: object
x-resource: pets
x-primary-key: id
x-display-format: "{name} #{id}"
x-list-columns: [name, species, age, weight, birthDate, parents]
@@ -157,6 +343,7 @@ components:
x-description: "Name of the pet"
x-filterable: true
x-sortable: true
x-autocomplete: token
species:
type: string
enum: [dog, cat, bird]
@@ -204,13 +391,12 @@ components:
x-filterable: true
metadata:
$ref: '#/components/schemas/Metadata'
x-order: 4
x-order: 8
x-label: "Metadata"
required: [id, name, parents]
Appointment:
type: object
x-resource: appointments
x-primary-key: id
x-display-format: "Appt #{id} {date}"
x-list-columns: [date, pet, vet, treatment, notes]
@@ -234,11 +420,18 @@ components:
x-order: 2
x-label: "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:
$ref: '#/components/schemas/Pet'
x-fk:
resource: pets
x-order: 3
x-order: 4
x-label: "Pet"
x-description: "Select a pet"
x-filterable: true
@@ -247,7 +440,7 @@ components:
x-fk:
resource: vets
prefetch: true
x-order: 4
x-order: 5
x-label: "Veterinarian"
x-description: "Select a veterinarian"
x-filterable: true
@@ -256,13 +449,13 @@ components:
x-fk:
resource: treatments
prefetch: true
x-order: 5
x-order: 6
x-label: "Treatment"
x-description: "Select a treatment"
x-filterable: true
metadata:
$ref: '#/components/schemas/Metadata'
x-order: 4
x-order: 7
x-label: "Metadata"
required: [id, date, pet, vet, treatment]
@@ -875,6 +1068,23 @@ paths:
$ref: '#/components/responses/ValidationError'
'500':
$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:
get:
@@ -942,6 +1152,7 @@ paths:
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
/appointments/{id}:
get:
operationId: get_appointment

View File

@@ -11,6 +11,9 @@ in the OpenAPI specification.
"""
from fastapi import Response, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sse import subscribe, unsubscribe
from models import (
ParentCreate,
@@ -40,6 +43,7 @@ from data import (
create_pet as _create_pet,
update_pet as _update_pet,
delete_pet as _delete_pet,
get_pet as _get_pet,
list_appointments as _list_appointments,
get_appointment as _get_appointment,
create_appointment as _create_appointment,
@@ -363,3 +367,23 @@ def delete_appointment(id: int, response: Response):
except KeyError:
raise HTTPException(status_code=404, detail="Appointment not found")
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")

View 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)

View File

@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openapi-first"
version = "0.0.4"
version = "0.0.6"
description = "Strict OpenAPI-first application bootstrap for FastAPI."
readme = "README.md"
requires-python = ">=3.10"
@@ -55,6 +55,9 @@ dependencies = [
# Code generation
"datamodel-code-generator>=0.25.0",
# HTTP client for service-to-service (token introspection)
"httpx>=0.27.0",
]
[project.scripts]