web_urldispatcher.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303
  1. import abc
  2. import asyncio
  3. import base64
  4. import functools
  5. import hashlib
  6. import html
  7. import inspect
  8. import keyword
  9. import os
  10. import re
  11. import sys
  12. import warnings
  13. from functools import wraps
  14. from pathlib import Path
  15. from types import MappingProxyType
  16. from typing import (
  17. TYPE_CHECKING,
  18. Any,
  19. Awaitable,
  20. Callable,
  21. Container,
  22. Dict,
  23. Final,
  24. Generator,
  25. Iterable,
  26. Iterator,
  27. List,
  28. Mapping,
  29. NoReturn,
  30. Optional,
  31. Pattern,
  32. Set,
  33. Sized,
  34. Tuple,
  35. Type,
  36. TypedDict,
  37. Union,
  38. cast,
  39. )
  40. from yarl import URL, __version__ as yarl_version
  41. from . import hdrs
  42. from .abc import AbstractMatchInfo, AbstractRouter, AbstractView
  43. from .helpers import DEBUG
  44. from .http import HttpVersion11
  45. from .typedefs import Handler, PathLike
  46. from .web_exceptions import (
  47. HTTPException,
  48. HTTPExpectationFailed,
  49. HTTPForbidden,
  50. HTTPMethodNotAllowed,
  51. HTTPNotFound,
  52. )
  53. from .web_fileresponse import FileResponse
  54. from .web_request import Request
  55. from .web_response import Response, StreamResponse
  56. from .web_routedef import AbstractRouteDef
  57. __all__ = (
  58. "UrlDispatcher",
  59. "UrlMappingMatchInfo",
  60. "AbstractResource",
  61. "Resource",
  62. "PlainResource",
  63. "DynamicResource",
  64. "AbstractRoute",
  65. "ResourceRoute",
  66. "StaticResource",
  67. "View",
  68. )
  69. if TYPE_CHECKING:
  70. from .web_app import Application
  71. BaseDict = Dict[str, str]
  72. else:
  73. BaseDict = dict
  74. CIRCULAR_SYMLINK_ERROR = (
  75. (OSError,)
  76. if sys.version_info < (3, 10) and sys.platform.startswith("win32")
  77. else (RuntimeError,) if sys.version_info < (3, 13) else ()
  78. )
  79. YARL_VERSION: Final[Tuple[int, ...]] = tuple(map(int, yarl_version.split(".")[:2]))
  80. HTTP_METHOD_RE: Final[Pattern[str]] = re.compile(
  81. r"^[0-9A-Za-z!#\$%&'\*\+\-\.\^_`\|~]+$"
  82. )
  83. ROUTE_RE: Final[Pattern[str]] = re.compile(
  84. r"(\{[_a-zA-Z][^{}]*(?:\{[^{}]*\}[^{}]*)*\})"
  85. )
  86. PATH_SEP: Final[str] = re.escape("/")
  87. _ExpectHandler = Callable[[Request], Awaitable[Optional[StreamResponse]]]
  88. _Resolve = Tuple[Optional["UrlMappingMatchInfo"], Set[str]]
  89. html_escape = functools.partial(html.escape, quote=True)
  90. class _InfoDict(TypedDict, total=False):
  91. path: str
  92. formatter: str
  93. pattern: Pattern[str]
  94. directory: Path
  95. prefix: str
  96. routes: Mapping[str, "AbstractRoute"]
  97. app: "Application"
  98. domain: str
  99. rule: "AbstractRuleMatching"
  100. http_exception: HTTPException
  101. class AbstractResource(Sized, Iterable["AbstractRoute"]):
  102. def __init__(self, *, name: Optional[str] = None) -> None:
  103. self._name = name
  104. @property
  105. def name(self) -> Optional[str]:
  106. return self._name
  107. @property
  108. @abc.abstractmethod
  109. def canonical(self) -> str:
  110. """Exposes the resource's canonical path.
  111. For example '/foo/bar/{name}'
  112. """
  113. @abc.abstractmethod # pragma: no branch
  114. def url_for(self, **kwargs: str) -> URL:
  115. """Construct url for resource with additional params."""
  116. @abc.abstractmethod # pragma: no branch
  117. async def resolve(self, request: Request) -> _Resolve:
  118. """Resolve resource.
  119. Return (UrlMappingMatchInfo, allowed_methods) pair.
  120. """
  121. @abc.abstractmethod
  122. def add_prefix(self, prefix: str) -> None:
  123. """Add a prefix to processed URLs.
  124. Required for subapplications support.
  125. """
  126. @abc.abstractmethod
  127. def get_info(self) -> _InfoDict:
  128. """Return a dict with additional info useful for introspection"""
  129. def freeze(self) -> None:
  130. pass
  131. @abc.abstractmethod
  132. def raw_match(self, path: str) -> bool:
  133. """Perform a raw match against path"""
  134. class AbstractRoute(abc.ABC):
  135. def __init__(
  136. self,
  137. method: str,
  138. handler: Union[Handler, Type[AbstractView]],
  139. *,
  140. expect_handler: Optional[_ExpectHandler] = None,
  141. resource: Optional[AbstractResource] = None,
  142. ) -> None:
  143. if expect_handler is None:
  144. expect_handler = _default_expect_handler
  145. assert inspect.iscoroutinefunction(expect_handler) or (
  146. sys.version_info < (3, 14) and asyncio.iscoroutinefunction(expect_handler)
  147. ), f"Coroutine is expected, got {expect_handler!r}"
  148. method = method.upper()
  149. if not HTTP_METHOD_RE.match(method):
  150. raise ValueError(f"{method} is not allowed HTTP method")
  151. assert callable(handler), handler
  152. if inspect.iscoroutinefunction(handler) or (
  153. sys.version_info < (3, 14) and asyncio.iscoroutinefunction(handler)
  154. ):
  155. pass
  156. elif inspect.isgeneratorfunction(handler):
  157. warnings.warn(
  158. "Bare generators are deprecated, use @coroutine wrapper",
  159. DeprecationWarning,
  160. )
  161. elif isinstance(handler, type) and issubclass(handler, AbstractView):
  162. pass
  163. else:
  164. warnings.warn(
  165. "Bare functions are deprecated, use async ones", DeprecationWarning
  166. )
  167. @wraps(handler)
  168. async def handler_wrapper(request: Request) -> StreamResponse:
  169. result = old_handler(request) # type: ignore[call-arg]
  170. if asyncio.iscoroutine(result):
  171. result = await result
  172. assert isinstance(result, StreamResponse)
  173. return result
  174. old_handler = handler
  175. handler = handler_wrapper
  176. self._method = method
  177. self._handler = handler
  178. self._expect_handler = expect_handler
  179. self._resource = resource
  180. @property
  181. def method(self) -> str:
  182. return self._method
  183. @property
  184. def handler(self) -> Handler:
  185. return self._handler
  186. @property
  187. @abc.abstractmethod
  188. def name(self) -> Optional[str]:
  189. """Optional route's name, always equals to resource's name."""
  190. @property
  191. def resource(self) -> Optional[AbstractResource]:
  192. return self._resource
  193. @abc.abstractmethod
  194. def get_info(self) -> _InfoDict:
  195. """Return a dict with additional info useful for introspection"""
  196. @abc.abstractmethod # pragma: no branch
  197. def url_for(self, *args: str, **kwargs: str) -> URL:
  198. """Construct url for route with additional params."""
  199. async def handle_expect_header(self, request: Request) -> Optional[StreamResponse]:
  200. return await self._expect_handler(request)
  201. class UrlMappingMatchInfo(BaseDict, AbstractMatchInfo):
  202. __slots__ = ("_route", "_apps", "_current_app", "_frozen")
  203. def __init__(self, match_dict: Dict[str, str], route: AbstractRoute) -> None:
  204. super().__init__(match_dict)
  205. self._route = route
  206. self._apps: List[Application] = []
  207. self._current_app: Optional[Application] = None
  208. self._frozen = False
  209. @property
  210. def handler(self) -> Handler:
  211. return self._route.handler
  212. @property
  213. def route(self) -> AbstractRoute:
  214. return self._route
  215. @property
  216. def expect_handler(self) -> _ExpectHandler:
  217. return self._route.handle_expect_header
  218. @property
  219. def http_exception(self) -> Optional[HTTPException]:
  220. return None
  221. def get_info(self) -> _InfoDict: # type: ignore[override]
  222. return self._route.get_info()
  223. @property
  224. def apps(self) -> Tuple["Application", ...]:
  225. return tuple(self._apps)
  226. def add_app(self, app: "Application") -> None:
  227. if self._frozen:
  228. raise RuntimeError("Cannot change apps stack after .freeze() call")
  229. if self._current_app is None:
  230. self._current_app = app
  231. self._apps.insert(0, app)
  232. @property
  233. def current_app(self) -> "Application":
  234. app = self._current_app
  235. assert app is not None
  236. return app
  237. @current_app.setter
  238. def current_app(self, app: "Application") -> None:
  239. if DEBUG: # pragma: no cover
  240. if app not in self._apps:
  241. raise RuntimeError(
  242. "Expected one of the following apps {!r}, got {!r}".format(
  243. self._apps, app
  244. )
  245. )
  246. self._current_app = app
  247. def freeze(self) -> None:
  248. self._frozen = True
  249. def __repr__(self) -> str:
  250. return f"<MatchInfo {super().__repr__()}: {self._route}>"
  251. class MatchInfoError(UrlMappingMatchInfo):
  252. __slots__ = ("_exception",)
  253. def __init__(self, http_exception: HTTPException) -> None:
  254. self._exception = http_exception
  255. super().__init__({}, SystemRoute(self._exception))
  256. @property
  257. def http_exception(self) -> HTTPException:
  258. return self._exception
  259. def __repr__(self) -> str:
  260. return "<MatchInfoError {}: {}>".format(
  261. self._exception.status, self._exception.reason
  262. )
  263. async def _default_expect_handler(request: Request) -> None:
  264. """Default handler for Expect header.
  265. Just send "100 Continue" to client.
  266. raise HTTPExpectationFailed if value of header is not "100-continue"
  267. """
  268. expect = request.headers.get(hdrs.EXPECT, "")
  269. if request.version == HttpVersion11:
  270. if expect.lower() == "100-continue":
  271. await request.writer.write(b"HTTP/1.1 100 Continue\r\n\r\n")
  272. # Reset output_size as we haven't started the main body yet.
  273. request.writer.output_size = 0
  274. else:
  275. raise HTTPExpectationFailed(text="Unknown Expect: %s" % expect)
  276. class Resource(AbstractResource):
  277. def __init__(self, *, name: Optional[str] = None) -> None:
  278. super().__init__(name=name)
  279. self._routes: Dict[str, ResourceRoute] = {}
  280. self._any_route: Optional[ResourceRoute] = None
  281. self._allowed_methods: Set[str] = set()
  282. def add_route(
  283. self,
  284. method: str,
  285. handler: Union[Type[AbstractView], Handler],
  286. *,
  287. expect_handler: Optional[_ExpectHandler] = None,
  288. ) -> "ResourceRoute":
  289. if route := self._routes.get(method, self._any_route):
  290. raise RuntimeError(
  291. "Added route will never be executed, "
  292. f"method {route.method} is already "
  293. "registered"
  294. )
  295. route_obj = ResourceRoute(method, handler, self, expect_handler=expect_handler)
  296. self.register_route(route_obj)
  297. return route_obj
  298. def register_route(self, route: "ResourceRoute") -> None:
  299. assert isinstance(
  300. route, ResourceRoute
  301. ), f"Instance of Route class is required, got {route!r}"
  302. if route.method == hdrs.METH_ANY:
  303. self._any_route = route
  304. self._allowed_methods.add(route.method)
  305. self._routes[route.method] = route
  306. async def resolve(self, request: Request) -> _Resolve:
  307. if (match_dict := self._match(request.rel_url.path_safe)) is None:
  308. return None, set()
  309. if route := self._routes.get(request.method, self._any_route):
  310. return UrlMappingMatchInfo(match_dict, route), self._allowed_methods
  311. return None, self._allowed_methods
  312. @abc.abstractmethod
  313. def _match(self, path: str) -> Optional[Dict[str, str]]:
  314. pass # pragma: no cover
  315. def __len__(self) -> int:
  316. return len(self._routes)
  317. def __iter__(self) -> Iterator["ResourceRoute"]:
  318. return iter(self._routes.values())
  319. # TODO: implement all abstract methods
  320. class PlainResource(Resource):
  321. def __init__(self, path: str, *, name: Optional[str] = None) -> None:
  322. super().__init__(name=name)
  323. assert not path or path.startswith("/")
  324. self._path = path
  325. @property
  326. def canonical(self) -> str:
  327. return self._path
  328. def freeze(self) -> None:
  329. if not self._path:
  330. self._path = "/"
  331. def add_prefix(self, prefix: str) -> None:
  332. assert prefix.startswith("/")
  333. assert not prefix.endswith("/")
  334. assert len(prefix) > 1
  335. self._path = prefix + self._path
  336. def _match(self, path: str) -> Optional[Dict[str, str]]:
  337. # string comparison is about 10 times faster than regexp matching
  338. if self._path == path:
  339. return {}
  340. return None
  341. def raw_match(self, path: str) -> bool:
  342. return self._path == path
  343. def get_info(self) -> _InfoDict:
  344. return {"path": self._path}
  345. def url_for(self) -> URL: # type: ignore[override]
  346. return URL.build(path=self._path, encoded=True)
  347. def __repr__(self) -> str:
  348. name = "'" + self.name + "' " if self.name is not None else ""
  349. return f"<PlainResource {name} {self._path}>"
  350. class DynamicResource(Resource):
  351. DYN = re.compile(r"\{(?P<var>[_a-zA-Z][_a-zA-Z0-9]*)\}")
  352. DYN_WITH_RE = re.compile(r"\{(?P<var>[_a-zA-Z][_a-zA-Z0-9]*):(?P<re>.+)\}")
  353. GOOD = r"[^{}/]+"
  354. def __init__(self, path: str, *, name: Optional[str] = None) -> None:
  355. super().__init__(name=name)
  356. self._orig_path = path
  357. pattern = ""
  358. formatter = ""
  359. for part in ROUTE_RE.split(path):
  360. match = self.DYN.fullmatch(part)
  361. if match:
  362. pattern += "(?P<{}>{})".format(match.group("var"), self.GOOD)
  363. formatter += "{" + match.group("var") + "}"
  364. continue
  365. match = self.DYN_WITH_RE.fullmatch(part)
  366. if match:
  367. pattern += "(?P<{var}>{re})".format(**match.groupdict())
  368. formatter += "{" + match.group("var") + "}"
  369. continue
  370. if "{" in part or "}" in part:
  371. raise ValueError(f"Invalid path '{path}'['{part}']")
  372. part = _requote_path(part)
  373. formatter += part
  374. pattern += re.escape(part)
  375. try:
  376. compiled = re.compile(pattern)
  377. except re.error as exc:
  378. raise ValueError(f"Bad pattern '{pattern}': {exc}") from None
  379. assert compiled.pattern.startswith(PATH_SEP)
  380. assert formatter.startswith("/")
  381. self._pattern = compiled
  382. self._formatter = formatter
  383. @property
  384. def canonical(self) -> str:
  385. return self._formatter
  386. def add_prefix(self, prefix: str) -> None:
  387. assert prefix.startswith("/")
  388. assert not prefix.endswith("/")
  389. assert len(prefix) > 1
  390. self._pattern = re.compile(re.escape(prefix) + self._pattern.pattern)
  391. self._formatter = prefix + self._formatter
  392. def _match(self, path: str) -> Optional[Dict[str, str]]:
  393. match = self._pattern.fullmatch(path)
  394. if match is None:
  395. return None
  396. return {
  397. key: _unquote_path_safe(value) for key, value in match.groupdict().items()
  398. }
  399. def raw_match(self, path: str) -> bool:
  400. return self._orig_path == path
  401. def get_info(self) -> _InfoDict:
  402. return {"formatter": self._formatter, "pattern": self._pattern}
  403. def url_for(self, **parts: str) -> URL:
  404. url = self._formatter.format_map({k: _quote_path(v) for k, v in parts.items()})
  405. return URL.build(path=url, encoded=True)
  406. def __repr__(self) -> str:
  407. name = "'" + self.name + "' " if self.name is not None else ""
  408. return "<DynamicResource {name} {formatter}>".format(
  409. name=name, formatter=self._formatter
  410. )
  411. class PrefixResource(AbstractResource):
  412. def __init__(self, prefix: str, *, name: Optional[str] = None) -> None:
  413. assert not prefix or prefix.startswith("/"), prefix
  414. assert prefix in ("", "/") or not prefix.endswith("/"), prefix
  415. super().__init__(name=name)
  416. self._prefix = _requote_path(prefix)
  417. self._prefix2 = self._prefix + "/"
  418. @property
  419. def canonical(self) -> str:
  420. return self._prefix
  421. def add_prefix(self, prefix: str) -> None:
  422. assert prefix.startswith("/")
  423. assert not prefix.endswith("/")
  424. assert len(prefix) > 1
  425. self._prefix = prefix + self._prefix
  426. self._prefix2 = self._prefix + "/"
  427. def raw_match(self, prefix: str) -> bool:
  428. return False
  429. # TODO: impl missing abstract methods
  430. class StaticResource(PrefixResource):
  431. VERSION_KEY = "v"
  432. def __init__(
  433. self,
  434. prefix: str,
  435. directory: PathLike,
  436. *,
  437. name: Optional[str] = None,
  438. expect_handler: Optional[_ExpectHandler] = None,
  439. chunk_size: int = 256 * 1024,
  440. show_index: bool = False,
  441. follow_symlinks: bool = False,
  442. append_version: bool = False,
  443. ) -> None:
  444. super().__init__(prefix, name=name)
  445. try:
  446. directory = Path(directory).expanduser().resolve(strict=True)
  447. except FileNotFoundError as error:
  448. raise ValueError(f"'{directory}' does not exist") from error
  449. if not directory.is_dir():
  450. raise ValueError(f"'{directory}' is not a directory")
  451. self._directory = directory
  452. self._show_index = show_index
  453. self._chunk_size = chunk_size
  454. self._follow_symlinks = follow_symlinks
  455. self._expect_handler = expect_handler
  456. self._append_version = append_version
  457. self._routes = {
  458. "GET": ResourceRoute(
  459. "GET", self._handle, self, expect_handler=expect_handler
  460. ),
  461. "HEAD": ResourceRoute(
  462. "HEAD", self._handle, self, expect_handler=expect_handler
  463. ),
  464. }
  465. self._allowed_methods = set(self._routes)
  466. def url_for( # type: ignore[override]
  467. self,
  468. *,
  469. filename: PathLike,
  470. append_version: Optional[bool] = None,
  471. ) -> URL:
  472. if append_version is None:
  473. append_version = self._append_version
  474. filename = str(filename).lstrip("/")
  475. url = URL.build(path=self._prefix, encoded=True)
  476. # filename is not encoded
  477. if YARL_VERSION < (1, 6):
  478. url = url / filename.replace("%", "%25")
  479. else:
  480. url = url / filename
  481. if append_version:
  482. unresolved_path = self._directory.joinpath(filename)
  483. try:
  484. if self._follow_symlinks:
  485. normalized_path = Path(os.path.normpath(unresolved_path))
  486. normalized_path.relative_to(self._directory)
  487. filepath = normalized_path.resolve()
  488. else:
  489. filepath = unresolved_path.resolve()
  490. filepath.relative_to(self._directory)
  491. except (ValueError, FileNotFoundError):
  492. # ValueError for case when path point to symlink
  493. # with follow_symlinks is False
  494. return url # relatively safe
  495. if filepath.is_file():
  496. # TODO cache file content
  497. # with file watcher for cache invalidation
  498. with filepath.open("rb") as f:
  499. file_bytes = f.read()
  500. h = self._get_file_hash(file_bytes)
  501. url = url.with_query({self.VERSION_KEY: h})
  502. return url
  503. return url
  504. @staticmethod
  505. def _get_file_hash(byte_array: bytes) -> str:
  506. m = hashlib.sha256() # todo sha256 can be configurable param
  507. m.update(byte_array)
  508. b64 = base64.urlsafe_b64encode(m.digest())
  509. return b64.decode("ascii")
  510. def get_info(self) -> _InfoDict:
  511. return {
  512. "directory": self._directory,
  513. "prefix": self._prefix,
  514. "routes": self._routes,
  515. }
  516. def set_options_route(self, handler: Handler) -> None:
  517. if "OPTIONS" in self._routes:
  518. raise RuntimeError("OPTIONS route was set already")
  519. self._routes["OPTIONS"] = ResourceRoute(
  520. "OPTIONS", handler, self, expect_handler=self._expect_handler
  521. )
  522. self._allowed_methods.add("OPTIONS")
  523. async def resolve(self, request: Request) -> _Resolve:
  524. path = request.rel_url.path_safe
  525. method = request.method
  526. if not path.startswith(self._prefix2) and path != self._prefix:
  527. return None, set()
  528. allowed_methods = self._allowed_methods
  529. if method not in allowed_methods:
  530. return None, allowed_methods
  531. match_dict = {"filename": _unquote_path_safe(path[len(self._prefix) + 1 :])}
  532. return (UrlMappingMatchInfo(match_dict, self._routes[method]), allowed_methods)
  533. def __len__(self) -> int:
  534. return len(self._routes)
  535. def __iter__(self) -> Iterator[AbstractRoute]:
  536. return iter(self._routes.values())
  537. async def _handle(self, request: Request) -> StreamResponse:
  538. rel_url = request.match_info["filename"]
  539. filename = Path(rel_url)
  540. if filename.anchor:
  541. # rel_url is an absolute name like
  542. # /static/\\machine_name\c$ or /static/D:\path
  543. # where the static dir is totally different
  544. raise HTTPForbidden()
  545. unresolved_path = self._directory.joinpath(filename)
  546. loop = asyncio.get_running_loop()
  547. return await loop.run_in_executor(
  548. None, self._resolve_path_to_response, unresolved_path
  549. )
  550. def _resolve_path_to_response(self, unresolved_path: Path) -> StreamResponse:
  551. """Take the unresolved path and query the file system to form a response."""
  552. # Check for access outside the root directory. For follow symlinks, URI
  553. # cannot traverse out, but symlinks can. Otherwise, no access outside
  554. # root is permitted.
  555. try:
  556. if self._follow_symlinks:
  557. normalized_path = Path(os.path.normpath(unresolved_path))
  558. normalized_path.relative_to(self._directory)
  559. file_path = normalized_path.resolve()
  560. else:
  561. file_path = unresolved_path.resolve()
  562. file_path.relative_to(self._directory)
  563. except (ValueError, *CIRCULAR_SYMLINK_ERROR) as error:
  564. # ValueError is raised for the relative check. Circular symlinks
  565. # raise here on resolving for python < 3.13.
  566. raise HTTPNotFound() from error
  567. # if path is a directory, return the contents if permitted. Note the
  568. # directory check will raise if a segment is not readable.
  569. try:
  570. if file_path.is_dir():
  571. if self._show_index:
  572. return Response(
  573. text=self._directory_as_html(file_path),
  574. content_type="text/html",
  575. )
  576. else:
  577. raise HTTPForbidden()
  578. except PermissionError as error:
  579. raise HTTPForbidden() from error
  580. # Return the file response, which handles all other checks.
  581. return FileResponse(file_path, chunk_size=self._chunk_size)
  582. def _directory_as_html(self, dir_path: Path) -> str:
  583. """returns directory's index as html."""
  584. assert dir_path.is_dir()
  585. relative_path_to_dir = dir_path.relative_to(self._directory).as_posix()
  586. index_of = f"Index of /{html_escape(relative_path_to_dir)}"
  587. h1 = f"<h1>{index_of}</h1>"
  588. index_list = []
  589. dir_index = dir_path.iterdir()
  590. for _file in sorted(dir_index):
  591. # show file url as relative to static path
  592. rel_path = _file.relative_to(self._directory).as_posix()
  593. quoted_file_url = _quote_path(f"{self._prefix}/{rel_path}")
  594. # if file is a directory, add '/' to the end of the name
  595. if _file.is_dir():
  596. file_name = f"{_file.name}/"
  597. else:
  598. file_name = _file.name
  599. index_list.append(
  600. f'<li><a href="{quoted_file_url}">{html_escape(file_name)}</a></li>'
  601. )
  602. ul = "<ul>\n{}\n</ul>".format("\n".join(index_list))
  603. body = f"<body>\n{h1}\n{ul}\n</body>"
  604. head_str = f"<head>\n<title>{index_of}</title>\n</head>"
  605. html = f"<html>\n{head_str}\n{body}\n</html>"
  606. return html
  607. def __repr__(self) -> str:
  608. name = "'" + self.name + "'" if self.name is not None else ""
  609. return "<StaticResource {name} {path} -> {directory!r}>".format(
  610. name=name, path=self._prefix, directory=self._directory
  611. )
  612. class PrefixedSubAppResource(PrefixResource):
  613. def __init__(self, prefix: str, app: "Application") -> None:
  614. super().__init__(prefix)
  615. self._app = app
  616. self._add_prefix_to_resources(prefix)
  617. def add_prefix(self, prefix: str) -> None:
  618. super().add_prefix(prefix)
  619. self._add_prefix_to_resources(prefix)
  620. def _add_prefix_to_resources(self, prefix: str) -> None:
  621. router = self._app.router
  622. for resource in router.resources():
  623. # Since the canonical path of a resource is about
  624. # to change, we need to unindex it and then reindex
  625. router.unindex_resource(resource)
  626. resource.add_prefix(prefix)
  627. router.index_resource(resource)
  628. def url_for(self, *args: str, **kwargs: str) -> URL:
  629. raise RuntimeError(".url_for() is not supported by sub-application root")
  630. def get_info(self) -> _InfoDict:
  631. return {"app": self._app, "prefix": self._prefix}
  632. async def resolve(self, request: Request) -> _Resolve:
  633. match_info = await self._app.router.resolve(request)
  634. match_info.add_app(self._app)
  635. if isinstance(match_info.http_exception, HTTPMethodNotAllowed):
  636. methods = match_info.http_exception.allowed_methods
  637. else:
  638. methods = set()
  639. return match_info, methods
  640. def __len__(self) -> int:
  641. return len(self._app.router.routes())
  642. def __iter__(self) -> Iterator[AbstractRoute]:
  643. return iter(self._app.router.routes())
  644. def __repr__(self) -> str:
  645. return "<PrefixedSubAppResource {prefix} -> {app!r}>".format(
  646. prefix=self._prefix, app=self._app
  647. )
  648. class AbstractRuleMatching(abc.ABC):
  649. @abc.abstractmethod # pragma: no branch
  650. async def match(self, request: Request) -> bool:
  651. """Return bool if the request satisfies the criteria"""
  652. @abc.abstractmethod # pragma: no branch
  653. def get_info(self) -> _InfoDict:
  654. """Return a dict with additional info useful for introspection"""
  655. @property
  656. @abc.abstractmethod # pragma: no branch
  657. def canonical(self) -> str:
  658. """Return a str"""
  659. class Domain(AbstractRuleMatching):
  660. re_part = re.compile(r"(?!-)[a-z\d-]{1,63}(?<!-)")
  661. def __init__(self, domain: str) -> None:
  662. super().__init__()
  663. self._domain = self.validation(domain)
  664. @property
  665. def canonical(self) -> str:
  666. return self._domain
  667. def validation(self, domain: str) -> str:
  668. if not isinstance(domain, str):
  669. raise TypeError("Domain must be str")
  670. domain = domain.rstrip(".").lower()
  671. if not domain:
  672. raise ValueError("Domain cannot be empty")
  673. elif "://" in domain:
  674. raise ValueError("Scheme not supported")
  675. url = URL("http://" + domain)
  676. assert url.raw_host is not None
  677. if not all(self.re_part.fullmatch(x) for x in url.raw_host.split(".")):
  678. raise ValueError("Domain not valid")
  679. if url.port == 80:
  680. return url.raw_host
  681. return f"{url.raw_host}:{url.port}"
  682. async def match(self, request: Request) -> bool:
  683. host = request.headers.get(hdrs.HOST)
  684. if not host:
  685. return False
  686. return self.match_domain(host)
  687. def match_domain(self, host: str) -> bool:
  688. return host.lower() == self._domain
  689. def get_info(self) -> _InfoDict:
  690. return {"domain": self._domain}
  691. class MaskDomain(Domain):
  692. re_part = re.compile(r"(?!-)[a-z\d\*-]{1,63}(?<!-)")
  693. def __init__(self, domain: str) -> None:
  694. super().__init__(domain)
  695. mask = self._domain.replace(".", r"\.").replace("*", ".*")
  696. self._mask = re.compile(mask)
  697. @property
  698. def canonical(self) -> str:
  699. return self._mask.pattern
  700. def match_domain(self, host: str) -> bool:
  701. return self._mask.fullmatch(host) is not None
  702. class MatchedSubAppResource(PrefixedSubAppResource):
  703. def __init__(self, rule: AbstractRuleMatching, app: "Application") -> None:
  704. AbstractResource.__init__(self)
  705. self._prefix = ""
  706. self._app = app
  707. self._rule = rule
  708. @property
  709. def canonical(self) -> str:
  710. return self._rule.canonical
  711. def get_info(self) -> _InfoDict:
  712. return {"app": self._app, "rule": self._rule}
  713. async def resolve(self, request: Request) -> _Resolve:
  714. if not await self._rule.match(request):
  715. return None, set()
  716. match_info = await self._app.router.resolve(request)
  717. match_info.add_app(self._app)
  718. if isinstance(match_info.http_exception, HTTPMethodNotAllowed):
  719. methods = match_info.http_exception.allowed_methods
  720. else:
  721. methods = set()
  722. return match_info, methods
  723. def __repr__(self) -> str:
  724. return f"<MatchedSubAppResource -> {self._app!r}>"
  725. class ResourceRoute(AbstractRoute):
  726. """A route with resource"""
  727. def __init__(
  728. self,
  729. method: str,
  730. handler: Union[Handler, Type[AbstractView]],
  731. resource: AbstractResource,
  732. *,
  733. expect_handler: Optional[_ExpectHandler] = None,
  734. ) -> None:
  735. super().__init__(
  736. method, handler, expect_handler=expect_handler, resource=resource
  737. )
  738. def __repr__(self) -> str:
  739. return "<ResourceRoute [{method}] {resource} -> {handler!r}".format(
  740. method=self.method, resource=self._resource, handler=self.handler
  741. )
  742. @property
  743. def name(self) -> Optional[str]:
  744. if self._resource is None:
  745. return None
  746. return self._resource.name
  747. def url_for(self, *args: str, **kwargs: str) -> URL:
  748. """Construct url for route with additional params."""
  749. assert self._resource is not None
  750. return self._resource.url_for(*args, **kwargs)
  751. def get_info(self) -> _InfoDict:
  752. assert self._resource is not None
  753. return self._resource.get_info()
  754. class SystemRoute(AbstractRoute):
  755. def __init__(self, http_exception: HTTPException) -> None:
  756. super().__init__(hdrs.METH_ANY, self._handle)
  757. self._http_exception = http_exception
  758. def url_for(self, *args: str, **kwargs: str) -> URL:
  759. raise RuntimeError(".url_for() is not allowed for SystemRoute")
  760. @property
  761. def name(self) -> Optional[str]:
  762. return None
  763. def get_info(self) -> _InfoDict:
  764. return {"http_exception": self._http_exception}
  765. async def _handle(self, request: Request) -> StreamResponse:
  766. raise self._http_exception
  767. @property
  768. def status(self) -> int:
  769. return self._http_exception.status
  770. @property
  771. def reason(self) -> str:
  772. return self._http_exception.reason
  773. def __repr__(self) -> str:
  774. return "<SystemRoute {self.status}: {self.reason}>".format(self=self)
  775. class View(AbstractView):
  776. async def _iter(self) -> StreamResponse:
  777. if self.request.method not in hdrs.METH_ALL:
  778. self._raise_allowed_methods()
  779. method: Optional[Callable[[], Awaitable[StreamResponse]]]
  780. method = getattr(self, self.request.method.lower(), None)
  781. if method is None:
  782. self._raise_allowed_methods()
  783. ret = await method()
  784. assert isinstance(ret, StreamResponse)
  785. return ret
  786. def __await__(self) -> Generator[Any, None, StreamResponse]:
  787. return self._iter().__await__()
  788. def _raise_allowed_methods(self) -> NoReturn:
  789. allowed_methods = {m for m in hdrs.METH_ALL if hasattr(self, m.lower())}
  790. raise HTTPMethodNotAllowed(self.request.method, allowed_methods)
  791. class ResourcesView(Sized, Iterable[AbstractResource], Container[AbstractResource]):
  792. def __init__(self, resources: List[AbstractResource]) -> None:
  793. self._resources = resources
  794. def __len__(self) -> int:
  795. return len(self._resources)
  796. def __iter__(self) -> Iterator[AbstractResource]:
  797. yield from self._resources
  798. def __contains__(self, resource: object) -> bool:
  799. return resource in self._resources
  800. class RoutesView(Sized, Iterable[AbstractRoute], Container[AbstractRoute]):
  801. def __init__(self, resources: List[AbstractResource]):
  802. self._routes: List[AbstractRoute] = []
  803. for resource in resources:
  804. for route in resource:
  805. self._routes.append(route)
  806. def __len__(self) -> int:
  807. return len(self._routes)
  808. def __iter__(self) -> Iterator[AbstractRoute]:
  809. yield from self._routes
  810. def __contains__(self, route: object) -> bool:
  811. return route in self._routes
  812. class UrlDispatcher(AbstractRouter, Mapping[str, AbstractResource]):
  813. NAME_SPLIT_RE = re.compile(r"[.:-]")
  814. def __init__(self) -> None:
  815. super().__init__()
  816. self._resources: List[AbstractResource] = []
  817. self._named_resources: Dict[str, AbstractResource] = {}
  818. self._resource_index: dict[str, list[AbstractResource]] = {}
  819. self._matched_sub_app_resources: List[MatchedSubAppResource] = []
  820. async def resolve(self, request: Request) -> UrlMappingMatchInfo:
  821. resource_index = self._resource_index
  822. allowed_methods: Set[str] = set()
  823. # Walk the url parts looking for candidates. We walk the url backwards
  824. # to ensure the most explicit match is found first. If there are multiple
  825. # candidates for a given url part because there are multiple resources
  826. # registered for the same canonical path, we resolve them in a linear
  827. # fashion to ensure registration order is respected.
  828. url_part = request.rel_url.path_safe
  829. while url_part:
  830. for candidate in resource_index.get(url_part, ()):
  831. match_dict, allowed = await candidate.resolve(request)
  832. if match_dict is not None:
  833. return match_dict
  834. else:
  835. allowed_methods |= allowed
  836. if url_part == "/":
  837. break
  838. url_part = url_part.rpartition("/")[0] or "/"
  839. #
  840. # We didn't find any candidates, so we'll try the matched sub-app
  841. # resources which we have to walk in a linear fashion because they
  842. # have regex/wildcard match rules and we cannot index them.
  843. #
  844. # For most cases we do not expect there to be many of these since
  845. # currently they are only added by `add_domain`
  846. #
  847. for resource in self._matched_sub_app_resources:
  848. match_dict, allowed = await resource.resolve(request)
  849. if match_dict is not None:
  850. return match_dict
  851. else:
  852. allowed_methods |= allowed
  853. if allowed_methods:
  854. return MatchInfoError(HTTPMethodNotAllowed(request.method, allowed_methods))
  855. return MatchInfoError(HTTPNotFound())
  856. def __iter__(self) -> Iterator[str]:
  857. return iter(self._named_resources)
  858. def __len__(self) -> int:
  859. return len(self._named_resources)
  860. def __contains__(self, resource: object) -> bool:
  861. return resource in self._named_resources
  862. def __getitem__(self, name: str) -> AbstractResource:
  863. return self._named_resources[name]
  864. def resources(self) -> ResourcesView:
  865. return ResourcesView(self._resources)
  866. def routes(self) -> RoutesView:
  867. return RoutesView(self._resources)
  868. def named_resources(self) -> Mapping[str, AbstractResource]:
  869. return MappingProxyType(self._named_resources)
  870. def register_resource(self, resource: AbstractResource) -> None:
  871. assert isinstance(
  872. resource, AbstractResource
  873. ), f"Instance of AbstractResource class is required, got {resource!r}"
  874. if self.frozen:
  875. raise RuntimeError("Cannot register a resource into frozen router.")
  876. name = resource.name
  877. if name is not None:
  878. parts = self.NAME_SPLIT_RE.split(name)
  879. for part in parts:
  880. if keyword.iskeyword(part):
  881. raise ValueError(
  882. f"Incorrect route name {name!r}, "
  883. "python keywords cannot be used "
  884. "for route name"
  885. )
  886. if not part.isidentifier():
  887. raise ValueError(
  888. "Incorrect route name {!r}, "
  889. "the name should be a sequence of "
  890. "python identifiers separated "
  891. "by dash, dot or column".format(name)
  892. )
  893. if name in self._named_resources:
  894. raise ValueError(
  895. "Duplicate {!r}, "
  896. "already handled by {!r}".format(name, self._named_resources[name])
  897. )
  898. self._named_resources[name] = resource
  899. self._resources.append(resource)
  900. if isinstance(resource, MatchedSubAppResource):
  901. # We cannot index match sub-app resources because they have match rules
  902. self._matched_sub_app_resources.append(resource)
  903. else:
  904. self.index_resource(resource)
  905. def _get_resource_index_key(self, resource: AbstractResource) -> str:
  906. """Return a key to index the resource in the resource index."""
  907. if "{" in (index_key := resource.canonical):
  908. # strip at the first { to allow for variables, and than
  909. # rpartition at / to allow for variable parts in the path
  910. # For example if the canonical path is `/core/locations{tail:.*}`
  911. # the index key will be `/core` since index is based on the
  912. # url parts split by `/`
  913. index_key = index_key.partition("{")[0].rpartition("/")[0]
  914. return index_key.rstrip("/") or "/"
  915. def index_resource(self, resource: AbstractResource) -> None:
  916. """Add a resource to the resource index."""
  917. resource_key = self._get_resource_index_key(resource)
  918. # There may be multiple resources for a canonical path
  919. # so we keep them in a list to ensure that registration
  920. # order is respected.
  921. self._resource_index.setdefault(resource_key, []).append(resource)
  922. def unindex_resource(self, resource: AbstractResource) -> None:
  923. """Remove a resource from the resource index."""
  924. resource_key = self._get_resource_index_key(resource)
  925. self._resource_index[resource_key].remove(resource)
  926. def add_resource(self, path: str, *, name: Optional[str] = None) -> Resource:
  927. if path and not path.startswith("/"):
  928. raise ValueError("path should be started with / or be empty")
  929. # Reuse last added resource if path and name are the same
  930. if self._resources:
  931. resource = self._resources[-1]
  932. if resource.name == name and resource.raw_match(path):
  933. return cast(Resource, resource)
  934. if not ("{" in path or "}" in path or ROUTE_RE.search(path)):
  935. resource = PlainResource(path, name=name)
  936. self.register_resource(resource)
  937. return resource
  938. resource = DynamicResource(path, name=name)
  939. self.register_resource(resource)
  940. return resource
  941. def add_route(
  942. self,
  943. method: str,
  944. path: str,
  945. handler: Union[Handler, Type[AbstractView]],
  946. *,
  947. name: Optional[str] = None,
  948. expect_handler: Optional[_ExpectHandler] = None,
  949. ) -> AbstractRoute:
  950. resource = self.add_resource(path, name=name)
  951. return resource.add_route(method, handler, expect_handler=expect_handler)
  952. def add_static(
  953. self,
  954. prefix: str,
  955. path: PathLike,
  956. *,
  957. name: Optional[str] = None,
  958. expect_handler: Optional[_ExpectHandler] = None,
  959. chunk_size: int = 256 * 1024,
  960. show_index: bool = False,
  961. follow_symlinks: bool = False,
  962. append_version: bool = False,
  963. ) -> AbstractResource:
  964. """Add static files view.
  965. prefix - url prefix
  966. path - folder with files
  967. """
  968. assert prefix.startswith("/")
  969. if prefix.endswith("/"):
  970. prefix = prefix[:-1]
  971. resource = StaticResource(
  972. prefix,
  973. path,
  974. name=name,
  975. expect_handler=expect_handler,
  976. chunk_size=chunk_size,
  977. show_index=show_index,
  978. follow_symlinks=follow_symlinks,
  979. append_version=append_version,
  980. )
  981. self.register_resource(resource)
  982. return resource
  983. def add_head(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  984. """Shortcut for add_route with method HEAD."""
  985. return self.add_route(hdrs.METH_HEAD, path, handler, **kwargs)
  986. def add_options(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  987. """Shortcut for add_route with method OPTIONS."""
  988. return self.add_route(hdrs.METH_OPTIONS, path, handler, **kwargs)
  989. def add_get(
  990. self,
  991. path: str,
  992. handler: Handler,
  993. *,
  994. name: Optional[str] = None,
  995. allow_head: bool = True,
  996. **kwargs: Any,
  997. ) -> AbstractRoute:
  998. """Shortcut for add_route with method GET.
  999. If allow_head is true, another
  1000. route is added allowing head requests to the same endpoint.
  1001. """
  1002. resource = self.add_resource(path, name=name)
  1003. if allow_head:
  1004. resource.add_route(hdrs.METH_HEAD, handler, **kwargs)
  1005. return resource.add_route(hdrs.METH_GET, handler, **kwargs)
  1006. def add_post(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1007. """Shortcut for add_route with method POST."""
  1008. return self.add_route(hdrs.METH_POST, path, handler, **kwargs)
  1009. def add_put(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1010. """Shortcut for add_route with method PUT."""
  1011. return self.add_route(hdrs.METH_PUT, path, handler, **kwargs)
  1012. def add_patch(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1013. """Shortcut for add_route with method PATCH."""
  1014. return self.add_route(hdrs.METH_PATCH, path, handler, **kwargs)
  1015. def add_delete(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1016. """Shortcut for add_route with method DELETE."""
  1017. return self.add_route(hdrs.METH_DELETE, path, handler, **kwargs)
  1018. def add_view(
  1019. self, path: str, handler: Type[AbstractView], **kwargs: Any
  1020. ) -> AbstractRoute:
  1021. """Shortcut for add_route with ANY methods for a class-based view."""
  1022. return self.add_route(hdrs.METH_ANY, path, handler, **kwargs)
  1023. def freeze(self) -> None:
  1024. super().freeze()
  1025. for resource in self._resources:
  1026. resource.freeze()
  1027. def add_routes(self, routes: Iterable[AbstractRouteDef]) -> List[AbstractRoute]:
  1028. """Append routes to route table.
  1029. Parameter should be a sequence of RouteDef objects.
  1030. Returns a list of registered AbstractRoute instances.
  1031. """
  1032. registered_routes = []
  1033. for route_def in routes:
  1034. registered_routes.extend(route_def.register(self))
  1035. return registered_routes
  1036. def _quote_path(value: str) -> str:
  1037. if YARL_VERSION < (1, 6):
  1038. value = value.replace("%", "%25")
  1039. return URL.build(path=value, encoded=False).raw_path
  1040. def _unquote_path_safe(value: str) -> str:
  1041. if "%" not in value:
  1042. return value
  1043. return value.replace("%2F", "/").replace("%25", "%")
  1044. def _requote_path(value: str) -> str:
  1045. # Quote non-ascii characters and other characters which must be quoted,
  1046. # but preserve existing %-sequences.
  1047. result = _quote_path(value)
  1048. if "%" in value:
  1049. result = result.replace("%25", "%")
  1050. return result