routing.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932
  1. import contextlib
  2. import functools
  3. import inspect
  4. import re
  5. import traceback
  6. import types
  7. import typing
  8. import warnings
  9. from contextlib import asynccontextmanager
  10. from enum import Enum
  11. from starlette._exception_handler import wrap_app_handling_exceptions
  12. from starlette._utils import get_route_path, is_async_callable
  13. from starlette.concurrency import run_in_threadpool
  14. from starlette.convertors import CONVERTOR_TYPES, Convertor
  15. from starlette.datastructures import URL, Headers, URLPath
  16. from starlette.exceptions import HTTPException
  17. from starlette.middleware import Middleware
  18. from starlette.requests import Request
  19. from starlette.responses import PlainTextResponse, RedirectResponse, Response
  20. from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
  21. from starlette.websockets import WebSocket, WebSocketClose
  22. class NoMatchFound(Exception):
  23. """
  24. Raised by `.url_for(name, **path_params)` and `.url_path_for(name, **path_params)`
  25. if no matching route exists.
  26. """
  27. def __init__(self, name: str, path_params: typing.Dict[str, typing.Any]) -> None:
  28. params = ", ".join(list(path_params.keys()))
  29. super().__init__(f'No route exists for name "{name}" and params "{params}".')
  30. class Match(Enum):
  31. NONE = 0
  32. PARTIAL = 1
  33. FULL = 2
  34. def iscoroutinefunction_or_partial(obj: typing.Any) -> bool: # pragma: no cover
  35. """
  36. Correctly determines if an object is a coroutine function,
  37. including those wrapped in functools.partial objects.
  38. """
  39. warnings.warn(
  40. "iscoroutinefunction_or_partial is deprecated, "
  41. "and will be removed in a future release.",
  42. DeprecationWarning,
  43. )
  44. while isinstance(obj, functools.partial):
  45. obj = obj.func
  46. return inspect.iscoroutinefunction(obj)
  47. def request_response(
  48. func: typing.Callable[
  49. [Request], typing.Union[typing.Awaitable[Response], Response]
  50. ],
  51. ) -> ASGIApp:
  52. """
  53. Takes a function or coroutine `func(request) -> response`,
  54. and returns an ASGI application.
  55. """
  56. async def app(scope: Scope, receive: Receive, send: Send) -> None:
  57. request = Request(scope, receive, send)
  58. async def app(scope: Scope, receive: Receive, send: Send) -> None:
  59. if is_async_callable(func):
  60. response = await func(request)
  61. else:
  62. response = await run_in_threadpool(func, request)
  63. await response(scope, receive, send)
  64. await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  65. return app
  66. def websocket_session(
  67. func: typing.Callable[[WebSocket], typing.Awaitable[None]],
  68. ) -> ASGIApp:
  69. """
  70. Takes a coroutine `func(session)`, and returns an ASGI application.
  71. """
  72. # assert asyncio.iscoroutinefunction(func), "WebSocket endpoints must be async"
  73. async def app(scope: Scope, receive: Receive, send: Send) -> None:
  74. session = WebSocket(scope, receive=receive, send=send)
  75. async def app(scope: Scope, receive: Receive, send: Send) -> None:
  76. await func(session)
  77. await wrap_app_handling_exceptions(app, session)(scope, receive, send)
  78. return app
  79. def get_name(endpoint: typing.Callable[..., typing.Any]) -> str:
  80. if inspect.isroutine(endpoint) or inspect.isclass(endpoint):
  81. return endpoint.__name__
  82. return endpoint.__class__.__name__
  83. def replace_params(
  84. path: str,
  85. param_convertors: typing.Dict[str, Convertor[typing.Any]],
  86. path_params: typing.Dict[str, str],
  87. ) -> typing.Tuple[str, typing.Dict[str, str]]:
  88. for key, value in list(path_params.items()):
  89. if "{" + key + "}" in path:
  90. convertor = param_convertors[key]
  91. value = convertor.to_string(value)
  92. path = path.replace("{" + key + "}", value)
  93. path_params.pop(key)
  94. return path, path_params
  95. # Match parameters in URL paths, eg. '{param}', and '{param:int}'
  96. PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
  97. def compile_path(
  98. path: str,
  99. ) -> typing.Tuple[typing.Pattern[str], str, typing.Dict[str, Convertor[typing.Any]]]:
  100. """
  101. Given a path string, like: "/{username:str}",
  102. or a host string, like: "{subdomain}.mydomain.org", return a three-tuple
  103. of (regex, format, {param_name:convertor}).
  104. regex: "/(?P<username>[^/]+)"
  105. format: "/{username}"
  106. convertors: {"username": StringConvertor()}
  107. """
  108. is_host = not path.startswith("/")
  109. path_regex = "^"
  110. path_format = ""
  111. duplicated_params = set()
  112. idx = 0
  113. param_convertors = {}
  114. for match in PARAM_REGEX.finditer(path):
  115. param_name, convertor_type = match.groups("str")
  116. convertor_type = convertor_type.lstrip(":")
  117. assert (
  118. convertor_type in CONVERTOR_TYPES
  119. ), f"Unknown path convertor '{convertor_type}'"
  120. convertor = CONVERTOR_TYPES[convertor_type]
  121. path_regex += re.escape(path[idx : match.start()])
  122. path_regex += f"(?P<{param_name}>{convertor.regex})"
  123. path_format += path[idx : match.start()]
  124. path_format += "{%s}" % param_name
  125. if param_name in param_convertors:
  126. duplicated_params.add(param_name)
  127. param_convertors[param_name] = convertor
  128. idx = match.end()
  129. if duplicated_params:
  130. names = ", ".join(sorted(duplicated_params))
  131. ending = "s" if len(duplicated_params) > 1 else ""
  132. raise ValueError(f"Duplicated param name{ending} {names} at path {path}")
  133. if is_host:
  134. # Align with `Host.matches()` behavior, which ignores port.
  135. hostname = path[idx:].split(":")[0]
  136. path_regex += re.escape(hostname) + "$"
  137. else:
  138. path_regex += re.escape(path[idx:]) + "$"
  139. path_format += path[idx:]
  140. return re.compile(path_regex), path_format, param_convertors
  141. class BaseRoute:
  142. def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
  143. raise NotImplementedError() # pragma: no cover
  144. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  145. raise NotImplementedError() # pragma: no cover
  146. async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
  147. raise NotImplementedError() # pragma: no cover
  148. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  149. """
  150. A route may be used in isolation as a stand-alone ASGI app.
  151. This is a somewhat contrived case, as they'll almost always be used
  152. within a Router, but could be useful for some tooling and minimal apps.
  153. """
  154. match, child_scope = self.matches(scope)
  155. if match == Match.NONE:
  156. if scope["type"] == "http":
  157. response = PlainTextResponse("Not Found", status_code=404)
  158. await response(scope, receive, send)
  159. elif scope["type"] == "websocket":
  160. websocket_close = WebSocketClose()
  161. await websocket_close(scope, receive, send)
  162. return
  163. scope.update(child_scope)
  164. await self.handle(scope, receive, send)
  165. class Route(BaseRoute):
  166. def __init__(
  167. self,
  168. path: str,
  169. endpoint: typing.Callable[..., typing.Any],
  170. *,
  171. methods: typing.Optional[typing.List[str]] = None,
  172. name: typing.Optional[str] = None,
  173. include_in_schema: bool = True,
  174. middleware: typing.Optional[typing.Sequence[Middleware]] = None,
  175. ) -> None:
  176. assert path.startswith("/"), "Routed paths must start with '/'"
  177. self.path = path
  178. self.endpoint = endpoint
  179. self.name = get_name(endpoint) if name is None else name
  180. self.include_in_schema = include_in_schema
  181. endpoint_handler = endpoint
  182. while isinstance(endpoint_handler, functools.partial):
  183. endpoint_handler = endpoint_handler.func
  184. if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
  185. # Endpoint is function or method. Treat it as `func(request) -> response`.
  186. self.app = request_response(endpoint)
  187. if methods is None:
  188. methods = ["GET"]
  189. else:
  190. # Endpoint is a class. Treat it as ASGI.
  191. self.app = endpoint
  192. if middleware is not None:
  193. for cls, args, kwargs in reversed(middleware):
  194. self.app = cls(app=self.app, *args, **kwargs)
  195. if methods is None:
  196. self.methods = None
  197. else:
  198. self.methods = {method.upper() for method in methods}
  199. if "GET" in self.methods:
  200. self.methods.add("HEAD")
  201. self.path_regex, self.path_format, self.param_convertors = compile_path(path)
  202. def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
  203. path_params: "typing.Dict[str, typing.Any]"
  204. if scope["type"] == "http":
  205. route_path = get_route_path(scope)
  206. match = self.path_regex.match(route_path)
  207. if match:
  208. matched_params = match.groupdict()
  209. for key, value in matched_params.items():
  210. matched_params[key] = self.param_convertors[key].convert(value)
  211. path_params = dict(scope.get("path_params", {}))
  212. path_params.update(matched_params)
  213. child_scope = {"endpoint": self.endpoint, "path_params": path_params}
  214. if self.methods and scope["method"] not in self.methods:
  215. return Match.PARTIAL, child_scope
  216. else:
  217. return Match.FULL, child_scope
  218. return Match.NONE, {}
  219. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  220. seen_params = set(path_params.keys())
  221. expected_params = set(self.param_convertors.keys())
  222. if name != self.name or seen_params != expected_params:
  223. raise NoMatchFound(name, path_params)
  224. path, remaining_params = replace_params(
  225. self.path_format, self.param_convertors, path_params
  226. )
  227. assert not remaining_params
  228. return URLPath(path=path, protocol="http")
  229. async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
  230. if self.methods and scope["method"] not in self.methods:
  231. headers = {"Allow": ", ".join(self.methods)}
  232. if "app" in scope:
  233. raise HTTPException(status_code=405, headers=headers)
  234. else:
  235. response = PlainTextResponse(
  236. "Method Not Allowed", status_code=405, headers=headers
  237. )
  238. await response(scope, receive, send)
  239. else:
  240. await self.app(scope, receive, send)
  241. def __eq__(self, other: typing.Any) -> bool:
  242. return (
  243. isinstance(other, Route)
  244. and self.path == other.path
  245. and self.endpoint == other.endpoint
  246. and self.methods == other.methods
  247. )
  248. def __repr__(self) -> str:
  249. class_name = self.__class__.__name__
  250. methods = sorted(self.methods or [])
  251. path, name = self.path, self.name
  252. return f"{class_name}(path={path!r}, name={name!r}, methods={methods!r})"
  253. class WebSocketRoute(BaseRoute):
  254. def __init__(
  255. self,
  256. path: str,
  257. endpoint: typing.Callable[..., typing.Any],
  258. *,
  259. name: typing.Optional[str] = None,
  260. middleware: typing.Optional[typing.Sequence[Middleware]] = None,
  261. ) -> None:
  262. assert path.startswith("/"), "Routed paths must start with '/'"
  263. self.path = path
  264. self.endpoint = endpoint
  265. self.name = get_name(endpoint) if name is None else name
  266. endpoint_handler = endpoint
  267. while isinstance(endpoint_handler, functools.partial):
  268. endpoint_handler = endpoint_handler.func
  269. if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
  270. # Endpoint is function or method. Treat it as `func(websocket)`.
  271. self.app = websocket_session(endpoint)
  272. else:
  273. # Endpoint is a class. Treat it as ASGI.
  274. self.app = endpoint
  275. if middleware is not None:
  276. for cls, args, kwargs in reversed(middleware):
  277. self.app = cls(app=self.app, *args, **kwargs)
  278. self.path_regex, self.path_format, self.param_convertors = compile_path(path)
  279. def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
  280. path_params: "typing.Dict[str, typing.Any]"
  281. if scope["type"] == "websocket":
  282. route_path = get_route_path(scope)
  283. match = self.path_regex.match(route_path)
  284. if match:
  285. matched_params = match.groupdict()
  286. for key, value in matched_params.items():
  287. matched_params[key] = self.param_convertors[key].convert(value)
  288. path_params = dict(scope.get("path_params", {}))
  289. path_params.update(matched_params)
  290. child_scope = {"endpoint": self.endpoint, "path_params": path_params}
  291. return Match.FULL, child_scope
  292. return Match.NONE, {}
  293. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  294. seen_params = set(path_params.keys())
  295. expected_params = set(self.param_convertors.keys())
  296. if name != self.name or seen_params != expected_params:
  297. raise NoMatchFound(name, path_params)
  298. path, remaining_params = replace_params(
  299. self.path_format, self.param_convertors, path_params
  300. )
  301. assert not remaining_params
  302. return URLPath(path=path, protocol="websocket")
  303. async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
  304. await self.app(scope, receive, send)
  305. def __eq__(self, other: typing.Any) -> bool:
  306. return (
  307. isinstance(other, WebSocketRoute)
  308. and self.path == other.path
  309. and self.endpoint == other.endpoint
  310. )
  311. def __repr__(self) -> str:
  312. return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r})"
  313. class Mount(BaseRoute):
  314. def __init__(
  315. self,
  316. path: str,
  317. app: typing.Optional[ASGIApp] = None,
  318. routes: typing.Optional[typing.Sequence[BaseRoute]] = None,
  319. name: typing.Optional[str] = None,
  320. *,
  321. middleware: typing.Optional[typing.Sequence[Middleware]] = None,
  322. ) -> None:
  323. assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
  324. assert (
  325. app is not None or routes is not None
  326. ), "Either 'app=...', or 'routes=' must be specified"
  327. self.path = path.rstrip("/")
  328. if app is not None:
  329. self._base_app: ASGIApp = app
  330. else:
  331. self._base_app = Router(routes=routes)
  332. self.app = self._base_app
  333. if middleware is not None:
  334. for cls, args, kwargs in reversed(middleware):
  335. self.app = cls(app=self.app, *args, **kwargs)
  336. self.name = name
  337. self.path_regex, self.path_format, self.param_convertors = compile_path(
  338. self.path + "/{path:path}"
  339. )
  340. @property
  341. def routes(self) -> typing.List[BaseRoute]:
  342. return getattr(self._base_app, "routes", [])
  343. def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
  344. path_params: "typing.Dict[str, typing.Any]"
  345. if scope["type"] in ("http", "websocket"):
  346. root_path = scope.get("root_path", "")
  347. route_path = get_route_path(scope)
  348. match = self.path_regex.match(route_path)
  349. if match:
  350. matched_params = match.groupdict()
  351. for key, value in matched_params.items():
  352. matched_params[key] = self.param_convertors[key].convert(value)
  353. remaining_path = "/" + matched_params.pop("path")
  354. matched_path = route_path[: -len(remaining_path)]
  355. path_params = dict(scope.get("path_params", {}))
  356. path_params.update(matched_params)
  357. child_scope = {
  358. "path_params": path_params,
  359. # app_root_path will only be set at the top level scope,
  360. # initialized with the (optional) value of a root_path
  361. # set above/before Starlette. And even though any
  362. # mount will have its own child scope with its own respective
  363. # root_path, the app_root_path will always be available in all
  364. # the child scopes with the same top level value because it's
  365. # set only once here with a default, any other child scope will
  366. # just inherit that app_root_path default value stored in the
  367. # scope. All this is needed to support Request.url_for(), as it
  368. # uses the app_root_path to build the URL path.
  369. "app_root_path": scope.get("app_root_path", root_path),
  370. "root_path": root_path + matched_path,
  371. "endpoint": self.app,
  372. }
  373. return Match.FULL, child_scope
  374. return Match.NONE, {}
  375. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  376. if self.name is not None and name == self.name and "path" in path_params:
  377. # 'name' matches "<mount_name>".
  378. path_params["path"] = path_params["path"].lstrip("/")
  379. path, remaining_params = replace_params(
  380. self.path_format, self.param_convertors, path_params
  381. )
  382. if not remaining_params:
  383. return URLPath(path=path)
  384. elif self.name is None or name.startswith(self.name + ":"):
  385. if self.name is None:
  386. # No mount name.
  387. remaining_name = name
  388. else:
  389. # 'name' matches "<mount_name>:<child_name>".
  390. remaining_name = name[len(self.name) + 1 :]
  391. path_kwarg = path_params.get("path")
  392. path_params["path"] = ""
  393. path_prefix, remaining_params = replace_params(
  394. self.path_format, self.param_convertors, path_params
  395. )
  396. if path_kwarg is not None:
  397. remaining_params["path"] = path_kwarg
  398. for route in self.routes or []:
  399. try:
  400. url = route.url_path_for(remaining_name, **remaining_params)
  401. return URLPath(
  402. path=path_prefix.rstrip("/") + str(url), protocol=url.protocol
  403. )
  404. except NoMatchFound:
  405. pass
  406. raise NoMatchFound(name, path_params)
  407. async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
  408. await self.app(scope, receive, send)
  409. def __eq__(self, other: typing.Any) -> bool:
  410. return (
  411. isinstance(other, Mount)
  412. and self.path == other.path
  413. and self.app == other.app
  414. )
  415. def __repr__(self) -> str:
  416. class_name = self.__class__.__name__
  417. name = self.name or ""
  418. return f"{class_name}(path={self.path!r}, name={name!r}, app={self.app!r})"
  419. class Host(BaseRoute):
  420. def __init__(
  421. self, host: str, app: ASGIApp, name: typing.Optional[str] = None
  422. ) -> None:
  423. assert not host.startswith("/"), "Host must not start with '/'"
  424. self.host = host
  425. self.app = app
  426. self.name = name
  427. self.host_regex, self.host_format, self.param_convertors = compile_path(host)
  428. @property
  429. def routes(self) -> typing.List[BaseRoute]:
  430. return getattr(self.app, "routes", [])
  431. def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
  432. if scope["type"] in ("http", "websocket"):
  433. headers = Headers(scope=scope)
  434. host = headers.get("host", "").split(":")[0]
  435. match = self.host_regex.match(host)
  436. if match:
  437. matched_params = match.groupdict()
  438. for key, value in matched_params.items():
  439. matched_params[key] = self.param_convertors[key].convert(value)
  440. path_params = dict(scope.get("path_params", {}))
  441. path_params.update(matched_params)
  442. child_scope = {"path_params": path_params, "endpoint": self.app}
  443. return Match.FULL, child_scope
  444. return Match.NONE, {}
  445. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  446. if self.name is not None and name == self.name and "path" in path_params:
  447. # 'name' matches "<mount_name>".
  448. path = path_params.pop("path")
  449. host, remaining_params = replace_params(
  450. self.host_format, self.param_convertors, path_params
  451. )
  452. if not remaining_params:
  453. return URLPath(path=path, host=host)
  454. elif self.name is None or name.startswith(self.name + ":"):
  455. if self.name is None:
  456. # No mount name.
  457. remaining_name = name
  458. else:
  459. # 'name' matches "<mount_name>:<child_name>".
  460. remaining_name = name[len(self.name) + 1 :]
  461. host, remaining_params = replace_params(
  462. self.host_format, self.param_convertors, path_params
  463. )
  464. for route in self.routes or []:
  465. try:
  466. url = route.url_path_for(remaining_name, **remaining_params)
  467. return URLPath(path=str(url), protocol=url.protocol, host=host)
  468. except NoMatchFound:
  469. pass
  470. raise NoMatchFound(name, path_params)
  471. async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
  472. await self.app(scope, receive, send)
  473. def __eq__(self, other: typing.Any) -> bool:
  474. return (
  475. isinstance(other, Host)
  476. and self.host == other.host
  477. and self.app == other.app
  478. )
  479. def __repr__(self) -> str:
  480. class_name = self.__class__.__name__
  481. name = self.name or ""
  482. return f"{class_name}(host={self.host!r}, name={name!r}, app={self.app!r})"
  483. _T = typing.TypeVar("_T")
  484. class _AsyncLiftContextManager(typing.AsyncContextManager[_T]):
  485. def __init__(self, cm: typing.ContextManager[_T]):
  486. self._cm = cm
  487. async def __aenter__(self) -> _T:
  488. return self._cm.__enter__()
  489. async def __aexit__(
  490. self,
  491. exc_type: typing.Optional[typing.Type[BaseException]],
  492. exc_value: typing.Optional[BaseException],
  493. traceback: typing.Optional[types.TracebackType],
  494. ) -> typing.Optional[bool]:
  495. return self._cm.__exit__(exc_type, exc_value, traceback)
  496. def _wrap_gen_lifespan_context(
  497. lifespan_context: typing.Callable[
  498. [typing.Any], typing.Generator[typing.Any, typing.Any, typing.Any]
  499. ],
  500. ) -> typing.Callable[[typing.Any], typing.AsyncContextManager[typing.Any]]:
  501. cmgr = contextlib.contextmanager(lifespan_context)
  502. @functools.wraps(cmgr)
  503. def wrapper(app: typing.Any) -> _AsyncLiftContextManager[typing.Any]:
  504. return _AsyncLiftContextManager(cmgr(app))
  505. return wrapper
  506. class _DefaultLifespan:
  507. def __init__(self, router: "Router"):
  508. self._router = router
  509. async def __aenter__(self) -> None:
  510. await self._router.startup()
  511. async def __aexit__(self, *exc_info: object) -> None:
  512. await self._router.shutdown()
  513. def __call__(self: _T, app: object) -> _T:
  514. return self
  515. class Router:
  516. def __init__(
  517. self,
  518. routes: typing.Optional[typing.Sequence[BaseRoute]] = None,
  519. redirect_slashes: bool = True,
  520. default: typing.Optional[ASGIApp] = None,
  521. on_startup: typing.Optional[
  522. typing.Sequence[typing.Callable[[], typing.Any]]
  523. ] = None,
  524. on_shutdown: typing.Optional[
  525. typing.Sequence[typing.Callable[[], typing.Any]]
  526. ] = None,
  527. # the generic to Lifespan[AppType] is the type of the top level application
  528. # which the router cannot know statically, so we use typing.Any
  529. lifespan: typing.Optional[Lifespan[typing.Any]] = None,
  530. *,
  531. middleware: typing.Optional[typing.Sequence[Middleware]] = None,
  532. ) -> None:
  533. self.routes = [] if routes is None else list(routes)
  534. self.redirect_slashes = redirect_slashes
  535. self.default = self.not_found if default is None else default
  536. self.on_startup = [] if on_startup is None else list(on_startup)
  537. self.on_shutdown = [] if on_shutdown is None else list(on_shutdown)
  538. if on_startup or on_shutdown:
  539. warnings.warn(
  540. "The on_startup and on_shutdown parameters are deprecated, and they "
  541. "will be removed on version 1.0. Use the lifespan parameter instead. "
  542. "See more about it on https://www.starlette.io/lifespan/.",
  543. DeprecationWarning,
  544. )
  545. if lifespan:
  546. warnings.warn(
  547. "The `lifespan` parameter cannot be used with `on_startup` or "
  548. "`on_shutdown`. Both `on_startup` and `on_shutdown` will be "
  549. "ignored."
  550. )
  551. if lifespan is None:
  552. self.lifespan_context: Lifespan[typing.Any] = _DefaultLifespan(self)
  553. elif inspect.isasyncgenfunction(lifespan):
  554. warnings.warn(
  555. "async generator function lifespans are deprecated, "
  556. "use an @contextlib.asynccontextmanager function instead",
  557. DeprecationWarning,
  558. )
  559. self.lifespan_context = asynccontextmanager(
  560. lifespan,
  561. )
  562. elif inspect.isgeneratorfunction(lifespan):
  563. warnings.warn(
  564. "generator function lifespans are deprecated, "
  565. "use an @contextlib.asynccontextmanager function instead",
  566. DeprecationWarning,
  567. )
  568. self.lifespan_context = _wrap_gen_lifespan_context(
  569. lifespan,
  570. )
  571. else:
  572. self.lifespan_context = lifespan
  573. self.middleware_stack = self.app
  574. if middleware:
  575. for cls, args, kwargs in reversed(middleware):
  576. self.middleware_stack = cls(self.middleware_stack, *args, **kwargs)
  577. async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
  578. if scope["type"] == "websocket":
  579. websocket_close = WebSocketClose()
  580. await websocket_close(scope, receive, send)
  581. return
  582. # If we're running inside a starlette application then raise an
  583. # exception, so that the configurable exception handler can deal with
  584. # returning the response. For plain ASGI apps, just return the response.
  585. if "app" in scope:
  586. raise HTTPException(status_code=404)
  587. else:
  588. response = PlainTextResponse("Not Found", status_code=404)
  589. await response(scope, receive, send)
  590. def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath:
  591. for route in self.routes:
  592. try:
  593. return route.url_path_for(name, **path_params)
  594. except NoMatchFound:
  595. pass
  596. raise NoMatchFound(name, path_params)
  597. async def startup(self) -> None:
  598. """
  599. Run any `.on_startup` event handlers.
  600. """
  601. for handler in self.on_startup:
  602. if is_async_callable(handler):
  603. await handler()
  604. else:
  605. handler()
  606. async def shutdown(self) -> None:
  607. """
  608. Run any `.on_shutdown` event handlers.
  609. """
  610. for handler in self.on_shutdown:
  611. if is_async_callable(handler):
  612. await handler()
  613. else:
  614. handler()
  615. async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
  616. """
  617. Handle ASGI lifespan messages, which allows us to manage application
  618. startup and shutdown events.
  619. """
  620. started = False
  621. app: typing.Any = scope.get("app")
  622. await receive()
  623. try:
  624. async with self.lifespan_context(app) as maybe_state:
  625. if maybe_state is not None:
  626. if "state" not in scope:
  627. raise RuntimeError(
  628. 'The server does not support "state" in the lifespan scope.'
  629. )
  630. scope["state"].update(maybe_state)
  631. await send({"type": "lifespan.startup.complete"})
  632. started = True
  633. await receive()
  634. except BaseException:
  635. exc_text = traceback.format_exc()
  636. if started:
  637. await send({"type": "lifespan.shutdown.failed", "message": exc_text})
  638. else:
  639. await send({"type": "lifespan.startup.failed", "message": exc_text})
  640. raise
  641. else:
  642. await send({"type": "lifespan.shutdown.complete"})
  643. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  644. """
  645. The main entry point to the Router class.
  646. """
  647. await self.middleware_stack(scope, receive, send)
  648. async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
  649. assert scope["type"] in ("http", "websocket", "lifespan")
  650. if "router" not in scope:
  651. scope["router"] = self
  652. if scope["type"] == "lifespan":
  653. await self.lifespan(scope, receive, send)
  654. return
  655. partial = None
  656. for route in self.routes:
  657. # Determine if any route matches the incoming scope,
  658. # and hand over to the matching route if found.
  659. match, child_scope = route.matches(scope)
  660. if match == Match.FULL:
  661. scope.update(child_scope)
  662. await route.handle(scope, receive, send)
  663. return
  664. elif match == Match.PARTIAL and partial is None:
  665. partial = route
  666. partial_scope = child_scope
  667. if partial is not None:
  668. #  Handle partial matches. These are cases where an endpoint is
  669. # able to handle the request, but is not a preferred option.
  670. # We use this in particular to deal with "405 Method Not Allowed".
  671. scope.update(partial_scope)
  672. await partial.handle(scope, receive, send)
  673. return
  674. route_path = get_route_path(scope)
  675. if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
  676. redirect_scope = dict(scope)
  677. if route_path.endswith("/"):
  678. redirect_scope["path"] = redirect_scope["path"].rstrip("/")
  679. else:
  680. redirect_scope["path"] = redirect_scope["path"] + "/"
  681. for route in self.routes:
  682. match, child_scope = route.matches(redirect_scope)
  683. if match != Match.NONE:
  684. redirect_url = URL(scope=redirect_scope)
  685. response = RedirectResponse(url=str(redirect_url))
  686. await response(scope, receive, send)
  687. return
  688. await self.default(scope, receive, send)
  689. def __eq__(self, other: typing.Any) -> bool:
  690. return isinstance(other, Router) and self.routes == other.routes
  691. def mount(
  692. self, path: str, app: ASGIApp, name: typing.Optional[str] = None
  693. ) -> None: # pragma: nocover
  694. route = Mount(path, app=app, name=name)
  695. self.routes.append(route)
  696. def host(
  697. self, host: str, app: ASGIApp, name: typing.Optional[str] = None
  698. ) -> None: # pragma: no cover
  699. route = Host(host, app=app, name=name)
  700. self.routes.append(route)
  701. def add_route(
  702. self,
  703. path: str,
  704. endpoint: typing.Callable[
  705. [Request], typing.Union[typing.Awaitable[Response], Response]
  706. ],
  707. methods: typing.Optional[typing.List[str]] = None,
  708. name: typing.Optional[str] = None,
  709. include_in_schema: bool = True,
  710. ) -> None: # pragma: nocover
  711. route = Route(
  712. path,
  713. endpoint=endpoint,
  714. methods=methods,
  715. name=name,
  716. include_in_schema=include_in_schema,
  717. )
  718. self.routes.append(route)
  719. def add_websocket_route(
  720. self,
  721. path: str,
  722. endpoint: typing.Callable[[WebSocket], typing.Awaitable[None]],
  723. name: typing.Optional[str] = None,
  724. ) -> None: # pragma: no cover
  725. route = WebSocketRoute(path, endpoint=endpoint, name=name)
  726. self.routes.append(route)
  727. def route(
  728. self,
  729. path: str,
  730. methods: typing.Optional[typing.List[str]] = None,
  731. name: typing.Optional[str] = None,
  732. include_in_schema: bool = True,
  733. ) -> typing.Callable: # type: ignore[type-arg]
  734. """
  735. We no longer document this decorator style API, and its usage is discouraged.
  736. Instead you should use the following approach:
  737. >>> routes = [Route(path, endpoint=...), ...]
  738. >>> app = Starlette(routes=routes)
  739. """
  740. warnings.warn(
  741. "The `route` decorator is deprecated, and will be removed in version 1.0.0."
  742. "Refer to https://www.starlette.io/routing/#http-routing for the recommended approach.", # noqa: E501
  743. DeprecationWarning,
  744. )
  745. def decorator(func: typing.Callable) -> typing.Callable: # type: ignore[type-arg] # noqa: E501
  746. self.add_route(
  747. path,
  748. func,
  749. methods=methods,
  750. name=name,
  751. include_in_schema=include_in_schema,
  752. )
  753. return func
  754. return decorator
  755. def websocket_route(
  756. self, path: str, name: typing.Optional[str] = None
  757. ) -> typing.Callable: # type: ignore[type-arg]
  758. """
  759. We no longer document this decorator style API, and its usage is discouraged.
  760. Instead you should use the following approach:
  761. >>> routes = [WebSocketRoute(path, endpoint=...), ...]
  762. >>> app = Starlette(routes=routes)
  763. """
  764. warnings.warn(
  765. "The `websocket_route` decorator is deprecated, and will be removed in version 1.0.0. Refer to " # noqa: E501
  766. "https://www.starlette.io/routing/#websocket-routing for the recommended approach.", # noqa: E501
  767. DeprecationWarning,
  768. )
  769. def decorator(func: typing.Callable) -> typing.Callable: # type: ignore[type-arg] # noqa: E501
  770. self.add_websocket_route(path, func, name=name)
  771. return func
  772. return decorator
  773. def add_event_handler(
  774. self, event_type: str, func: typing.Callable[[], typing.Any]
  775. ) -> None: # pragma: no cover
  776. assert event_type in ("startup", "shutdown")
  777. if event_type == "startup":
  778. self.on_startup.append(func)
  779. else:
  780. self.on_shutdown.append(func)
  781. def on_event(self, event_type: str) -> typing.Callable: # type: ignore[type-arg]
  782. warnings.warn(
  783. "The `on_event` decorator is deprecated, and will be removed in version 1.0.0. " # noqa: E501
  784. "Refer to https://www.starlette.io/lifespan/ for recommended approach.",
  785. DeprecationWarning,
  786. )
  787. def decorator(func: typing.Callable) -> typing.Callable: # type: ignore[type-arg] # noqa: E501
  788. self.add_event_handler(event_type, func)
  789. return func
  790. return decorator