Router path substitution #1

Merged
nikto_b merged 4 commits from path-substitute-feature into master 2025-07-19 04:13:44 +03:00
2 changed files with 75 additions and 10 deletions
Showing only changes of commit 2943351442 - Show all commits

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import re
import typing import typing
from typing import Optional, Sequence from typing import Optional, Sequence
from pathspec import Pattern
from .exceptions import MethodNotAllowedException, NotFoundException from .exceptions import MethodNotAllowedException, NotFoundException
from .types import InternalHandlerType from .types import InternalHandlerType
from .internal_types import MethodType from .internal_types import MethodType
@@ -10,31 +13,68 @@ from .internal_types import MethodType
class Route: class Route:
static_subroutes: dict[str, Route] static_subroutes: dict[str, Route]
regexp_subroutes: list[tuple[Pattern, Route]]
handler: dict[MethodType, InternalHandlerType] handler: dict[MethodType, InternalHandlerType]
def __init__(self) -> None: def __init__(self) -> None:
self.static_subroutes = {} self.static_subroutes = {}
self.regexp_subroutes = []
self.handler = {} self.handler = {}
def _find_regexp_subroute(self, p: Pattern) -> Optional[Route]:
for _p, _r in self.regexp_subroutes:
if _p == p:
return _r
return None
def _add_regexp_subroute(self, p: Pattern, r: Route):
if self._find_regexp_subroute(p) is None:
self.regexp_subroutes.append((p, r))
self.regexp_subroutes.sort(key=lambda x: len(x[0].pattern), reverse=True)
def add(self, method: MethodType, sequence: Sequence[str], handler: InternalHandlerType) -> None: def add(self, method: MethodType, sequence: Sequence[str], handler: InternalHandlerType) -> None:
if len(sequence) == 0: if len(sequence) == 0:
self.handler[method] = handler self.handler[method] = handler
return return
subroute = self.static_subroutes.get(sequence[0]) part = sequence[0]
if subroute is None:
subroute = Route() if '{' in part:
self.static_subroutes[sequence[0]] = subroute if '}' not in part:
raise ValueError(f'Invalid subpath substitute placeholder: {part}')
re_part = part.replace('(', '\\(').replace(')', '\\)')
re_part = re.sub(r'\{.+}', r'(.+)', re_part)
re_part = re.compile('^' + re_part + '$')
subroute = self._find_regexp_subroute(re_part)
if subroute is None:
subroute = Route()
self._add_regexp_subroute(re_part, subroute)
else:
subroute = self.static_subroutes.get(part)
if subroute is None:
subroute = Route()
self.static_subroutes[part] = subroute
subroute.add(method, sequence[1:], handler) subroute.add(method, sequence[1:], handler)
def get(self, method: MethodType, sequence: Sequence[str]) -> Optional[InternalHandlerType]: def get(self, method: MethodType, sequence: Sequence[str]) -> Optional[InternalHandlerType]:
if len(sequence) == 0: if len(sequence) == 0:
if len(self.handler) == 0:
raise NotFoundException('')
ret = self.handler.get(method) ret = self.handler.get(method)
if ret is None: if ret is None:
raise MethodNotAllowedException(', '.join(map(str, self.handler.keys()))) raise MethodNotAllowedException(', '.join(map(str, self.handler.keys())))
return ret return ret
subroute = self.static_subroutes.get(sequence[0]) subroute = self.static_subroutes.get(sequence[0])
if subroute is None:
for p, sr in self.regexp_subroutes:
m = p.match(sequence[0])
if m is not None:
subroute = sr
if subroute is None: if subroute is None:
raise NotFoundException('/'.join(sequence)) raise NotFoundException('/'.join(sequence))
return subroute.get(method, sequence[1:]) return subroute.get(method, sequence[1:])

View File

@@ -5,13 +5,13 @@ from src.turbosloth.router import Router
def test_router_root_handler(): def test_router_root_handler():
async def f(_: dict, *args): async def f(*args):
pass pass
async def d(_: dict, *args): async def d(*args):
pass pass
async def a(_: dict, *args): async def a(*args):
pass pass
r = Router() r = Router()
@@ -38,10 +38,10 @@ def test_router_root_handler():
def test_router_match(): def test_router_match():
async def f(_: dict, *args): async def f(*args):
pass pass
async def d(_: dict, *args): async def d(*args):
pass pass
r = Router() r = Router()
@@ -52,5 +52,30 @@ def test_router_match():
with pytest.raises(MethodNotAllowedException, match=f'405\tMethod Not Allowed: POST /asdf, allowed: GET'): with pytest.raises(MethodNotAllowedException, match=f'405\tMethod Not Allowed: POST /asdf, allowed: GET'):
r.match('POST', 'asdf') r.match('POST', 'asdf')
with pytest.raises(MethodNotAllowedException, match=f'405\tMethod Not Allowed: POST /, allowed: none'): with pytest.raises(NotFoundException, match=f'404\tNot Found'):
r.match('POST', '') r.match('POST', '')
def test_router_pattern_match():
async def f(*args):
pass
r = Router()
r.add('GET', '/{some}/asdf', f)
r.add('GET', '/{some}/b{some1}c', f)
assert r.match('GET', '/1234/asdf')
assert r.match('GET', '/ /asdf')
assert r.match('GET', '/ /basdfc')
with pytest.raises(NotFoundException, match='404\tNot Found: asd'):
r.match('GET', 'asd')
with pytest.raises(NotFoundException, match='404\tNot Found: asd'):
r.match('GET', 'asd/')
with pytest.raises(NotFoundException, match='404\tNot Found: asd'):
r.match('GET', 'asd/b')
with pytest.raises(NotFoundException, match='404\tNot Found: asd'):
r.match('GET', 'asd/basdf')