fastapi.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import asyncio
  2. import logging
  3. import os
  4. import typing
  5. from functools import partial
  6. import uvicorn
  7. from starlette.applications import Starlette
  8. from starlette.requests import Request
  9. from starlette.responses import HTMLResponse
  10. from starlette.routing import Route, WebSocketRoute, Mount
  11. from starlette.websockets import WebSocket, WebSocketState
  12. from starlette.websockets import WebSocketDisconnect
  13. from . import page
  14. from .page import make_applications, render_page
  15. from .remote_access import start_remote_access_service
  16. from .tornado import open_webbrowser_on_server_started
  17. from .utils import cdn_validation, OriginChecker, print_listen_address
  18. from ..session import register_session_implement_for_target, Session
  19. from ..session.base import get_session_info_from_headers
  20. from ..utils import get_free_port, STATIC_PATH, strip_space, parse_file_size
  21. logger = logging.getLogger(__name__)
  22. from .adaptor import ws as ws_adaptor
  23. class WebSocketConnection(ws_adaptor.WebSocketConnection):
  24. def __init__(self, websocket: WebSocket, ioloop):
  25. self.ws = websocket
  26. self.ioloop = ioloop
  27. def get_query_argument(self, name) -> typing.Optional[str]:
  28. return self.ws.query_params.get(name, None)
  29. def make_session_info(self) -> dict:
  30. session_info = get_session_info_from_headers(self.ws.headers)
  31. session_info['user_ip'] = self.ws.client.host or ''
  32. session_info['request'] = self.ws
  33. session_info['backend'] = 'starlette'
  34. session_info['protocol'] = 'websocket'
  35. return session_info
  36. def write_message(self, message: dict):
  37. self.ioloop.create_task(self.ws.send_json(message))
  38. def closed(self) -> bool:
  39. return self.ws.application_state == WebSocketState.DISCONNECTED
  40. def close(self):
  41. self.ioloop.create_task(self.ws.close())
  42. def _webio_routes(applications, cdn, check_origin_func, reconnect_timeout):
  43. """
  44. :param dict applications: dict of `name -> task function`
  45. :param bool/str cdn: Whether to load front-end static resources from CDN
  46. :param callable check_origin_func: check_origin_func(origin, host) -> bool
  47. """
  48. ws_adaptor.set_expire_second(reconnect_timeout)
  49. async def http_endpoint(request: Request):
  50. origin = request.headers.get('origin')
  51. if origin and not check_origin_func(origin=origin, host=request.headers.get('host')):
  52. return HTMLResponse(status_code=403, content="Cross origin websockets not allowed")
  53. # Backward compatible
  54. if request.query_params.get('test'):
  55. return HTMLResponse(content="")
  56. app_name = request.query_params.get('app', 'index')
  57. app = applications.get(app_name) or applications['index']
  58. no_cdn = cdn is True and request.query_params.get('_pywebio_cdn', '') == 'false'
  59. html = render_page(app, protocol='ws', cdn=False if no_cdn else cdn)
  60. return HTMLResponse(content=html)
  61. async def websocket_endpoint(websocket: WebSocket):
  62. ioloop = asyncio.get_event_loop()
  63. asyncio.get_event_loop().create_task(ws_adaptor.session_clean_task())
  64. await websocket.accept()
  65. app_name = websocket.query_params.get('app', 'index')
  66. application = applications.get(app_name) or applications['index']
  67. conn = WebSocketConnection(websocket, ioloop)
  68. handler = ws_adaptor.WebSocketHandler(
  69. connection=conn, application=application, reconnectable=bool(reconnect_timeout), ioloop=ioloop
  70. )
  71. while True:
  72. try:
  73. msg = await websocket.receive()
  74. if msg["type"] == "websocket.disconnect":
  75. raise WebSocketDisconnect(msg["code"])
  76. text, binary = msg.get('text'), msg.get('bytes')
  77. if text:
  78. handler.send_client_data(text)
  79. if binary:
  80. handler.send_client_data(binary)
  81. except WebSocketDisconnect:
  82. handler.notify_connection_lost()
  83. break
  84. return [
  85. Route("/", http_endpoint),
  86. WebSocketRoute("/", websocket_endpoint)
  87. ]
  88. def webio_routes(applications, cdn=True, reconnect_timeout=0, allowed_origins=None, check_origin=None):
  89. """Get the FastAPI/Starlette routes for running PyWebIO applications.
  90. The API communicates with the browser using WebSocket protocol.
  91. The arguments of ``webio_routes()`` have the same meaning as for :func:`pywebio.platform.fastapi.start_server`
  92. .. versionadded:: 1.3
  93. :return: FastAPI/Starlette routes
  94. """
  95. try:
  96. import websockets
  97. except Exception:
  98. raise RuntimeError(strip_space("""
  99. Missing dependency package `websockets` for websocket support.
  100. You can install it with the following command:
  101. pip install websockets
  102. """.strip(), n=8)) from None
  103. applications = make_applications(applications)
  104. for target in applications.values():
  105. register_session_implement_for_target(target)
  106. cdn = cdn_validation(cdn, 'error')
  107. if check_origin is None:
  108. check_origin_func = partial(OriginChecker.check_origin, allowed_origins=allowed_origins or [])
  109. else:
  110. check_origin_func = lambda origin, host: OriginChecker.is_same_site(origin, host) or check_origin(origin)
  111. return _webio_routes(applications=applications, cdn=cdn, check_origin_func=check_origin_func,
  112. reconnect_timeout=reconnect_timeout)
  113. def start_server(applications, port=0, host='', cdn=True, reconnect_timeout=0,
  114. static_dir=None, remote_access=False, debug=False,
  115. allowed_origins=None, check_origin=None,
  116. auto_open_webbrowser=False,
  117. max_payload_size='200M',
  118. **uvicorn_settings):
  119. """Start a FastAPI/Starlette server using uvicorn to provide the PyWebIO application as a web service.
  120. :param bool debug: Boolean indicating if debug tracebacks should be returned on errors.
  121. :param uvicorn_settings: Additional keyword arguments passed to ``uvicorn.run()``.
  122. For details, please refer: https://www.uvicorn.org/settings/
  123. The rest arguments of ``start_server()`` have the same meaning as for :func:`pywebio.platform.tornado.start_server`
  124. .. versionadded:: 1.3
  125. """
  126. app = asgi_app(applications, cdn=cdn, reconnect_timeout=reconnect_timeout,
  127. static_dir=static_dir, debug=debug,
  128. allowed_origins=allowed_origins, check_origin=check_origin)
  129. if auto_open_webbrowser:
  130. asyncio.get_event_loop().create_task(open_webbrowser_on_server_started('127.0.0.1', port))
  131. if not host:
  132. host = '0.0.0.0'
  133. if port == 0:
  134. port = get_free_port()
  135. print_listen_address(host, port)
  136. if remote_access:
  137. start_remote_access_service(local_port=port)
  138. page.MAX_PAYLOAD_SIZE = max_payload_size = parse_file_size(max_payload_size)
  139. uvicorn_settings = uvicorn_settings or {}
  140. uvicorn_settings.setdefault('ws_max_size', max_payload_size)
  141. uvicorn.run(app, host=host, port=port, **uvicorn_settings)
  142. def asgi_app(applications, cdn=True, reconnect_timeout=0, static_dir=None, debug=False, allowed_origins=None,
  143. check_origin=None):
  144. """Get the starlette/Fastapi ASGI app for running PyWebIO applications.
  145. Use :func:`pywebio.platform.fastapi.webio_routes` if you prefer handling static files yourself.
  146. The arguments of ``asgi_app()`` have the same meaning as for :func:`pywebio.platform.fastapi.start_server`
  147. :Example:
  148. To be used with ``FastAPI.mount()`` to include pywebio as a subapp into an existing Starlette/FastAPI application::
  149. from fastapi import FastAPI
  150. from pywebio.platform.fastapi import asgi_app
  151. from pywebio.output import put_text
  152. app = FastAPI()
  153. subapp = asgi_app(lambda: put_text("hello from pywebio"))
  154. app.mount("/pywebio", subapp)
  155. :Returns: Starlette/Fastapi ASGI app
  156. .. versionadded:: 1.3
  157. """
  158. try:
  159. from starlette.staticfiles import StaticFiles
  160. except Exception:
  161. raise RuntimeError(strip_space("""
  162. Missing dependency package `aiofiles` for static file serving.
  163. You can install it with the following command:
  164. pip install aiofiles
  165. """.strip(), n=8)) from None
  166. debug = Session.debug = os.environ.get('PYWEBIO_DEBUG', debug)
  167. cdn = cdn_validation(cdn, 'warn')
  168. if cdn is False:
  169. cdn = 'pywebio_static'
  170. routes = webio_routes(applications, cdn=cdn, reconnect_timeout=reconnect_timeout,
  171. allowed_origins=allowed_origins, check_origin=check_origin)
  172. if static_dir:
  173. routes.append(Mount('/static', app=StaticFiles(directory=static_dir), name="static"))
  174. routes.append(Mount('/pywebio_static', app=StaticFiles(directory=STATIC_PATH), name="pywebio_static"))
  175. return Starlette(routes=routes, debug=debug)