├── .bumpversion.cfg ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── Makefile ├── README.rst ├── http_router ├── __init__.py ├── exceptions.py ├── py.typed ├── router.pxd ├── router.py ├── router.pyx ├── routes.pxd ├── routes.py ├── routes.pyx ├── types.py └── utils.py ├── pyproject.toml ├── setup.py └── tests.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | current_version = 5.0.8 4 | files = pyproject.toml 5 | tag = True 6 | tag_name = {new_version} 7 | message = build(version): {current_version} -> {new_version} 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_run: 5 | workflows: [tests] 6 | branches: [master] 7 | types: [completed] 8 | 9 | jobs: 10 | setup: 11 | runs-on: ubuntu-latest 12 | if: github.event.workflow_run.conclusion == 'success' 13 | 14 | steps: 15 | - uses: actions/checkout@main 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: Prepare to build 20 | run: pip install build 21 | 22 | - name: Build sdist 23 | run: python -m build --sdist 24 | env: 25 | HTTP_ROUTER_NO_EXTENSIONS: '1' 26 | 27 | - uses: actions/upload-artifact@main 28 | with: 29 | name: dist 30 | path: dist/*.tar.* 31 | 32 | build: 33 | strategy: 34 | matrix: 35 | os: [macos-latest, windows-latest, ubuntu-latest] 36 | cibw_arch: ["auto64", "aarch64", "universal2"] 37 | cibw_python: 38 | - "cp39" 39 | - "cp310" 40 | - "cp311" 41 | - "cp312" 42 | - "cp313" 43 | exclude: 44 | - os: macos-latest 45 | cibw_arch: aarch64 46 | - os: ubuntu-latest 47 | cibw_arch: universal2 48 | - os: windows-latest 49 | cibw_arch: universal2 50 | - os: windows-latest 51 | cibw_arch: aarch64 52 | 53 | runs-on: ${{ matrix.os }} 54 | needs: setup 55 | 56 | defaults: 57 | run: 58 | shell: bash 59 | env: 60 | PIP_DISABLE_PIP_VERSION_CHECK: 1 61 | 62 | steps: 63 | - uses: actions/checkout@main 64 | with: 65 | fetch-depth: 5 66 | 67 | - name: Set up QEMU 68 | if: matrix.os == 'ubuntu-latest' && matrix.cibw_arch == 'aarch64' 69 | uses: docker/setup-qemu-action@v1 70 | with: 71 | platforms: arm64 72 | 73 | - uses: pypa/cibuildwheel@main 74 | env: 75 | CIBW_BUILD_VERBOSITY: 1 76 | CIBW_BUILD: ${{ matrix.cibw_python }}-* 77 | CIBW_ARCHS: ${{ matrix.cibw_arch }} 78 | 79 | - uses: actions/upload-artifact@main 80 | with: 81 | name: wheel-${{ matrix.os }}-${{ matrix.cibw_arch }}-${{ matrix.cibw_python }} 82 | path: wheelhouse/*.whl 83 | 84 | publish: 85 | runs-on: ubuntu-latest 86 | needs: [build] 87 | steps: 88 | 89 | - name: Download a distribution artifact pt.1 90 | uses: actions/download-artifact@main 91 | with: 92 | path: dist 93 | merge-multiple: true 94 | 95 | - name: Publish distribution 📦 to PyPI 96 | uses: pypa/gh-action-pypi-publish@master 97 | with: 98 | user: __token__ 99 | password: ${{ secrets.PYPI }} 100 | 101 | notify: 102 | runs-on: ubuntu-latest 103 | needs: publish 104 | steps: 105 | 106 | - name: Notify Success 107 | uses: archive/github-actions-slack@master 108 | with: 109 | slack-channel: C2CRL4C4V 110 | slack-text: Release is success *[${{ github.repository }}] (${{ github.ref }})* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 111 | slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_TOKEN }} 112 | slack-optional-as_user: false 113 | slack-optional-icon_emoji: ":white_check_mark:" 114 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | branches: [master, develop] 9 | 10 | push: 11 | branches: [master, develop, feature/**] 12 | 13 | jobs: 14 | 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.10'] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | with: 25 | fetch-depth: 5 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@main 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | cache: pip 32 | 33 | - name: Setup requirements 34 | run: pip install .[tests] 35 | 36 | - name: Check code 37 | run: ruff check http_router 38 | if: matrix.python-version != 'pypy-3.10' 39 | 40 | - name: Check types 41 | run: mypy 42 | if: matrix.python-version != 'pypy-3.10' 43 | 44 | - name: Test with pytest 45 | run: pytest 46 | 47 | notify: 48 | runs-on: ubuntu-latest 49 | needs: tests 50 | steps: 51 | 52 | - name: Notify Success 53 | uses: archive/github-actions-slack@master 54 | with: 55 | slack-channel: C2CRL4C4V 56 | slack-text: Tests are passed *[${{ github.repository }}] (${{ github.ref }})* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 57 | slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_TOKEN }} 58 | slack-optional-as_user: false 59 | slack-optional-icon_emoji: ":white_check_mark:" 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.benchmarks 2 | /.eggs 3 | /.ignore 4 | /.vimrc 5 | /build 6 | /dist 7 | /env 8 | /http_router/*.c 9 | /http_router/*.html 10 | /http_router/*.so 11 | /todo.txt 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/charliermarsh/ruff-pre-commit 13 | # Ruff version. 14 | rev: 'v0.2.1' 15 | hooks: 16 | - id: ruff 17 | name: Check code 18 | 19 | - repo: local 20 | hooks: 21 | - id: check-types 22 | name: Check types 23 | entry: mypy http_router 24 | language: system 25 | pass_filenames: false 26 | - id: check-code 27 | name: Refactor code 28 | entry: ruff check http_router 29 | language: system 30 | pass_filenames: false 31 | - id: run-tests 32 | name: Run tests 33 | entry: pytest tests.py 34 | language: system 35 | pass_filenames: false 36 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VIRTUAL_ENV ?= env 2 | PACKAGE ?= http_router 3 | 4 | all: $(VIRTUAL_ENV) 5 | 6 | $(VIRTUAL_ENV): pyproject.toml 7 | @[ -d $(VIRTUAL_ENV) ] || python -m venv env 8 | @$(VIRTUAL_ENV)/bin/pip install -e .[tests,dev] 9 | @$(VIRTUAL_ENV)/bin/pre-commit install --hook-type pre-push 10 | @touch $(VIRTUAL_ENV) 11 | 12 | VERSION ?= minor 13 | 14 | .PHONY: version 15 | version: $(VIRTUAL_ENV) 16 | bump2version $(VERSION) 17 | git checkout master 18 | git pull 19 | git merge develop 20 | git checkout develop 21 | git push origin develop master 22 | git push --tags 23 | 24 | .PHONY: minor 25 | minor: 26 | make version VERSION=minor 27 | 28 | .PHONY: patch 29 | patch: 30 | make version VERSION=patch 31 | 32 | .PHONY: major 33 | major: 34 | make version VERSION=major 35 | 36 | .PHONY: clean 37 | # target: clean - Display callable targets 38 | clean: 39 | rm -rf build/ dist/ docs/_build *.egg-info $(PACKAGE)/*.c $(PACKAGE)/*.so $(PACKAGE)/*.html 40 | find $(CURDIR) -name "*.py[co]" -delete 41 | find $(CURDIR) -name "*.orig" -delete 42 | find $(CURDIR)/$(MODULE) -name "__pycache__" | xargs rm -rf 43 | 44 | LATEST_BENCHMARK = $(shell ls -t .benchmarks/* | head -1 | head -c4) 45 | test t: $(VIRTUAL_ENV) 46 | $(VIRTUAL_ENV)/bin/pytest tests.py --benchmark-autosave --benchmark-compare=$(LATEST_BENCHMARK) 47 | 48 | mypy: $(VIRTUAL_ENV) 49 | $(VIRTUAL_ENV)/bin/mypy $(PACKAGE) 50 | 51 | $(PACKAGE)/%.c: $(PACKAGE)/%.pyx $(PACKAGE)/%.pxd 52 | $(VIRTUAL_ENV)/bin/cython -a $< 53 | 54 | cyt: $(PACKAGE)/router.c $(PACKAGE)/routes.c 55 | 56 | compile: cyt 57 | $(VIRTUAL_ENV)/bin/python setup.py build_ext --inplace 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | HTTP Router 2 | ########### 3 | 4 | .. _description: 5 | 6 | **http-router** -- A simple router for HTTP applications 7 | 8 | The library is not a HTTP framework. It's an utilite to build the frameworks. 9 | The main goal of the library to bind targets to http routes and match them. 10 | 11 | .. _badges: 12 | 13 | .. image:: https://github.com/klen/http-router/workflows/tests/badge.svg 14 | :target: https://github.com/klen/http-router/actions 15 | :alt: Tests Status 16 | 17 | .. image:: https://img.shields.io/pypi/v/http-router 18 | :target: https://pypi.org/project/http-router/ 19 | :alt: PYPI Version 20 | 21 | .. image:: https://img.shields.io/pypi/pyversions/http-router 22 | :target: https://pypi.org/project/http-router/ 23 | :alt: Python Versions 24 | 25 | .. _contents: 26 | 27 | .. contents:: 28 | 29 | 30 | .. _requirements: 31 | 32 | Requirements 33 | ============= 34 | 35 | - python 3.9, 3.10, 3.11, 3.12, 3.13, pypy3 36 | 37 | 38 | .. _installation: 39 | 40 | Installation 41 | ============= 42 | 43 | **http-router** should be installed using pip: :: 44 | 45 | pip install http-router 46 | 47 | 48 | Usage 49 | ===== 50 | 51 | Create a router: 52 | 53 | .. code:: python 54 | 55 | from http_router import Router 56 | 57 | 58 | # Initialize the router 59 | router = Router(trim_last_slash=True) 60 | 61 | 62 | Define routes: 63 | 64 | .. code:: python 65 | 66 | @router.route('/simple') 67 | def simple(): 68 | return 'result from the fn' 69 | 70 | Call the router with HTTP path and optionally method to get a match result. 71 | 72 | .. code:: python 73 | 74 | match = router('/simple', method='GET') 75 | assert match, 'HTTP path is ok' 76 | assert match.target is simple 77 | 78 | The router supports regex objects too: 79 | 80 | .. code:: python 81 | 82 | import re 83 | 84 | @router.route(re.compile(r'/regexp/\w{3}-\d{2}/?')) 85 | def regex(): 86 | return 'result from the fn' 87 | 88 | But the lib has a simplier interface for the dynamic routes: 89 | 90 | .. code:: python 91 | 92 | @router.route('/users/{username}') 93 | def users(): 94 | return 'result from the fn' 95 | 96 | By default this will capture characters up to the end of the path or the next 97 | ``/``. 98 | 99 | Optionally, you can use a converter to specify the type of the argument like 100 | ``{variable_name:converter}``. 101 | 102 | Converter types: 103 | 104 | ========= ==================================== 105 | ``str`` (default) accepts any text without a slash 106 | ``int`` accepts positive integers 107 | ``float`` accepts positive floating point values 108 | ``path`` like string but also accepts slashes 109 | ``uuid`` accepts UUID strings 110 | ========= ==================================== 111 | 112 | Convertors are used by prefixing them with a colon, like so: 113 | 114 | .. code:: python 115 | 116 | @router.route('/orders/{order_id:int}') 117 | def orders(): 118 | return 'result from the fn' 119 | 120 | Any unknown convertor will be parsed as a regex: 121 | 122 | .. code:: python 123 | 124 | @router.route('/orders/{order_id:\d{3}}') 125 | def orders(): 126 | return 'result from the fn' 127 | 128 | 129 | Multiple paths are supported as well: 130 | 131 | .. code:: python 132 | 133 | @router.route('/', '/home') 134 | def index(): 135 | return 'index' 136 | 137 | 138 | Handling HTTP methods: 139 | 140 | .. code:: python 141 | 142 | @router.route('/only-post', methods=['POST']) 143 | def only_post(): 144 | return 'only-post' 145 | 146 | 147 | Submounting routes: 148 | 149 | .. code:: python 150 | 151 | subrouter = Router() 152 | 153 | @subrouter.route('/items/{item}') 154 | def items(): 155 | pass 156 | 157 | router = Router() 158 | router.route('/api')(subrouter) 159 | 160 | 161 | match = router('/api/items/12', method='GET') 162 | assert match, 'HTTP path is ok' 163 | assert match.target is items 164 | assert match.params == {"item": "12"} 165 | 166 | 167 | .. _bugtracker: 168 | 169 | Bug tracker 170 | =========== 171 | 172 | If you have any suggestions, bug reports or 173 | annoyances please report them to the issue tracker 174 | at https://github.com/klen/http-router/issues 175 | 176 | 177 | .. _contributing: 178 | 179 | Contributing 180 | ============ 181 | 182 | Development of the project happens at: https://github.com/klen/http-router 183 | 184 | 185 | .. _license: 186 | 187 | License 188 | ======== 189 | 190 | Licensed under a `MIT license`_. 191 | 192 | 193 | .. _links: 194 | 195 | .. _klen: https://github.com/klen 196 | .. _MIT license: http://opensource.org/licenses/MIT 197 | -------------------------------------------------------------------------------- /http_router/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .exceptions import InvalidMethodError, NotFoundError, RouterError 4 | from .router import Router 5 | from .routes import DynamicRoute, Mount, PrefixedRoute, Route 6 | 7 | __all__ = ( 8 | "DynamicRoute", 9 | "Mount", 10 | "PrefixedRoute", 11 | "Route", 12 | "Router", 13 | "InvalidMethodError", 14 | "NotFoundError", 15 | "RouterError", 16 | ) 17 | -------------------------------------------------------------------------------- /http_router/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class RouterError(Exception): 5 | pass 6 | 7 | 8 | class NotFoundError(RouterError): 9 | pass 10 | 11 | 12 | class InvalidMethodError(RouterError): 13 | pass 14 | -------------------------------------------------------------------------------- /http_router/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klen/http-router/033271b20bbbec1ca1ee3a31eaabd282d18459b8/http_router/py.typed -------------------------------------------------------------------------------- /http_router/router.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | 3 | 4 | cdef class Router: 5 | 6 | cdef readonly dict plain 7 | cdef readonly list dynamic 8 | 9 | cdef public bint trim_last_slash 10 | cdef public object validator 11 | cdef public object converter 12 | -------------------------------------------------------------------------------- /http_router/router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from functools import lru_cache, partial 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | Callable, 9 | ClassVar, 10 | Optional, 11 | ) 12 | 13 | from .exceptions import InvalidMethodError, NotFoundError, RouterError 14 | from .utils import parse_path 15 | 16 | if TYPE_CHECKING: 17 | from .types import TMethodsArg, TPath, TVObj 18 | 19 | 20 | class Router: 21 | """Route HTTP queries.""" 22 | 23 | NotFoundError: ClassVar[type[Exception]] = NotFoundError 24 | RouterError: ClassVar[type[Exception]] = RouterError 25 | InvalidMethodError: ClassVar[type[Exception]] = InvalidMethodError 26 | 27 | def __init__( 28 | self, 29 | *, 30 | trim_last_slash: bool = False, 31 | validator: Optional[Callable[[Any], bool]] = None, 32 | converter: Optional[Callable] = None, 33 | ): 34 | """Initialize the router. 35 | 36 | :param trim_last_slash: Ignore a last slash 37 | :param validator: Validate objects to route 38 | :param converter: Convert objects to route 39 | 40 | """ 41 | self.trim_last_slash = trim_last_slash 42 | self.validator = validator or (lambda _: True) 43 | self.converter = converter or (lambda v: v) 44 | self.plain: defaultdict[str, list[Route]] = defaultdict(list) 45 | self.dynamic: list[Route] = [] 46 | 47 | def __call__(self, path: str, method: str = "GET") -> RouteMatch: 48 | """Found a target for the given path and method.""" 49 | if self.trim_last_slash: 50 | path = path.rstrip("/") 51 | 52 | match = self.match(path, method) 53 | if not match.path: 54 | raise self.NotFoundError(path, method) 55 | 56 | if not match.method: 57 | raise self.InvalidMethodError(path, method) 58 | 59 | return match 60 | 61 | def __getattr__(self, method: str) -> Callable: 62 | """Shortcut to the router methods.""" 63 | return partial(self.route, methods=method) 64 | 65 | def __route__(self, root: Router, prefix: str, *_, **__) -> Router: 66 | """Bind self as a nested router.""" 67 | route = Mount(prefix, set(), router=self) 68 | root.dynamic.insert(0, route) 69 | return self 70 | 71 | @lru_cache(maxsize=1024) # noqa: B019 72 | def match(self, path: str, method: str) -> RouteMatch: 73 | """Search a matched target for the given path and method.""" 74 | neighbour = None 75 | for route in self.plain.get(path, self.dynamic): 76 | match = route.match(path, method) 77 | if match.path: 78 | if match.method: 79 | return match 80 | neighbour = match 81 | 82 | return RouteMatch(path=False, method=False) if neighbour is None else neighbour 83 | 84 | def bind( 85 | self, 86 | target: Any, 87 | *paths: TPath, 88 | methods: Optional[TMethodsArg] = None, 89 | **opts, 90 | ) -> list[Route]: 91 | """Bind a target to self.""" 92 | if opts: 93 | target = partial(target, **opts) 94 | 95 | if isinstance(methods, str): 96 | methods = [methods] 97 | 98 | if methods is not None: 99 | methods = {m.upper() for m in methods or []} 100 | 101 | routes = [] 102 | 103 | for src in paths: 104 | path = src 105 | if self.trim_last_slash and isinstance(path, str): 106 | path = path.rstrip("/") 107 | 108 | path, pattern, params = parse_path(path) 109 | 110 | if pattern: 111 | route: Route = DynamicRoute( 112 | path, 113 | methods=methods, 114 | target=target, 115 | pattern=pattern, 116 | params=params, 117 | ) 118 | self.dynamic.append(route) 119 | 120 | else: 121 | route = Route(path, methods, target) 122 | self.plain[path].append(route) 123 | 124 | routes.append(route) 125 | 126 | return routes 127 | 128 | def route( 129 | self, 130 | *paths: TPath, 131 | methods: Optional[TMethodsArg] = None, 132 | **opts, 133 | ) -> Callable[[TVObj], TVObj]: 134 | """Register a route.""" 135 | 136 | def wrapper(target: TVObj) -> TVObj: 137 | if hasattr(target, "__route__"): 138 | target.__route__(self, *paths, methods=methods, **opts) 139 | return target 140 | 141 | if not self.validator(target): 142 | raise self.RouterError("Invalid target: %r" % target) 143 | 144 | target = self.converter(target) 145 | self.bind(target, *paths, methods=methods, **opts) 146 | return target 147 | 148 | return wrapper 149 | 150 | def routes(self) -> list[Route]: 151 | """Get a list of self routes.""" 152 | return sorted( 153 | self.dynamic + [r for routes in self.plain.values() for r in routes], 154 | ) 155 | 156 | 157 | from .routes import DynamicRoute, Mount, Route, RouteMatch # noqa: E402 158 | -------------------------------------------------------------------------------- /http_router/router.pyx: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from functools import lru_cache, partial 3 | from typing import Any, Callable, ClassVar, DefaultDict, List, Optional, Type, Union 4 | 5 | from .types import TMethodsArg, TPath, TVObj 6 | from .utils import parse_path 7 | from .exceptions import InvalidMethodError, NotFoundError, RouterError 8 | 9 | 10 | cdef class Router: 11 | """Route HTTP queries.""" 12 | 13 | NotFoundError: ClassVar[Type[Exception]] = NotFoundError # noqa 14 | RouterError: ClassVar[Type[Exception]] = RouterError # noqa 15 | InvalidMethodError: ClassVar[Type[Exception]] = InvalidMethodError # noqa 16 | 17 | def __init__( 18 | self, 19 | bint trim_last_slash=False, 20 | object validator=None, 21 | object converter=None 22 | ): 23 | """Initialize the router.""" 24 | self.trim_last_slash = trim_last_slash 25 | self.validator = validator or (lambda v: True) 26 | self.converter = converter or (lambda v: v) 27 | self.plain: Dict[str, List[Route]] = {} 28 | self.dynamic: List[Route] = [] 29 | 30 | def __call__(self, str path, str method="GET") -> 'RouteMatch': 31 | """Found a target for the given path and method.""" 32 | if self.trim_last_slash: 33 | path = path.rstrip('/') 34 | 35 | match = self.match(path, method) 36 | 37 | if match is None: 38 | raise self.NotFoundError(path, method) 39 | 40 | if not match.method: 41 | raise self.InvalidMethodError(path, method) 42 | 43 | return match 44 | 45 | def __route__(self, root: 'Router', prefix: str, *paths: Any, 46 | methods: TMethodsArg = None, **params): 47 | """Bind self as a nested router.""" 48 | route = Mount(prefix, set(), router=self) 49 | root.dynamic.insert(0, route) 50 | return self 51 | 52 | @lru_cache(maxsize=1024) 53 | def match(self, str path, str method) -> 'RouteMatch': 54 | """Search a matched target for the given path and method.""" 55 | cdef list routes = self.plain.get(path, self.dynamic) 56 | cdef RouteMatch match, neighbor = None 57 | cdef Route route 58 | 59 | for route in routes: 60 | match = route.match(path, method) 61 | if match.path: 62 | if match.method: 63 | return match 64 | neighbor = match 65 | 66 | return neighbor 67 | 68 | def bind(self, target: Any, *paths: TPath, methods: Optional[TMethodsArg] = None, **opts): 69 | """Bind a target to self.""" 70 | if opts: 71 | target = partial(target, **opts) 72 | 73 | if isinstance(methods, str): 74 | methods = [methods] 75 | 76 | if methods: 77 | methods = set(m.upper() for m in methods or []) 78 | 79 | routes = [] 80 | for path in paths: 81 | if self.trim_last_slash and isinstance(path, str): 82 | path = path.rstrip('/') 83 | 84 | path, pattern, params = parse_path(path) 85 | 86 | if pattern: 87 | route: Route = DynamicRoute( 88 | path, methods=methods, target=target, pattern=pattern, params=params) 89 | self.dynamic.append(route) 90 | 91 | else: 92 | route = Route(path, methods, target) 93 | self.plain.setdefault(path, []) 94 | self.plain[path].append(route) 95 | 96 | routes.append(route) 97 | 98 | return routes 99 | 100 | def route( 101 | self, 102 | *paths: TPath, 103 | methods: Optional[TMethodsArg] = None, 104 | **opts 105 | ): 106 | """Register a route.""" 107 | 108 | def wrapper(target: TVObj) -> TVObj: 109 | if hasattr(target, '__route__'): 110 | target.__route__(self, *paths, methods=methods, **opts) 111 | return target 112 | 113 | if not self.validator(target): # type: ignore 114 | raise self.RouterError('Invalid target: %r' % target) 115 | 116 | target = self.converter(target) 117 | self.bind(target, *paths, methods=methods, **opts) 118 | return target 119 | 120 | return wrapper 121 | 122 | def routes(self) -> List['Route']: 123 | """Get a list of self routes.""" 124 | return sorted(self.dynamic + [r for routes in self.plain.values() for r in routes]) 125 | 126 | def __getattr__(self, method: str) -> Callable: 127 | """Shortcut to the router methods.""" 128 | return partial(self.route, methods=method) 129 | 130 | 131 | from .routes cimport DynamicRoute, Mount, Route, RouteMatch # noqa 132 | 133 | # pylama: ignore=D 134 | -------------------------------------------------------------------------------- /http_router/routes.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | 3 | from .router cimport Router 4 | 5 | 6 | cdef class RouteMatch: 7 | 8 | cdef readonly bint path, method 9 | cdef readonly object target 10 | cdef readonly dict params 11 | 12 | 13 | cdef class Route: 14 | 15 | cdef readonly str path 16 | cdef readonly set methods 17 | cdef readonly object target 18 | 19 | cpdef RouteMatch match(self, str path, str method) 20 | 21 | 22 | cdef class DynamicRoute(Route): 23 | 24 | cdef readonly object pattern 25 | cdef readonly dict params 26 | 27 | 28 | cdef class PrefixedRoute(Route): 29 | 30 | pass 31 | 32 | 33 | cdef class Mount(PrefixedRoute): 34 | 35 | pass 36 | -------------------------------------------------------------------------------- /http_router/routes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Pattern, cast 4 | from urllib.parse import unquote 5 | 6 | from .router import Router 7 | from .utils import identity, parse_path 8 | 9 | if TYPE_CHECKING: 10 | from .types import TMethods 11 | 12 | 13 | class RouteMatch: 14 | """Keeping route matching data.""" 15 | 16 | __slots__ = "path", "method", "target", "params" 17 | 18 | def __init__( 19 | self, 20 | path: bool, 21 | method: bool, 22 | target=None, 23 | params: Optional[Mapping[str, Any]] = None, 24 | ): 25 | self.path = path 26 | self.method = method 27 | self.target = target 28 | self.params = params 29 | 30 | def __bool__(self): 31 | return self.path and self.method 32 | 33 | def __repr__(self): 34 | return f"" 35 | 36 | 37 | class Route: 38 | """Base plain route class.""" 39 | 40 | __slots__ = "path", "methods", "target" 41 | 42 | def __init__( 43 | self, path: str, methods: Optional[TMethods] = None, target: Any = None, 44 | ): 45 | self.path = path 46 | self.methods = methods 47 | self.target = target 48 | 49 | def __lt__(self, route: "Route") -> bool: 50 | assert isinstance(route, Route), "Only routes are supported" 51 | return self.path < route.path 52 | 53 | def match(self, path: str, method: str) -> RouteMatch: 54 | """Is the route match the path.""" 55 | methods = self.methods 56 | return RouteMatch( 57 | path == self.path, methods is None or (method in methods), self.target, 58 | ) 59 | 60 | 61 | class DynamicRoute(Route): 62 | """Base dynamic route class.""" 63 | 64 | __slots__ = "path", "methods", "target", "pattern", "params" 65 | 66 | def __init__( 67 | self, 68 | path: str, 69 | methods: Optional[TMethods] = None, 70 | target: Any = None, 71 | pattern: Optional[Pattern] = None, 72 | params: Optional[dict] = None, 73 | ): 74 | if pattern is None: 75 | path, pattern, params = parse_path(path) 76 | assert pattern, "Invalid path" 77 | self.pattern = pattern 78 | self.params = params or {} 79 | super(DynamicRoute, self).__init__(path, methods, target) 80 | 81 | def match(self, path: str, method: str) -> RouteMatch: 82 | match = self.pattern.match(path) 83 | if not match: 84 | return RouteMatch(False, False) 85 | 86 | return RouteMatch( 87 | True, 88 | not self.methods or method in self.methods, 89 | self.target, 90 | { 91 | key: self.params.get(key, identity)(unquote(value)) 92 | for key, value in match.groupdict().items() 93 | }, 94 | ) 95 | 96 | 97 | class PrefixedRoute(Route): 98 | """Match by a prefix.""" 99 | 100 | def __init__( 101 | self, path: str, methods: Optional[TMethods] = None, target: Any = None, 102 | ): 103 | path, pattern, _ = parse_path(path) 104 | if pattern: 105 | assert not pattern, "Prefix doesn't support patterns." 106 | 107 | super(PrefixedRoute, self).__init__(path.rstrip("/"), methods, target) 108 | 109 | def match(self, path: str, method: str) -> RouteMatch: 110 | """Is the route match the path.""" 111 | methods = self.methods 112 | return RouteMatch( 113 | path.startswith(self.path), not methods or (method in methods), self.target, 114 | ) 115 | 116 | 117 | class Mount(PrefixedRoute): 118 | """Support for nested routers.""" 119 | 120 | def __init__( 121 | self, 122 | path: str, 123 | methods: Optional[TMethods] = None, 124 | router: Optional[Router] = None, 125 | ): 126 | """Validate self prefix.""" 127 | router = router or Router() 128 | super(Mount, self).__init__(path, methods, router.match) 129 | 130 | def match(self, path: str, method: str) -> RouteMatch: 131 | """Is the route match the path.""" 132 | match: RouteMatch = super(Mount, self).match(path, method) 133 | if match: 134 | target = cast(Callable, self.target) 135 | return target(path[len(self.path) :], method) 136 | 137 | return match 138 | 139 | # ruff: noqa: FBT001, FBT003, PLR0913 140 | -------------------------------------------------------------------------------- /http_router/routes.pyx: -------------------------------------------------------------------------------- 1 | from typing import Pattern, Union 2 | from urllib.parse import unquote 3 | 4 | from .router import Router 5 | from .utils import parse_path, identity 6 | 7 | 8 | cdef class RouteMatch: 9 | """Keeping route matching data.""" 10 | 11 | def __cinit__(self, bint path, bint method, object target=None, dict params=None): 12 | self.path = path 13 | self.method = method 14 | self.target = target 15 | self.params = params 16 | 17 | def __bool__(self) -> bool: 18 | return self.path and self.method 19 | 20 | 21 | cdef class Route: 22 | """Base plain route class.""" 23 | 24 | def __init__(self, str path, set methods, object target=None): 25 | self.path = path 26 | self.methods = methods 27 | self.target = target 28 | 29 | def __lt__(self, Route route) -> bool: 30 | return self.path < route.path 31 | 32 | cpdef RouteMatch match(self, str path, str method): 33 | """Is the route match the path.""" 34 | cdef bint path_ = self.path == path 35 | cdef set methods = self.methods 36 | cdef bint method_ = not methods or method in methods 37 | if not (path_ and method_): 38 | return RouteMatch(path_, method_) 39 | 40 | return RouteMatch(path_, method_, self.target) 41 | 42 | 43 | cdef class DynamicRoute(Route): 44 | """Base dynamic route class.""" 45 | 46 | def __init__(self, path: Union[str, Pattern], set methods, 47 | object target=None, pattern: Pattern = None, dict params = None): 48 | 49 | if pattern is None: 50 | path, pattern, params = parse_path(path) 51 | assert pattern, 'Invalid path' 52 | 53 | self.pattern = pattern 54 | self.params = params 55 | self.path = path 56 | self.methods = methods 57 | self.target = target 58 | 59 | cpdef RouteMatch match(self, str path, str method): 60 | match = self.pattern.match(path) # type: ignore # checked in __post_init__ 61 | if not match: 62 | return RouteMatch(False, False) 63 | 64 | cdef bint method_ = not self.methods or method in self.methods 65 | cdef dict path_params = { 66 | key: self.params.get(key, identity)(unquote(value)) 67 | for key, value in match.groupdict().items() 68 | } 69 | 70 | return RouteMatch(True, method_, self.target, path_params) 71 | 72 | 73 | cdef class PrefixedRoute(Route): 74 | """Match by a prefix.""" 75 | 76 | def __init__(self, str path, set methods, object target=None): 77 | path, pattern, _ = parse_path(path) 78 | if pattern: 79 | assert not pattern, "Prefix doesn't support patterns." 80 | 81 | super(PrefixedRoute, self).__init__(path.rstrip('/'), methods, target) 82 | 83 | cpdef RouteMatch match(self, str path, str method): 84 | """Is the route match the path.""" 85 | cdef set methods = self.methods 86 | return RouteMatch( 87 | path.startswith(self.path), not methods or (method in methods), self.target) 88 | 89 | 90 | cdef class Mount(PrefixedRoute): 91 | """Support for nested routers.""" 92 | 93 | def __init__(self, str path, set methods, Router router=None): 94 | """Validate self prefix.""" 95 | router = router or Router() 96 | super(Mount, self).__init__(path, methods, router.match) 97 | 98 | cpdef RouteMatch match(self, str path, str method): 99 | """Is the route match the path.""" 100 | cdef RouteMatch match = super(Mount, self).match(path, method) 101 | if match.path and match.method: 102 | return self.target(path[len(self.path):], method) 103 | 104 | return match 105 | -------------------------------------------------------------------------------- /http_router/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Iterable, Pattern, TypeVar, Union 4 | 5 | TMethods = Iterable[str] 6 | TMethodsArg = Union[TMethods, str] 7 | TPath = Union[str, Pattern] 8 | TVObj = TypeVar("TVObj", bound=Any) 9 | -------------------------------------------------------------------------------- /http_router/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING, Optional, Pattern 5 | from uuid import UUID 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import Callable 9 | 10 | from .types import TPath, TVObj 11 | 12 | def identity(v: TVObj) -> TVObj: 13 | """Identity function.""" 14 | return v 15 | 16 | 17 | VAR_RE = re.compile(r"^(?P[a-zA-Z][_a-zA-Z0-9]*)(?::(?P.+))?$") 18 | VAR_TYPES = { 19 | "float": (r"\d+(\.\d+)?", float), 20 | "int": (r"\d+", int), 21 | "path": (r".*", str), 22 | "str": (r"[^/]+", str), 23 | "uuid": (r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", UUID), 24 | } 25 | 26 | 27 | def parse_path(path: TPath) -> tuple[str, Optional[Pattern], dict[str, Callable]]: 28 | """Prepare the given path to regexp it.""" 29 | if isinstance(path, Pattern): 30 | return path.pattern, path, {} 31 | 32 | src, regex, path = path.strip(" "), "^", "" 33 | params: dict[str, Callable] = {} 34 | idx, cur, group = 0, 0, None 35 | while cur < len(src): 36 | sym = src[cur] 37 | cur += 1 38 | 39 | if sym == "{": 40 | if group: 41 | cur = src.find("}", cur) + 1 42 | continue 43 | 44 | group = cur 45 | continue 46 | 47 | if sym == "}" and group: 48 | part = src[group : cur - 1] 49 | length = len(part) 50 | match = VAR_RE.match(part.strip()) 51 | if match: 52 | opts = match.groupdict("str") 53 | var_type_re, params[opts["var"]] = VAR_TYPES.get( 54 | opts["var_type"], (opts["var_type"], identity), 55 | ) 56 | regex += ( 57 | re.escape(src[idx : group - 1]) 58 | + f"(?P<{ opts['var'] }>{ var_type_re })" 59 | ) 60 | path += src[idx : group - 1] + f"{{{opts['var']}}}" 61 | cur = idx = group + length + 1 62 | 63 | group = None 64 | 65 | if not path: 66 | return src, None, params 67 | 68 | regex += re.escape(src[idx:]) + "$" 69 | path += src[idx:] 70 | return path, re.compile(regex), params 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "Cython"] 3 | 4 | [project] 5 | name = "http-router" 6 | version = "5.0.8" 7 | description = "A simple router system for HTTP applications" 8 | readme = "README.rst" 9 | requires-python = ">=3.9" 10 | license = { "text" = "MIT License" } 11 | authors = [{ name = "Kirill Klenov", email = "horneds@gmail.com" }] 12 | keywords = ["http", "router", "web", "framework"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: Implementation :: PyPy", 25 | "Programming Language :: Cython", 26 | "Topic :: Internet :: WWW/HTTP", 27 | ] 28 | 29 | [project.urls] 30 | homepage = "https://github.com/klen/http-router" 31 | repository = "https://github.com/klen/http-router" 32 | documentation = "https://github.com/klen/http-router" 33 | 34 | [project.optional-dependencies] 35 | tests = ["ruff", "pytest", "pytest-benchmark", "pytest-mypy; implementation_name == 'cpython'"] 36 | dev = ["bump2version", "tox", "pre-commit"] 37 | 38 | [tool.setuptools] 39 | packages = ['http_router'] 40 | 41 | [tool.setuptools.package-data] 42 | http_router = ["py.typed", "router.pxd", "router.pyx", "routes.pxd", "routes.pyx"] 43 | 44 | [tool.pytest.ini_options] 45 | addopts = "-xsv" 46 | testpaths = "tests.py" 47 | 48 | [tool.pylama] 49 | ignore = "D" 50 | 51 | [tool.mypy] 52 | packages = ["http_router"] 53 | 54 | [tool.tox] 55 | legacy_tox_ini = """ 56 | [tox] 57 | envlist = py39,py310,py311,py312,py313,pypy310 58 | 59 | [testenv] 60 | deps = -e .[tests] 61 | commands = 62 | pytest --mypy 63 | 64 | [testenv:pypy310] 65 | deps = -e .[tests] 66 | commands = 67 | pytest 68 | """ 69 | 70 | [tool.ruff] 71 | fix = false 72 | line-length = 100 73 | target-version = "py39" 74 | exclude = [".venv", "docs", "examples"] 75 | 76 | [tool.ruff.lint] 77 | select = ["ALL"] 78 | ignore = ["D", "UP", "ANN", "DJ", "EM", "RSE", "SLF", "S101", "PLR2004"] 79 | isort.required-imports = ["from __future__ import annotations"] 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup the library.""" 2 | 3 | import os 4 | import sys 5 | 6 | from setuptools import setup 7 | 8 | try: 9 | from Cython.Build import cythonize 10 | except ImportError: 11 | cythonize = None 12 | 13 | 14 | NO_EXTENSIONS = ( 15 | sys.implementation.name != "cpython" 16 | or bool(os.environ.get("HTTP_ROUTER_NO_EXTENSIONS")) 17 | or cythonize is None 18 | ) 19 | 20 | print("*********************") 21 | print("* Pure Python build *" if NO_EXTENSIONS else "* Accelerated build *") 22 | print("*********************") 23 | 24 | setup( 25 | setup_requires=["wheel"], 26 | ext_modules=[] 27 | if NO_EXTENSIONS or cythonize is None 28 | else cythonize("http_router/*.pyx", language_level=3), 29 | ) 30 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | """HTTP Router tests.""" 2 | from __future__ import annotations 3 | 4 | import inspect 5 | from contextlib import suppress 6 | from re import compile as re 7 | from typing import TYPE_CHECKING, Pattern 8 | 9 | import pytest 10 | 11 | if TYPE_CHECKING: 12 | from http_router import Router 13 | 14 | 15 | @pytest.fixture() 16 | def router() -> "Router": 17 | from http_router import Router 18 | 19 | return Router() 20 | 21 | 22 | def test_router_basic(router: "Router"): 23 | assert router 24 | assert not router.trim_last_slash 25 | assert router.validator is not None 26 | assert router.NotFoundError is not None 27 | assert router.RouterError is not None 28 | assert router.InvalidMethodError is not None 29 | 30 | router.trim_last_slash = True 31 | assert router.trim_last_slash 32 | 33 | 34 | def test_router_route_re(router: "Router"): 35 | res = router.route(re("test.jpg"))("test1 passed") 36 | assert res == "test1 passed" 37 | 38 | assert router("test.jpg").target == "test1 passed" 39 | assert router("testAjpg").target == "test1 passed" 40 | assert router("testAjpg/regex/can/be/dangerous").target == "test1 passed" 41 | 42 | router.route(re(r"params/(\w+)"))("test2 passed") 43 | match = router("params/mike") 44 | assert match 45 | assert not match.params 46 | 47 | router.route(re(r"params2/(?P\w+)"))("test3 passed") 48 | match = router("params2/mike") 49 | assert match 50 | assert match.params == {"User": "mike"} 51 | 52 | 53 | def test_router_route_str(router): 54 | router.route("test.jpg")(True) 55 | match = router("test.jpg") 56 | assert match 57 | 58 | with pytest.raises(router.NotFoundError): 59 | router("test.jpeg") 60 | 61 | router.route("/any/{item}")(True) 62 | match = router("/any/test") 63 | assert match 64 | assert match.params == {"item": "test"} 65 | 66 | router.route("/str/{item:str}")(True) 67 | match = router("/str/42") 68 | assert match 69 | assert match.params == {"item": "42"} 70 | 71 | router.route("/int/{item:int}")(True) 72 | match = router("/int/42") 73 | assert match 74 | assert match.params == {"item": 42} 75 | 76 | router.route(r"/regex/{item:\d{3}}")(True) 77 | match = router("/regex/422") 78 | assert match 79 | assert match.params == {"item": "422"} 80 | 81 | 82 | def test_parse_path(): 83 | from http_router.utils import parse_path 84 | 85 | assert parse_path("/") == ("/", None, {}) 86 | assert parse_path("/test.jpg") == ("/test.jpg", None, {}) 87 | assert parse_path("/{foo") == ("/{foo", None, {}) 88 | 89 | path, regex, params = parse_path(r"/{foo}/") 90 | assert isinstance(regex, Pattern) 91 | assert regex.pattern == r"^/(?P[^/]+)/$" 92 | assert path == "/{foo}/" 93 | assert params == {"foo": str} 94 | 95 | path, regex, params = parse_path(r"/{foo:int}/") 96 | assert isinstance(regex, Pattern) 97 | assert regex.pattern == r"^/(?P\d+)/$" 98 | assert path == "/{foo}/" 99 | assert params == {"foo": int} 100 | 101 | path, regex, params = parse_path(re(r"/(?P\d{1,3})/")) 102 | assert isinstance(regex, Pattern) 103 | assert params == {} 104 | assert path 105 | 106 | path, regex, params = parse_path( 107 | r"/api/v1/items/{item:str}/subitems/{ subitem:\d{3} }/find", 108 | ) 109 | assert path == "/api/v1/items/{item}/subitems/{subitem}/find" 110 | assert regex.match("/api/v1/items/foo/subitems/300/find") 111 | assert params["item"] 112 | assert params["subitem"] 113 | 114 | 115 | def test_route(): 116 | from http_router.routes import Route 117 | 118 | route = Route("/only-post", {"POST"}, None) 119 | assert route.methods 120 | assert route.match("/only-post", "POST") 121 | assert not route.match("/only-post", "") 122 | 123 | route = Route("/only-post", set(), None) 124 | assert not route.methods 125 | 126 | 127 | def test_dynamic_route(): 128 | from http_router.routes import DynamicRoute 129 | 130 | route = DynamicRoute(r"/order/{id:int}", set(), None) 131 | match = route.match("/order/100", "") 132 | assert match 133 | assert match.params == {"id": 100} 134 | 135 | match = route.match("/order/unknown", "") 136 | assert not match 137 | assert not match.params 138 | 139 | route = DynamicRoute(re("/regex(/opt)?"), set(), None) 140 | match = route.match("/regex", "") 141 | assert match 142 | 143 | match = route.match("/regex/opt", "") 144 | assert match 145 | 146 | 147 | def test_router(): 148 | """Base tests.""" 149 | from http_router import Router 150 | 151 | router = Router(trim_last_slash=True) 152 | 153 | with pytest.raises(router.NotFoundError): 154 | assert router("/unknown") 155 | 156 | router.route("/", "/simple")("simple") 157 | 158 | match = router("/", "POST") 159 | assert match.target == "simple" 160 | assert not match.params 161 | 162 | match = router("/simple", "DELETE") 163 | assert match.target == "simple" 164 | assert not match.params 165 | 166 | router.route("/only-post", methods="post")("only-post") 167 | assert router.plain["/only-post"][0].methods == {"POST"} 168 | 169 | with pytest.raises(router.InvalidMethodError): 170 | assert router("/only-post") 171 | 172 | match = router("/only-post", "POST") 173 | assert match.target == "only-post" 174 | assert not match.params 175 | 176 | router.route("/dynamic1/{id}")("dyn1") 177 | router.route("/dynamic2/{ id }")("dyn2") 178 | 179 | match = router("/dynamic1/11/") 180 | assert match.target == "dyn1" 181 | assert match.params == {"id": "11"} 182 | 183 | match = router("/dynamic2/22/") 184 | assert match.target == "dyn2" 185 | assert match.params == {"id": "22"} 186 | 187 | @router.route(r"/hello/{name:str}", methods="post") 188 | def hello(): 189 | return "hello" 190 | 191 | match = router("/hello/john/", "POST") 192 | assert match.target() == "hello" 193 | assert match.params == {"name": "john"} 194 | 195 | @router.route("/params", var="value") 196 | def params(**opts): 197 | return opts 198 | 199 | match = router("/params", "POST") 200 | assert match.target() == {"var": "value"} 201 | 202 | assert router.routes() 203 | assert router.routes()[0].path == "" 204 | 205 | 206 | def test_mounts(): 207 | from http_router import Router 208 | from http_router.routes import Mount 209 | 210 | router = Router() 211 | route = Mount("/api/", set(), router) 212 | assert route.path == "/api" 213 | match = route.match("/api/e1", "") 214 | assert not match 215 | 216 | router.route("/e1")("e1") 217 | match = route.match("/api/e1", "UNKNOWN") 218 | assert match 219 | assert match.target == "e1" 220 | 221 | root = Router() 222 | subrouter = Router() 223 | 224 | root.route("/api")(1) 225 | root.route(re("/api/test"))(2) 226 | root.route("/api")(subrouter) 227 | subrouter.route("/test")(3) 228 | 229 | assert root("/api").target == 1 230 | assert root("/api/test").target == 3 231 | 232 | 233 | def test_trim_last_slash(): 234 | from http_router import Router 235 | 236 | router = Router() 237 | 238 | router.route("/route1")("route1") 239 | router.route("/route2/")("route2") 240 | 241 | assert router("/route1").target == "route1" 242 | assert router("/route2/").target == "route2" 243 | 244 | with pytest.raises(router.NotFoundError): 245 | assert not router("/route1/") 246 | 247 | with pytest.raises(router.NotFoundError): 248 | assert not router("/route2") 249 | 250 | router = Router(trim_last_slash=True) 251 | 252 | router.route("/route1")("route1") 253 | router.route("/route2/")("route2") 254 | 255 | assert router("/route1").target == "route1" 256 | assert router("/route2/").target == "route2" 257 | assert router("/route1/").target == "route1" 258 | assert router("/route2").target == "route2" 259 | 260 | 261 | def test_validator(): 262 | from http_router import Router 263 | 264 | # The router only accepts async functions 265 | router = Router(validator=inspect.iscoroutinefunction) 266 | 267 | with pytest.raises(router.RouterError): 268 | router.route("/", "/simple")(lambda: "simple") 269 | 270 | 271 | def test_converter(): 272 | from http_router import Router 273 | 274 | # The router only accepts async functions 275 | router = Router(converter=lambda v: lambda r: (r, v)) 276 | 277 | router.route("/")("simple") 278 | match = router("/") 279 | assert match.target("test") == ("test", "simple") 280 | 281 | 282 | def test_custom_route(): 283 | from http_router import Router 284 | 285 | router = Router() 286 | 287 | @router.route() 288 | class View: 289 | methods = "get", "post" 290 | 291 | def __new__(cls, *args, **kwargs): 292 | """Init the class and call it.""" 293 | self = super().__new__(cls) 294 | return self(*args, **kwargs) 295 | 296 | @classmethod 297 | def __route__(cls, router, *paths, **params): 298 | return router.bind(cls, "/", methods=cls.methods) 299 | 300 | assert router.plain["/"][0].methods == {"GET", "POST"} 301 | match = router("/") 302 | assert match.target is View 303 | 304 | 305 | def test_nested_routers(): 306 | from http_router import Router 307 | 308 | child = Router() 309 | child.route("/url", methods="PATCH")("child_url") 310 | match = child("/url", "PATCH") 311 | assert match.target == "child_url" 312 | 313 | root = Router() 314 | root.route("/child")(child) 315 | 316 | with pytest.raises(root.NotFoundError): 317 | root("/child") 318 | 319 | with pytest.raises(root.NotFoundError): 320 | root("/child/unknown") 321 | 322 | with pytest.raises(root.InvalidMethodError): 323 | root("/child/url") 324 | 325 | match = root("/child/url", "PATCH") 326 | assert match.target == "child_url" 327 | 328 | 329 | def test_readme(): 330 | from http_router import Router 331 | 332 | router = Router(trim_last_slash=True) 333 | 334 | @router.route("/simple") 335 | def simple(): 336 | return "simple" 337 | 338 | match = router("/simple") 339 | assert match.target() == "simple" 340 | assert match.params is None 341 | 342 | 343 | def test_method_shortcuts(router): 344 | router.delete("/delete")("DELETE") 345 | router.get("/get")("GET") 346 | router.post("/post")("POST") 347 | 348 | for route in router.routes(): 349 | method = route.target 350 | assert route.methods == {method} 351 | 352 | 353 | def test_benchmark(router, benchmark): 354 | import random 355 | import string 356 | 357 | chars = string.ascii_letters + string.digits 358 | randpath = lambda: "".join(random.choices(chars, k=10)) # noqa: E731 359 | methods = "GET", "POST" 360 | 361 | routes = [f"/{ randpath() }/{ randpath() }" for _ in range(100)] 362 | routes += [f"/{ randpath() }/{{item}}/{ randpath() }" for _ in range(100)] 363 | random.shuffle(routes) 364 | 365 | paths = [] 366 | for route in routes: 367 | router.route(route, methods=random.choice(methods))("OK") 368 | paths.append(route.format(item=randpath())) 369 | 370 | paths = [route.format(item=randpath()) for route in routes] 371 | 372 | def do_work(): 373 | for path in paths: 374 | with suppress(router.InvalidMethodError): 375 | assert router(path) 376 | 377 | benchmark(do_work) 378 | 379 | 380 | def test_readme_examples(): 381 | from http_router import Router 382 | 383 | subrouter = Router() 384 | 385 | @subrouter.route("/items/{item}") 386 | def items(): 387 | pass 388 | 389 | router = Router() 390 | router.route("/api")(subrouter) 391 | 392 | match = router("/api/items/12", method="GET") 393 | assert match, "HTTP path is ok" 394 | assert match.target is items 395 | assert match.params == {"item": "12"} 396 | 397 | 398 | # ruff: noqa: FBT003, S311 399 | --------------------------------------------------------------------------------