diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..039e0ff --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,91 @@ +# Routing + +URL based routing ... + +```python +>>> u = httpx.URL('/users/alex') +>>> r = httpx.Router([ +... httpx.Path('/', endpoint=homepage), +... httpx.Path('/users', endpoint=user_list), +... httpx.Path('/users/{userid}', endpoint=user_profile), +... ]) +>>> m = r.match(u) + +>>> m.endpoint + +>>> m.name +'user_profile' +>>> m.kwargs +{'userid': 'alex'} +``` + +--- + +## Router + +The `Router` class implements the `Transport` API and can be used to provide either server-side or client-side URL routing. + +... + +--- + +## Path + +... + +## PathPrefix + +... + +## Domain + +... + +## Scheme + +... + +--- + +## Route + +The `Route` class is an interface defining the core API for URL matching. + +### match() + +... + +### reverse() + +... + +--- + +# Server Side Routing + +URL based routing is designed to integrate with server applications... + +```python +routes = [ + httpx.Scheme('http', endpoint=https_redirect), + httpx.Scheme('https', routes=[ + httpx.Host('www.example.com', routes=[ + httpx.Path('/', endpoint=...), + httpx.Path('/users', endpoint=...), + httpx.Path('/users/{user_id}', endpoint=...), + httpx.PathPrefix('/static', endpoint=serve_static), + httpx.PathPrefix('/', endpoint=trailing_slash_redirect_or_not_found), + ]), + httpx.Host('example.com', endpoint=www_redirect), + httpx.Host('*', endpoint=host_disallowed), + ]) +] +``` + +--- + +# Client Side Routing + +While URL based routing is most typically used server-side, it can also be useful in some client side contexts. + +... diff --git a/scripts/unasync b/scripts/unasync index 22db6f2..f547752 100755 --- a/scripts/unasync +++ b/scripts/unasync @@ -7,6 +7,8 @@ unasync.unasync_files( "src/ahttpx/_client.py", "src/ahttpx/_models.py", "src/ahttpx/_pool.py", + "src/ahttpx/_routing.py", + "src/ahttpx/_transport.py", "src/ahttpx/_urlparse.py", "src/ahttpx/_urls.py" ], diff --git a/src/ahttpx/__init__.py b/src/ahttpx/__init__.py index 4b8f79d..1e72ba1 100644 --- a/src/ahttpx/__init__.py +++ b/src/ahttpx/__init__.py @@ -2,6 +2,7 @@ from ._models import * # Content, File, Files, Form, Headers, JSON, MultiPart, Response, Request, Text from ._network import * # NetworkBackend, NetworkStream, timeout from ._pool import * # Connection, ConnectionPool, Transport, open_connection_pool, open_connection +from ._transport import * # Transport from ._urls import * # QueryParams, URL diff --git a/src/ahttpx/_routing.py b/src/ahttpx/_routing.py new file mode 100644 index 0000000..f0c2acc --- /dev/null +++ b/src/ahttpx/_routing.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from ._models import Response, Request, URL +from ._transport import Transport + +import re +import typing + + +__all__ = ["Path", "Route", "Router", "Match"] + + +class Route: + def match(self, url: URL) -> "Match" | None: + raise NotImplementedError() + + def reverse(self, name: str, **kwargs: str) -> URL | None: + raise NotImplementedError() + + +class Path(Route): + def __init__(self, path: str, endpoint: typing.Any, name: str | None = None): + self._path = path + self._endpoint = endpoint + self._pattern = self._compile_regex(path) + self._name = self._generate_name(endpoint, name) + + def _generate_name(self, endpoint: typing.Any, name: str | None) -> str: + return name or endpoint.__name__ + + def _compile_regex(self, path) -> re.Pattern: + # Escape all non-placeholder characters in the pattern + escaped_path = re.escape(path) + # Replace escaped curly brace placeholders with regex groups + regex_pattern = re.sub(r'\\{([^{}]+)\\}', r'(?P<\1>[^/]+)', escaped_path) + return re.compile(f'^{regex_pattern}$') + + @property + def name(self) -> str: + return self._name + + @property + def path(self) -> str: + return self._path + + @property + def endpoint(self) -> typing.Any: + return self._endpoint + + def match(self, url: URL) -> "Match" | None: + m = self._pattern.match(url.path) + if m is None: + return None + + return Match( + name=self.name, + kwargs=m.groupdict(), + endpoint=self.endpoint + ) + + def reverse(self, name: str, **kwargs: str) -> URL | None: + if name != self._name: + return None + if kwargs.keys() != self._pattern.groupindex.keys(): + return None + return URL(self._path.format(**kwargs)) + + def __repr__(self): + return f"" + + +class Scheme(Route): + def __init__(self, scheme: str, routes: list[Route]): + self._scheme = scheme + self._routes = routes + self._router = Router(routes) + + def match(self, url: URL) -> "Match" | None: + if url.scheme == self._scheme: + return self._router.match(url) + return None + + def reverse(self, name: str, **kwargs: str) -> URL | None: + return self._router.reverse(name, **kwargs) + + def __repr__(self): + return f"" + + +class Match: + def __init__(self, name: str, kwargs: dict[str, str], endpoint: typing.Any): + self._name = name + self._kwargs = kwargs + self._endpoint = endpoint + + @property + def name(self) -> str: + return self._name + + @property + def kwargs(self) -> dict[str, str]: + return dict(self._kwargs) + + @property + def endpoint(self) -> typing.Any: + return self._endpoint + + def __repr__(self) -> str: + return f"" + + +class Router(Transport): + def __init__(self, routes: list[Route]): + self._routes = list(routes) + + @property + def routes(self) -> list[Route]: + return list(self._routes) + + def match(self, url: URL) -> "Match" | None: + for route in self._routes: + m = route.match(url) + if m is not None: + return m + + def reverse(self, name: str, **kwargs: str) -> URL | None: + for route in self._routes: + r = route.reverse(name, kwargs) + if r is not None: + return r + + async def send(self, request: Request) -> typing.AsyncIterator[Response]: + match = self.match(request.url) + if match is not None: + response = await match.endpoint(request) + return response + return Response(404) + + def __repr__(self) -> str: + return "" diff --git a/src/ahttpx/_transport.py b/src/ahttpx/_transport.py new file mode 100644 index 0000000..3096916 --- /dev/null +++ b/src/ahttpx/_transport.py @@ -0,0 +1,37 @@ +import contextlib +import typing + +from ._models import Content, Request, Response, URL + + +class Transport: + @contextlib.asynccontextmanager + async def send(self, request: Request) -> typing.AsyncIterator[Response]: + raise NotImplementedError() + + async def close(self): + pass + + async def request( + self, + method: str, + url: URL | str, + headers: typing.Mapping[str, str] | None = None, + content: Content | typing.AsyncIterable[bytes] | bytes | None = None, + ) -> Response: + request = Request(method, url, headers=headers, content=content) + async with self.send(request) as response: + await response.read() + return response + + @contextlib.asynccontextmanager + async def stream( + self, + method: str, + url: URL | str, + headers: typing.Mapping[str, str] | None = None, + content: Content | typing.AsyncIterable[bytes] | bytes | None = None, + ) -> typing.AsyncIterator[Response]: + request = Request(method, url, headers=headers, content=content) + async with self.send(request) as response: + yield response diff --git a/src/httpx/__init__.py b/src/httpx/__init__.py index 4b8f79d..1e72ba1 100644 --- a/src/httpx/__init__.py +++ b/src/httpx/__init__.py @@ -2,6 +2,7 @@ from ._models import * # Content, File, Files, Form, Headers, JSON, MultiPart, Response, Request, Text from ._network import * # NetworkBackend, NetworkStream, timeout from ._pool import * # Connection, ConnectionPool, Transport, open_connection_pool, open_connection +from ._transport import * # Transport from ._urls import * # QueryParams, URL diff --git a/src/httpx/_routing.py b/src/httpx/_routing.py new file mode 100644 index 0000000..9a1b92e --- /dev/null +++ b/src/httpx/_routing.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from ._models import Response, Request, URL +from ._transport import Transport + +import re +import typing + + +__all__ = ["Path", "Route", "Router", "Match"] + + +class Route: + def match(self, url: URL) -> "Match" | None: + raise NotImplementedError() + + def reverse(self, name: str, **kwargs: str) -> URL | None: + raise NotImplementedError() + + +class Path(Route): + def __init__(self, path: str, endpoint: typing.Any, name: str | None = None): + self._path = path + self._endpoint = endpoint + self._pattern = self._compile_regex(path) + self._name = self._generate_name(endpoint, name) + + def _generate_name(self, endpoint: typing.Any, name: str | None) -> str: + return name or endpoint.__name__ + + def _compile_regex(self, path) -> re.Pattern: + # Escape all non-placeholder characters in the pattern + escaped_path = re.escape(path) + # Replace escaped curly brace placeholders with regex groups + regex_pattern = re.sub(r'\\{([^{}]+)\\}', r'(?P<\1>[^/]+)', escaped_path) + return re.compile(f'^{regex_pattern}$') + + @property + def name(self) -> str: + return self._name + + @property + def path(self) -> str: + return self._path + + @property + def endpoint(self) -> typing.Any: + return self._endpoint + + def match(self, url: URL) -> "Match" | None: + m = self._pattern.match(url.path) + if m is None: + return None + + return Match( + name=self.name, + kwargs=m.groupdict(), + endpoint=self.endpoint + ) + + def reverse(self, name: str, **kwargs: str) -> URL | None: + if name != self._name: + return None + if kwargs.keys() != self._pattern.groupindex.keys(): + return None + return URL(self._path.format(**kwargs)) + + def __repr__(self): + return f"" + + +class Scheme(Route): + def __init__(self, scheme: str, routes: list[Route]): + self._scheme = scheme + self._routes = routes + self._router = Router(routes) + + def match(self, url: URL) -> "Match" | None: + if url.scheme == self._scheme: + return self._router.match(url) + return None + + def reverse(self, name: str, **kwargs: str) -> URL | None: + return self._router.reverse(name, **kwargs) + + def __repr__(self): + return f"" + + +class Match: + def __init__(self, name: str, kwargs: dict[str, str], endpoint: typing.Any): + self._name = name + self._kwargs = kwargs + self._endpoint = endpoint + + @property + def name(self) -> str: + return self._name + + @property + def kwargs(self) -> dict[str, str]: + return dict(self._kwargs) + + @property + def endpoint(self) -> typing.Any: + return self._endpoint + + def __repr__(self) -> str: + return f"" + + +class Router(Transport): + def __init__(self, routes: list[Route]): + self._routes = list(routes) + + @property + def routes(self) -> list[Route]: + return list(self._routes) + + def match(self, url: URL) -> "Match" | None: + for route in self._routes: + m = route.match(url) + if m is not None: + return m + + def reverse(self, name: str, **kwargs: str) -> URL | None: + for route in self._routes: + r = route.reverse(name, kwargs) + if r is not None: + return r + + def send(self, request: Request) -> typing.Iterator[Response]: + match = self.match(request.url) + if match is not None: + response = match.endpoint(request) + return response + return Response(404) + + def __repr__(self) -> str: + return "" diff --git a/src/httpx/_transport.py b/src/httpx/_transport.py new file mode 100644 index 0000000..47b8937 --- /dev/null +++ b/src/httpx/_transport.py @@ -0,0 +1,37 @@ +import contextlib +import typing + +from ._models import Content, Request, Response, URL + + +class Transport: + @contextlib.contextmanager + def send(self, request: Request) -> typing.Iterator[Response]: + raise NotImplementedError() + + def close(self): + pass + + def request( + self, + method: str, + url: URL | str, + headers: typing.Mapping[str, str] | None = None, + content: Content | typing.Iterable[bytes] | bytes | None = None, + ) -> Response: + request = Request(method, url, headers=headers, content=content) + with self.send(request) as response: + response.read() + return response + + @contextlib.contextmanager + def stream( + self, + method: str, + url: URL | str, + headers: typing.Mapping[str, str] | None = None, + content: Content | typing.Iterable[bytes] | bytes | None = None, + ) -> typing.Iterator[Response]: + request = Request(method, url, headers=headers, content=content) + with self.send(request) as response: + yield response