fastapi.py 8.5 KB

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