Skip to content

Commit 94e0620

Browse files
committed
implement fastapi render server
1 parent 962d885 commit 94e0620

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-0
lines changed

requirements/pkg-extras.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
sanic <19.12.0
33
sanic-cors >=0.9.9
44

5+
# extra=fastapi
6+
fastapi >=0.63.0
7+
uvicorn[standard] >=0.13.4
8+
59
# extra=flask
610
flask
711
flask-cors

src/idom/server/fastapi.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import asyncio
2+
import json
3+
import logging
4+
import uuid
5+
from threading import Event
6+
from typing import Any, Dict, Optional, Tuple, Type, Union, cast
7+
8+
import uvicorn
9+
from fastapi import APIRouter, FastAPI, Request, WebSocket
10+
from fastapi.middleware.cors import CORSMiddleware
11+
from fastapi.responses import RedirectResponse
12+
from fastapi.staticfiles import StaticFiles
13+
from mypy_extensions import TypedDict
14+
from starlette.websockets import WebSocketDisconnect
15+
16+
from idom.config import IDOM_CLIENT_BUILD_DIR
17+
from idom.core.dispatcher import (
18+
AbstractDispatcher,
19+
RecvCoroutine,
20+
SendCoroutine,
21+
SharedViewDispatcher,
22+
SingleViewDispatcher,
23+
)
24+
from idom.core.layout import Layout, LayoutEvent, LayoutUpdate
25+
26+
from .base import AbstractRenderServer
27+
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
class Config(TypedDict, total=False):
33+
"""Config for :class:`FastApiRenderServer`"""
34+
35+
cors: Union[bool, Dict[str, Any]]
36+
url_prefix: str
37+
serve_static_files: bool
38+
redirect_root_to_index: bool
39+
40+
41+
class FastApiRenderServer(AbstractRenderServer[FastAPI, Config]):
42+
"""Base ``sanic`` extension."""
43+
44+
_dispatcher_type: Type[AbstractDispatcher]
45+
46+
def stop(self) -> None:
47+
"""Stop the running application"""
48+
self._loop.call_soon_threadsafe(self._loop.stop)
49+
50+
def _create_config(self, config: Optional[Config]) -> Config:
51+
new_config: Config = {
52+
"cors": False,
53+
"url_prefix": "",
54+
"serve_static_files": True,
55+
"redirect_root_to_index": True,
56+
**(config or {}), # type: ignore
57+
}
58+
return new_config
59+
60+
def _default_application(self, config: Config) -> FastAPI:
61+
return FastAPI()
62+
63+
def _setup_application(self, config: Config, app: FastAPI) -> None:
64+
router = APIRouter(prefix=config["url_prefix"])
65+
66+
self._setup_api_router(config, router)
67+
self._setup_static_files(config, app)
68+
69+
cors_config = config["cors"]
70+
if cors_config:
71+
cors_params = (
72+
cors_config
73+
if isinstance(cors_config, dict)
74+
else {"allow_origins": ["*"]}
75+
)
76+
app.add_middleware(CORSMiddleware, **cors_params)
77+
78+
app.include_router(router)
79+
80+
def _setup_application_did_start_event(
81+
self, config: Config, app: FastAPI, event: Event
82+
) -> None:
83+
@app.on_event("startup")
84+
async def startup_event():
85+
self._loop = asyncio.get_event_loop()
86+
event.set()
87+
88+
def _setup_api_router(self, config: Config, router: APIRouter) -> None:
89+
"""Add routes to the application blueprint"""
90+
91+
@router.websocket("/stream") # type: ignore
92+
async def model_stream(socket: WebSocket) -> None:
93+
await socket.accept()
94+
95+
async def sock_send(value: LayoutUpdate) -> None:
96+
await socket.send_text(json.dumps(value))
97+
98+
async def sock_recv() -> LayoutEvent:
99+
return LayoutEvent(**json.loads(await socket.receive_text()))
100+
101+
try:
102+
await self._run_dispatcher(
103+
sock_send, sock_recv, dict(socket.query_params)
104+
)
105+
except WebSocketDisconnect as error:
106+
logger.info(f"WebSocket disconnect: {error.code}")
107+
108+
def _setup_static_files(self, config: Config, app: FastAPI) -> None:
109+
# This really should be added to the APIRouter, but there's a bug in FastAPI
110+
# BUG: https://github.com/tiangolo/fastapi/issues/1469
111+
url_prefix = config["url_prefix"]
112+
if config["serve_static_files"]:
113+
app.mount(
114+
f"{url_prefix}/client",
115+
StaticFiles(
116+
directory=str(IDOM_CLIENT_BUILD_DIR.get()),
117+
html=True,
118+
check_dir=True,
119+
),
120+
name="idom_static_files",
121+
)
122+
123+
if config["redirect_root_to_index"]:
124+
125+
@app.route(f"{url_prefix}/")
126+
def redirect_to_index(request: Request):
127+
return RedirectResponse(
128+
f"{url_prefix}/client/index.html?{request.query_params}"
129+
)
130+
131+
def _run_application(
132+
self,
133+
config: Config,
134+
app: FastAPI,
135+
host: str,
136+
port: int,
137+
args: Tuple[Any, ...],
138+
kwargs: Dict[str, Any],
139+
) -> None:
140+
uvicorn.run(app, host=host, port=port, *args, **kwargs)
141+
142+
def _run_application_in_thread(
143+
self,
144+
config: Config,
145+
app: FastAPI,
146+
host: str,
147+
port: int,
148+
args: Tuple[Any, ...],
149+
kwargs: Dict[str, Any],
150+
) -> None:
151+
# uvicorn does the event loop setup for us
152+
self._run_application(config, app, host, port, args, kwargs)
153+
154+
async def _run_dispatcher(
155+
self,
156+
send: SendCoroutine,
157+
recv: RecvCoroutine,
158+
params: Dict[str, Any],
159+
) -> None:
160+
async with self._make_dispatcher(params) as dispatcher:
161+
await dispatcher.run(send, recv, None)
162+
163+
def _make_dispatcher(self, params: Dict[str, Any]) -> AbstractDispatcher:
164+
return self._dispatcher_type(Layout(self._root_component_constructor(**params)))
165+
166+
167+
class PerClientStateServer(FastApiRenderServer):
168+
"""Each client view will have its own state."""
169+
170+
_dispatcher_type = SingleViewDispatcher
171+
172+
173+
class SharedClientStateServer(FastApiRenderServer):
174+
"""All connected client views will have shared state."""
175+
176+
_dispatcher_type = SharedViewDispatcher
177+
_dispatcher: SharedViewDispatcher
178+
179+
def _setup_application(self, config: Config, app: FastAPI) -> None:
180+
app.on_event("startup")(self._activate_dispatcher)
181+
app.on_event("shutdown")(self._deactivate_dispatcher)
182+
super()._setup_application(config, app)
183+
184+
async def _activate_dispatcher(self) -> None:
185+
self._dispatcher = cast(SharedViewDispatcher, self._make_dispatcher({}))
186+
await self._dispatcher.start()
187+
188+
async def _deactivate_dispatcher(self) -> None: # pragma: no cover
189+
# this doesn't seem to get triggered during testing for some reason
190+
await self._dispatcher.stop()
191+
192+
async def _run_dispatcher(
193+
self,
194+
send: SendCoroutine,
195+
recv: RecvCoroutine,
196+
params: Dict[str, Any],
197+
) -> None:
198+
if params:
199+
msg = f"SharedClientState server does not support per-client view parameters {params}"
200+
raise ValueError(msg)
201+
await self._dispatcher.run(send, recv, uuid.uuid4().hex, join=True)

tests/test_server/test_common/test_per_client_state.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
import idom
4+
from idom.server import fastapi as idom_fastapi
45
from idom.server import flask as idom_flask
56
from idom.server import sanic as idom_sanic
67
from idom.server import tornado as idom_tornado
@@ -14,6 +15,7 @@
1415
idom_sanic.PerClientStateServer,
1516
idom_flask.PerClientStateServer,
1617
idom_tornado.PerClientStateServer,
18+
idom_fastapi.PerClientStateServer,
1719
],
1820
ids=lambda cls: f"{cls.__module__}.{cls.__name__}",
1921
)

tests/test_server/test_common/test_shared_state_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
import idom
7+
from idom.server import fastapi as idom_fastapi
78
from idom.server import sanic as idom_sanic
89
from idom.testing import ServerMountPoint
910

@@ -13,6 +14,7 @@
1314
# add new SharedClientStateServer implementations here to
1415
# run a suite of tests which check basic functionality
1516
idom_sanic.SharedClientStateServer,
17+
idom_fastapi.SharedClientStateServer,
1618
],
1719
ids=lambda cls: f"{cls.__module__}.{cls.__name__}",
1820
)

0 commit comments

Comments
 (0)