sse sub resource
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ components:
|
|||||||
|
|
||||||
Call:
|
Call:
|
||||||
type: object
|
type: object
|
||||||
x-resource: calls
|
|
||||||
x-primary-key: _received_at
|
x-primary-key: _received_at
|
||||||
x-display-format: "{sound}"
|
x-display-format: "{sound}"
|
||||||
x-list-columns: [sound]
|
x-list-columns: [sound]
|
||||||
@@ -75,7 +74,6 @@ components:
|
|||||||
|
|
||||||
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]
|
||||||
@@ -114,7 +112,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]
|
||||||
@@ -158,7 +155,6 @@ components:
|
|||||||
|
|
||||||
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]
|
||||||
@@ -189,7 +185,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]
|
||||||
@@ -260,7 +255,6 @@ components:
|
|||||||
|
|
||||||
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 +289,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 +298,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 +307,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 +378,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 +926,23 @@ paths:
|
|||||||
$ref: '#/components/responses/ValidationError'
|
$ref: '#/components/responses/ValidationError'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
/pets/{id}/calls:
|
||||||
|
get:
|
||||||
|
summary: Stream animal sounds via SSE, scoped to a pet's species
|
||||||
|
operationId: stream_calls
|
||||||
|
x-sse: true
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: {type: integer}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: SSE stream of random animal sounds
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Call'
|
||||||
|
|
||||||
/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_calls(id: int):
|
||||||
"""Stream random animal sounds via SSE."""
|
"""Stream animal sounds 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,44 @@
|
|||||||
"""
|
|
||||||
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
|
||||||
|
|
||||||
|
_sounds_by_species = {"dog": ["woof"], "cat": ["meow"], "bird": ["coo"]}
|
||||||
_sounds = ["woof", "meow", "coo"]
|
_subscribers: dict[int, list[asyncio.Queue]] = {}
|
||||||
_subscribers: list[asyncio.Queue] = []
|
_worker_tasks: dict[int, asyncio.Task] = {}
|
||||||
_worker_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def _sound_worker():
|
async def _sound_worker(pet_id: int, species: str):
|
||||||
|
sounds = _sounds_by_species.get(species, ["woof"])
|
||||||
while True:
|
while True:
|
||||||
sound = random.choice(_sounds)
|
sound = random.choice(sounds)
|
||||||
data = json.dumps({"sound": sound})
|
data = json.dumps({"sound": sound})
|
||||||
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(
|
||||||
|
_sound_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user