web_urldispatcher.py 43 KB

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