web_urldispatcher.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305
  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. unresolved_path = self._directory.joinpath(filename)
  549. loop = asyncio.get_running_loop()
  550. return await loop.run_in_executor(
  551. None, self._resolve_path_to_response, unresolved_path
  552. )
  553. def _resolve_path_to_response(self, unresolved_path: Path) -> StreamResponse:
  554. """Take the unresolved path and query the file system to form a response."""
  555. # Check for access outside the root directory. For follow symlinks, URI
  556. # cannot traverse out, but symlinks can. Otherwise, no access outside
  557. # root is permitted.
  558. try:
  559. if self._follow_symlinks:
  560. normalized_path = Path(os.path.normpath(unresolved_path))
  561. normalized_path.relative_to(self._directory)
  562. file_path = normalized_path.resolve()
  563. else:
  564. file_path = unresolved_path.resolve()
  565. file_path.relative_to(self._directory)
  566. except (ValueError, *CIRCULAR_SYMLINK_ERROR) as error:
  567. # ValueError is raised for the relative check. Circular symlinks
  568. # raise here on resolving for python < 3.13.
  569. raise HTTPNotFound() from error
  570. # if path is a directory, return the contents if permitted. Note the
  571. # directory check will raise if a segment is not readable.
  572. try:
  573. if file_path.is_dir():
  574. if self._show_index:
  575. return Response(
  576. text=self._directory_as_html(file_path),
  577. content_type="text/html",
  578. )
  579. else:
  580. raise HTTPForbidden()
  581. except PermissionError as error:
  582. raise HTTPForbidden() from error
  583. # Return the file response, which handles all other checks.
  584. return FileResponse(file_path, chunk_size=self._chunk_size)
  585. def _directory_as_html(self, dir_path: Path) -> str:
  586. """returns directory's index as html."""
  587. assert dir_path.is_dir()
  588. relative_path_to_dir = dir_path.relative_to(self._directory).as_posix()
  589. index_of = f"Index of /{html_escape(relative_path_to_dir)}"
  590. h1 = f"<h1>{index_of}</h1>"
  591. index_list = []
  592. dir_index = dir_path.iterdir()
  593. for _file in sorted(dir_index):
  594. # show file url as relative to static path
  595. rel_path = _file.relative_to(self._directory).as_posix()
  596. quoted_file_url = _quote_path(f"{self._prefix}/{rel_path}")
  597. # if file is a directory, add '/' to the end of the name
  598. if _file.is_dir():
  599. file_name = f"{_file.name}/"
  600. else:
  601. file_name = _file.name
  602. index_list.append(
  603. f'<li><a href="{quoted_file_url}">{html_escape(file_name)}</a></li>'
  604. )
  605. ul = "<ul>\n{}\n</ul>".format("\n".join(index_list))
  606. body = f"<body>\n{h1}\n{ul}\n</body>"
  607. head_str = f"<head>\n<title>{index_of}</title>\n</head>"
  608. html = f"<html>\n{head_str}\n{body}\n</html>"
  609. return html
  610. def __repr__(self) -> str:
  611. name = "'" + self.name + "'" if self.name is not None else ""
  612. return "<StaticResource {name} {path} -> {directory!r}>".format(
  613. name=name, path=self._prefix, directory=self._directory
  614. )
  615. class PrefixedSubAppResource(PrefixResource):
  616. def __init__(self, prefix: str, app: "Application") -> None:
  617. super().__init__(prefix)
  618. self._app = app
  619. self._add_prefix_to_resources(prefix)
  620. def add_prefix(self, prefix: str) -> None:
  621. super().add_prefix(prefix)
  622. self._add_prefix_to_resources(prefix)
  623. def _add_prefix_to_resources(self, prefix: str) -> None:
  624. router = self._app.router
  625. for resource in router.resources():
  626. # Since the canonical path of a resource is about
  627. # to change, we need to unindex it and then reindex
  628. router.unindex_resource(resource)
  629. resource.add_prefix(prefix)
  630. router.index_resource(resource)
  631. def url_for(self, *args: str, **kwargs: str) -> URL:
  632. raise RuntimeError(".url_for() is not supported by sub-application root")
  633. def get_info(self) -> _InfoDict:
  634. return {"app": self._app, "prefix": self._prefix}
  635. async def resolve(self, request: Request) -> _Resolve:
  636. match_info = await self._app.router.resolve(request)
  637. match_info.add_app(self._app)
  638. if isinstance(match_info.http_exception, HTTPMethodNotAllowed):
  639. methods = match_info.http_exception.allowed_methods
  640. else:
  641. methods = set()
  642. return match_info, methods
  643. def __len__(self) -> int:
  644. return len(self._app.router.routes())
  645. def __iter__(self) -> Iterator[AbstractRoute]:
  646. return iter(self._app.router.routes())
  647. def __repr__(self) -> str:
  648. return "<PrefixedSubAppResource {prefix} -> {app!r}>".format(
  649. prefix=self._prefix, app=self._app
  650. )
  651. class AbstractRuleMatching(abc.ABC):
  652. @abc.abstractmethod # pragma: no branch
  653. async def match(self, request: Request) -> bool:
  654. """Return bool if the request satisfies the criteria"""
  655. @abc.abstractmethod # pragma: no branch
  656. def get_info(self) -> _InfoDict:
  657. """Return a dict with additional info useful for introspection"""
  658. @property
  659. @abc.abstractmethod # pragma: no branch
  660. def canonical(self) -> str:
  661. """Return a str"""
  662. class Domain(AbstractRuleMatching):
  663. re_part = re.compile(r"(?!-)[a-z\d-]{1,63}(?<!-)")
  664. def __init__(self, domain: str) -> None:
  665. super().__init__()
  666. self._domain = self.validation(domain)
  667. @property
  668. def canonical(self) -> str:
  669. return self._domain
  670. def validation(self, domain: str) -> str:
  671. if not isinstance(domain, str):
  672. raise TypeError("Domain must be str")
  673. domain = domain.rstrip(".").lower()
  674. if not domain:
  675. raise ValueError("Domain cannot be empty")
  676. elif "://" in domain:
  677. raise ValueError("Scheme not supported")
  678. url = URL("http://" + domain)
  679. assert url.raw_host is not None
  680. if not all(self.re_part.fullmatch(x) for x in url.raw_host.split(".")):
  681. raise ValueError("Domain not valid")
  682. if url.port == 80:
  683. return url.raw_host
  684. return f"{url.raw_host}:{url.port}"
  685. async def match(self, request: Request) -> bool:
  686. host = request.headers.get(hdrs.HOST)
  687. if not host:
  688. return False
  689. return self.match_domain(host)
  690. def match_domain(self, host: str) -> bool:
  691. return host.lower() == self._domain
  692. def get_info(self) -> _InfoDict:
  693. return {"domain": self._domain}
  694. class MaskDomain(Domain):
  695. re_part = re.compile(r"(?!-)[a-z\d\*-]{1,63}(?<!-)")
  696. def __init__(self, domain: str) -> None:
  697. super().__init__(domain)
  698. mask = self._domain.replace(".", r"\.").replace("*", ".*")
  699. self._mask = re.compile(mask)
  700. @property
  701. def canonical(self) -> str:
  702. return self._mask.pattern
  703. def match_domain(self, host: str) -> bool:
  704. return self._mask.fullmatch(host) is not None
  705. class MatchedSubAppResource(PrefixedSubAppResource):
  706. def __init__(self, rule: AbstractRuleMatching, app: "Application") -> None:
  707. AbstractResource.__init__(self)
  708. self._prefix = ""
  709. self._app = app
  710. self._rule = rule
  711. @property
  712. def canonical(self) -> str:
  713. return self._rule.canonical
  714. def get_info(self) -> _InfoDict:
  715. return {"app": self._app, "rule": self._rule}
  716. async def resolve(self, request: Request) -> _Resolve:
  717. if not await self._rule.match(request):
  718. return None, set()
  719. match_info = await self._app.router.resolve(request)
  720. match_info.add_app(self._app)
  721. if isinstance(match_info.http_exception, HTTPMethodNotAllowed):
  722. methods = match_info.http_exception.allowed_methods
  723. else:
  724. methods = set()
  725. return match_info, methods
  726. def __repr__(self) -> str:
  727. return f"<MatchedSubAppResource -> {self._app!r}>"
  728. class ResourceRoute(AbstractRoute):
  729. """A route with resource"""
  730. def __init__(
  731. self,
  732. method: str,
  733. handler: Union[Handler, Type[AbstractView]],
  734. resource: AbstractResource,
  735. *,
  736. expect_handler: Optional[_ExpectHandler] = None,
  737. ) -> None:
  738. super().__init__(
  739. method, handler, expect_handler=expect_handler, resource=resource
  740. )
  741. def __repr__(self) -> str:
  742. return "<ResourceRoute [{method}] {resource} -> {handler!r}".format(
  743. method=self.method, resource=self._resource, handler=self.handler
  744. )
  745. @property
  746. def name(self) -> Optional[str]:
  747. if self._resource is None:
  748. return None
  749. return self._resource.name
  750. def url_for(self, *args: str, **kwargs: str) -> URL:
  751. """Construct url for route with additional params."""
  752. assert self._resource is not None
  753. return self._resource.url_for(*args, **kwargs)
  754. def get_info(self) -> _InfoDict:
  755. assert self._resource is not None
  756. return self._resource.get_info()
  757. class SystemRoute(AbstractRoute):
  758. def __init__(self, http_exception: HTTPException) -> None:
  759. super().__init__(hdrs.METH_ANY, self._handle)
  760. self._http_exception = http_exception
  761. def url_for(self, *args: str, **kwargs: str) -> URL:
  762. raise RuntimeError(".url_for() is not allowed for SystemRoute")
  763. @property
  764. def name(self) -> Optional[str]:
  765. return None
  766. def get_info(self) -> _InfoDict:
  767. return {"http_exception": self._http_exception}
  768. async def _handle(self, request: Request) -> StreamResponse:
  769. raise self._http_exception
  770. @property
  771. def status(self) -> int:
  772. return self._http_exception.status
  773. @property
  774. def reason(self) -> str:
  775. return self._http_exception.reason
  776. def __repr__(self) -> str:
  777. return "<SystemRoute {self.status}: {self.reason}>".format(self=self)
  778. class View(AbstractView):
  779. async def _iter(self) -> StreamResponse:
  780. if self.request.method not in hdrs.METH_ALL:
  781. self._raise_allowed_methods()
  782. method: Optional[Callable[[], Awaitable[StreamResponse]]]
  783. method = getattr(self, self.request.method.lower(), None)
  784. if method is None:
  785. self._raise_allowed_methods()
  786. ret = await method()
  787. assert isinstance(ret, StreamResponse)
  788. return ret
  789. def __await__(self) -> Generator[None, None, StreamResponse]:
  790. return self._iter().__await__()
  791. def _raise_allowed_methods(self) -> NoReturn:
  792. allowed_methods = {m for m in hdrs.METH_ALL if hasattr(self, m.lower())}
  793. raise HTTPMethodNotAllowed(self.request.method, allowed_methods)
  794. class ResourcesView(Sized, Iterable[AbstractResource], Container[AbstractResource]):
  795. def __init__(self, resources: List[AbstractResource]) -> None:
  796. self._resources = resources
  797. def __len__(self) -> int:
  798. return len(self._resources)
  799. def __iter__(self) -> Iterator[AbstractResource]:
  800. yield from self._resources
  801. def __contains__(self, resource: object) -> bool:
  802. return resource in self._resources
  803. class RoutesView(Sized, Iterable[AbstractRoute], Container[AbstractRoute]):
  804. def __init__(self, resources: List[AbstractResource]):
  805. self._routes: List[AbstractRoute] = []
  806. for resource in resources:
  807. for route in resource:
  808. self._routes.append(route)
  809. def __len__(self) -> int:
  810. return len(self._routes)
  811. def __iter__(self) -> Iterator[AbstractRoute]:
  812. yield from self._routes
  813. def __contains__(self, route: object) -> bool:
  814. return route in self._routes
  815. class UrlDispatcher(AbstractRouter, Mapping[str, AbstractResource]):
  816. NAME_SPLIT_RE = re.compile(r"[.:-]")
  817. def __init__(self) -> None:
  818. super().__init__()
  819. self._resources: List[AbstractResource] = []
  820. self._named_resources: Dict[str, AbstractResource] = {}
  821. self._resource_index: dict[str, list[AbstractResource]] = {}
  822. self._matched_sub_app_resources: List[MatchedSubAppResource] = []
  823. async def resolve(self, request: Request) -> UrlMappingMatchInfo:
  824. resource_index = self._resource_index
  825. allowed_methods: Set[str] = set()
  826. # MatchedSubAppResource is primarily used to match on domain names
  827. # (though custom rules could match on other things). This means that
  828. # the traversal algorithm below can't be applied, and that we likely
  829. # need to check these first so a sub app that defines the same path
  830. # as a parent app will get priority if there's a domain match.
  831. #
  832. # For most cases we do not expect there to be many of these since
  833. # currently they are only added by `.add_domain()`.
  834. for resource in self._matched_sub_app_resources:
  835. match_dict, allowed = await resource.resolve(request)
  836. if match_dict is not None:
  837. return match_dict
  838. else:
  839. allowed_methods |= allowed
  840. # Walk the url parts looking for candidates. We walk the url backwards
  841. # to ensure the most explicit match is found first. If there are multiple
  842. # candidates for a given url part because there are multiple resources
  843. # registered for the same canonical path, we resolve them in a linear
  844. # fashion to ensure registration order is respected.
  845. url_part = request.rel_url.path_safe
  846. while url_part:
  847. for candidate in resource_index.get(url_part, ()):
  848. match_dict, allowed = await candidate.resolve(request)
  849. if match_dict is not None:
  850. return match_dict
  851. else:
  852. allowed_methods |= allowed
  853. if url_part == "/":
  854. break
  855. url_part = url_part.rpartition("/")[0] or "/"
  856. if allowed_methods:
  857. return MatchInfoError(HTTPMethodNotAllowed(request.method, allowed_methods))
  858. return MatchInfoError(HTTPNotFound())
  859. def __iter__(self) -> Iterator[str]:
  860. return iter(self._named_resources)
  861. def __len__(self) -> int:
  862. return len(self._named_resources)
  863. def __contains__(self, resource: object) -> bool:
  864. return resource in self._named_resources
  865. def __getitem__(self, name: str) -> AbstractResource:
  866. return self._named_resources[name]
  867. def resources(self) -> ResourcesView:
  868. return ResourcesView(self._resources)
  869. def routes(self) -> RoutesView:
  870. return RoutesView(self._resources)
  871. def named_resources(self) -> Mapping[str, AbstractResource]:
  872. return MappingProxyType(self._named_resources)
  873. def register_resource(self, resource: AbstractResource) -> None:
  874. assert isinstance(
  875. resource, AbstractResource
  876. ), f"Instance of AbstractResource class is required, got {resource!r}"
  877. if self.frozen:
  878. raise RuntimeError("Cannot register a resource into frozen router.")
  879. name = resource.name
  880. if name is not None:
  881. parts = self.NAME_SPLIT_RE.split(name)
  882. for part in parts:
  883. if keyword.iskeyword(part):
  884. raise ValueError(
  885. f"Incorrect route name {name!r}, "
  886. "python keywords cannot be used "
  887. "for route name"
  888. )
  889. if not part.isidentifier():
  890. raise ValueError(
  891. "Incorrect route name {!r}, "
  892. "the name should be a sequence of "
  893. "python identifiers separated "
  894. "by dash, dot or column".format(name)
  895. )
  896. if name in self._named_resources:
  897. raise ValueError(
  898. "Duplicate {!r}, "
  899. "already handled by {!r}".format(name, self._named_resources[name])
  900. )
  901. self._named_resources[name] = resource
  902. self._resources.append(resource)
  903. if isinstance(resource, MatchedSubAppResource):
  904. # We cannot index match sub-app resources because they have match rules
  905. self._matched_sub_app_resources.append(resource)
  906. else:
  907. self.index_resource(resource)
  908. def _get_resource_index_key(self, resource: AbstractResource) -> str:
  909. """Return a key to index the resource in the resource index."""
  910. if "{" in (index_key := resource.canonical):
  911. # strip at the first { to allow for variables, and than
  912. # rpartition at / to allow for variable parts in the path
  913. # For example if the canonical path is `/core/locations{tail:.*}`
  914. # the index key will be `/core` since index is based on the
  915. # url parts split by `/`
  916. index_key = index_key.partition("{")[0].rpartition("/")[0]
  917. return index_key.rstrip("/") or "/"
  918. def index_resource(self, resource: AbstractResource) -> None:
  919. """Add a resource to the resource index."""
  920. resource_key = self._get_resource_index_key(resource)
  921. # There may be multiple resources for a canonical path
  922. # so we keep them in a list to ensure that registration
  923. # order is respected.
  924. self._resource_index.setdefault(resource_key, []).append(resource)
  925. def unindex_resource(self, resource: AbstractResource) -> None:
  926. """Remove a resource from the resource index."""
  927. resource_key = self._get_resource_index_key(resource)
  928. self._resource_index[resource_key].remove(resource)
  929. def add_resource(self, path: str, *, name: Optional[str] = None) -> Resource:
  930. if path and not path.startswith("/"):
  931. raise ValueError("path should be started with / or be empty")
  932. # Reuse last added resource if path and name are the same
  933. if self._resources:
  934. resource = self._resources[-1]
  935. if resource.name == name and resource.raw_match(path):
  936. return cast(Resource, resource)
  937. if not ("{" in path or "}" in path or ROUTE_RE.search(path)):
  938. resource = PlainResource(path, name=name)
  939. self.register_resource(resource)
  940. return resource
  941. resource = DynamicResource(path, name=name)
  942. self.register_resource(resource)
  943. return resource
  944. def add_route(
  945. self,
  946. method: str,
  947. path: str,
  948. handler: Union[Handler, Type[AbstractView]],
  949. *,
  950. name: Optional[str] = None,
  951. expect_handler: Optional[_ExpectHandler] = None,
  952. ) -> AbstractRoute:
  953. resource = self.add_resource(path, name=name)
  954. return resource.add_route(method, handler, expect_handler=expect_handler)
  955. def add_static(
  956. self,
  957. prefix: str,
  958. path: PathLike,
  959. *,
  960. name: Optional[str] = None,
  961. expect_handler: Optional[_ExpectHandler] = None,
  962. chunk_size: int = 256 * 1024,
  963. show_index: bool = False,
  964. follow_symlinks: bool = False,
  965. append_version: bool = False,
  966. ) -> AbstractResource:
  967. """Add static files view.
  968. prefix - url prefix
  969. path - folder with files
  970. """
  971. assert prefix.startswith("/")
  972. if prefix.endswith("/"):
  973. prefix = prefix[:-1]
  974. resource = StaticResource(
  975. prefix,
  976. path,
  977. name=name,
  978. expect_handler=expect_handler,
  979. chunk_size=chunk_size,
  980. show_index=show_index,
  981. follow_symlinks=follow_symlinks,
  982. append_version=append_version,
  983. )
  984. self.register_resource(resource)
  985. return resource
  986. def add_head(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  987. """Shortcut for add_route with method HEAD."""
  988. return self.add_route(hdrs.METH_HEAD, path, handler, **kwargs)
  989. def add_options(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  990. """Shortcut for add_route with method OPTIONS."""
  991. return self.add_route(hdrs.METH_OPTIONS, path, handler, **kwargs)
  992. def add_get(
  993. self,
  994. path: str,
  995. handler: Handler,
  996. *,
  997. name: Optional[str] = None,
  998. allow_head: bool = True,
  999. **kwargs: Any,
  1000. ) -> AbstractRoute:
  1001. """Shortcut for add_route with method GET.
  1002. If allow_head is true, another
  1003. route is added allowing head requests to the same endpoint.
  1004. """
  1005. resource = self.add_resource(path, name=name)
  1006. if allow_head:
  1007. resource.add_route(hdrs.METH_HEAD, handler, **kwargs)
  1008. return resource.add_route(hdrs.METH_GET, handler, **kwargs)
  1009. def add_post(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1010. """Shortcut for add_route with method POST."""
  1011. return self.add_route(hdrs.METH_POST, path, handler, **kwargs)
  1012. def add_put(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1013. """Shortcut for add_route with method PUT."""
  1014. return self.add_route(hdrs.METH_PUT, path, handler, **kwargs)
  1015. def add_patch(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1016. """Shortcut for add_route with method PATCH."""
  1017. return self.add_route(hdrs.METH_PATCH, path, handler, **kwargs)
  1018. def add_delete(self, path: str, handler: Handler, **kwargs: Any) -> AbstractRoute:
  1019. """Shortcut for add_route with method DELETE."""
  1020. return self.add_route(hdrs.METH_DELETE, path, handler, **kwargs)
  1021. def add_view(
  1022. self, path: str, handler: Type[AbstractView], **kwargs: Any
  1023. ) -> AbstractRoute:
  1024. """Shortcut for add_route with ANY methods for a class-based view."""
  1025. return self.add_route(hdrs.METH_ANY, path, handler, **kwargs)
  1026. def freeze(self) -> None:
  1027. super().freeze()
  1028. for resource in self._resources:
  1029. resource.freeze()
  1030. def add_routes(self, routes: Iterable[AbstractRouteDef]) -> List[AbstractRoute]:
  1031. """Append routes to route table.
  1032. Parameter should be a sequence of RouteDef objects.
  1033. Returns a list of registered AbstractRoute instances.
  1034. """
  1035. registered_routes = []
  1036. for route_def in routes:
  1037. registered_routes.extend(route_def.register(self))
  1038. return registered_routes
  1039. def _quote_path(value: str) -> str:
  1040. if YARL_VERSION < (1, 6):
  1041. value = value.replace("%", "%25")
  1042. return URL.build(path=value, encoded=False).raw_path
  1043. def _unquote_path_safe(value: str) -> str:
  1044. if "%" not in value:
  1045. return value
  1046. return value.replace("%2F", "/").replace("%25", "%")
  1047. def _requote_path(value: str) -> str:
  1048. # Quote non-ascii characters and other characters which must be quoted,
  1049. # but preserve existing %-sequences.
  1050. result = _quote_path(value)
  1051. if "%" in value:
  1052. result = result.replace("%25", "%")
  1053. return result