sse sub resource

This commit is contained in:
2026-07-12 00:51:23 +05:30
parent 4a7a76e330
commit f1a7a556fd
5 changed files with 59 additions and 60 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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:

View File

@@ -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")

View File

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