Compare commits
10 Commits
5da0a688a8
...
0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| c95de5a6e9 | |||
| b3f3068f8d | |||
| 7d075b3904 | |||
| 28c6d9e964 | |||
| 628c3e97f5 | |||
| 4bf358734a | |||
| f5b3c2bb11 | |||
| 3da419fed5 | |||
| a417563ab5 | |||
| 1940e33bc7 |
@@ -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
|
||||||
@@ -315,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)
|
||||||
|
|||||||
@@ -190,19 +190,19 @@ components:
|
|||||||
x-label: "Complications"
|
x-label: "Complications"
|
||||||
required: [noteType, surgeryType]
|
required: [noteType, surgeryType]
|
||||||
|
|
||||||
Call:
|
Action:
|
||||||
type: object
|
type: object
|
||||||
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
|
||||||
@@ -223,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
|
||||||
@@ -230,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
|
||||||
@@ -261,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
|
||||||
@@ -274,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
|
||||||
@@ -281,7 +287,7 @@ 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]
|
||||||
|
|
||||||
@@ -304,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
|
||||||
@@ -334,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]
|
||||||
@@ -381,7 +391,7 @@ 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]
|
||||||
|
|
||||||
@@ -1058,10 +1068,10 @@ paths:
|
|||||||
$ref: '#/components/responses/ValidationError'
|
$ref: '#/components/responses/ValidationError'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalServerError'
|
$ref: '#/components/responses/InternalServerError'
|
||||||
/pets/{id}/calls:
|
/pets/{id}/actions:
|
||||||
get:
|
get:
|
||||||
summary: Stream animal sounds via SSE, scoped to a pet's species
|
summary: Stream animal actions via SSE, scoped to a pet's species
|
||||||
operationId: stream_calls
|
operationId: stream_actions
|
||||||
x-sse: true
|
x-sse: true
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -1070,11 +1080,11 @@ paths:
|
|||||||
schema: {type: integer}
|
schema: {type: integer}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: SSE stream of random animal sounds
|
description: SSE stream of random animal actions (behaviors)
|
||||||
content:
|
content:
|
||||||
text/event-stream:
|
text/event-stream:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Call'
|
$ref: '#/components/schemas/Action'
|
||||||
|
|
||||||
/appointments:
|
/appointments:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -369,8 +369,8 @@ def delete_appointment(id: int, response: Response):
|
|||||||
response.status_code = 204
|
response.status_code = 204
|
||||||
|
|
||||||
|
|
||||||
async def stream_calls(id: int):
|
async def stream_actions(id: int):
|
||||||
"""Stream animal sounds via SSE, scoped to a pet's species."""
|
"""Stream animal actions via SSE, scoped to a pet's species."""
|
||||||
try:
|
try:
|
||||||
pet = _get_pet(id)
|
pet = _get_pet(id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import asyncio
|
|||||||
import random
|
import random
|
||||||
import json
|
import json
|
||||||
|
|
||||||
_sounds_by_species = {"dog": ["woof"], "cat": ["meow"], "bird": ["coo"]}
|
_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]] = {}
|
_subscribers: dict[int, list[asyncio.Queue]] = {}
|
||||||
_worker_tasks: dict[int, asyncio.Task] = {}
|
_worker_tasks: dict[int, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _sound_worker(pet_id: int, species: str):
|
async def _behavior_worker(pet_id: int, species: str):
|
||||||
sounds = _sounds_by_species.get(species, ["woof"])
|
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})
|
||||||
queues = _subscribers.get(pet_id, [])
|
queues = _subscribers.get(pet_id, [])
|
||||||
for q in queues:
|
for q in queues:
|
||||||
await q.put(data)
|
await q.put(data)
|
||||||
@@ -22,7 +26,7 @@ def _ensure_worker(pet_id: int, species: str):
|
|||||||
if pet_id not in _worker_tasks or _worker_tasks[pet_id].done():
|
if pet_id not in _worker_tasks or _worker_tasks[pet_id].done():
|
||||||
_subscribers.setdefault(pet_id, [])
|
_subscribers.setdefault(pet_id, [])
|
||||||
_worker_tasks[pet_id] = asyncio.create_task(
|
_worker_tasks[pet_id] = asyncio.create_task(
|
||||||
_sound_worker(pet_id, species)
|
_behavior_worker(pet_id, species)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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