Skip to content

Commit ec9b06f

Browse files
authored
Merge pull request #76 from michalpokusa/routes-refactor
`Route` refactor and fixes, CPython example
2 parents 8e0b86a + e4c05ad commit ec9b06f

File tree

5 files changed

+172
-130
lines changed

5 files changed

+172
-130
lines changed

adafruit_httpserver/route.py

+65-86
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010
try:
11-
from typing import Callable, List, Iterable, Union, Tuple, Dict, TYPE_CHECKING
11+
from typing import Callable, Iterable, Union, Tuple, Literal, Dict, TYPE_CHECKING
1212

1313
if TYPE_CHECKING:
1414
from .response import Response
@@ -23,6 +23,24 @@
2323
class Route:
2424
"""Route definition for different paths, see `adafruit_httpserver.server.Server.route`."""
2525

26+
@staticmethod
27+
def _prepare_path_pattern(path: str, append_slash: bool) -> str:
28+
# Escape all dots
29+
path = re.sub(r"\.", r"\\.", path)
30+
31+
# Replace url parameters with regex groups
32+
path = re.sub(r"<\w+>", r"([^/]+)", path)
33+
34+
# Replace wildcards with corresponding regex
35+
path = path.replace(r"\.\.\.\.", r".+").replace(r"\.\.\.", r"[^/]+")
36+
37+
# Add optional slash at the end if append_slash is True
38+
if append_slash:
39+
path += r"/?"
40+
41+
# Add start and end of string anchors
42+
return f"^{path}$"
43+
2644
def __init__(
2745
self,
2846
path: str = "",
@@ -33,80 +51,89 @@ def __init__(
3351
) -> None:
3452
self._validate_path(path, append_slash)
3553

36-
self.parameters_names = [
37-
name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != ""
38-
]
39-
self.path = re.sub(r"<\w+>", r"([^/]+)", path).replace("....", r".+").replace(
40-
"...", r"[^/]+"
41-
) + ("/?" if append_slash else "")
54+
self.path = path
4255
self.methods = (
4356
set(methods) if isinstance(methods, (set, list, tuple)) else set([methods])
4457
)
45-
4658
self.handler = handler
59+
self.parameters_names = [
60+
name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != ""
61+
]
62+
self.path_pattern = re.compile(self._prepare_path_pattern(path, append_slash))
4763

4864
@staticmethod
4965
def _validate_path(path: str, append_slash: bool) -> None:
5066
if not path.startswith("/"):
5167
raise ValueError("Path must start with a slash.")
5268

69+
if path.endswith("/") and append_slash:
70+
raise ValueError("Cannot use append_slash=True when path ends with /")
71+
72+
if "//" in path:
73+
raise ValueError("Path cannot contain double slashes.")
74+
5375
if "<>" in path:
5476
raise ValueError("All URL parameters must be named.")
5577

56-
if path.endswith("/") and append_slash:
57-
raise ValueError("Cannot use append_slash=True when path ends with /")
78+
if re.search(r"[^/]<[^/]+>|<[^/]+>[^/]", path):
79+
raise ValueError("All URL parameters must be between slashes.")
80+
81+
if re.search(r"[^/.]\.\.\.\.?|\.?\.\.\.[^/.]", path):
82+
raise ValueError("... and .... must be between slashes")
5883

59-
def match(self, other: "Route") -> Tuple[bool, Dict[str, str]]:
84+
if "....." in path:
85+
raise ValueError("Path cannot contain more than 4 dots in a row.")
86+
87+
def matches(
88+
self, method: str, path: str
89+
) -> Union[Tuple[Literal[False], None], Tuple[Literal[True], Dict[str, str]]]:
6090
"""
61-
Checks if the route matches the other route.
91+
Checks if the route matches given ``method`` and ``path``.
6292
63-
If the route contains parameters, it will check if the ``other`` route contains values for
93+
If the route contains parameters, it will check if the ``path`` contains values for
6494
them.
6595
66-
Returns tuple of a boolean and a list of strings. The boolean indicates if the routes match,
67-
and the list contains the values of the url parameters from the ``other`` route.
96+
Returns tuple of a boolean that indicates if the routes matches and a dict containing
97+
values for url parameters.
98+
If the route does not match ``path`` or ``method`` if will return ``None`` instead of dict.
6899
69100
Examples::
70101
71-
route = Route("/example", GET, True)
102+
route = Route("/example", GET, append_slash=True)
72103
73-
other1a = Route("/example", GET)
74-
other1b = Route("/example/", GET)
75-
route.matches(other1a) # True, {}
76-
route.matches(other1b) # True, {}
104+
route.matches(GET, "/example") # True, {}
105+
route.matches(GET, "/example/") # True, {}
77106
78-
other2 = Route("/other-example", GET)
79-
route.matches(other2) # False, {}
107+
route.matches(GET, "/other-example") # False, None
108+
route.matches(POST, "/example/") # False, None
80109
81110
...
82111
83112
route = Route("/example/<parameter>", GET)
84113
85-
other1 = Route("/example/123", GET)
86-
route.matches(other1) # True, {"parameter": "123"}
114+
route.matches(GET, "/example/123") # True, {"parameter": "123"}
87115
88-
other2 = Route("/other-example", GET)
89-
route.matches(other2) # False, {}
116+
route.matches(GET, "/other-example") # False, None
90117
91118
...
92119
93-
route1 = Route("/example/.../something", GET)
94-
other1 = Route("/example/123/something", GET)
95-
route1.matches(other1) # True, {}
120+
route = Route("/example/.../something", GET)
121+
route.matches(GET, "/example/123/something") # True, {}
96122
97-
route2 = Route("/example/..../something", GET)
98-
other2 = Route("/example/123/456/something", GET)
99-
route2.matches(other2) # True, {}
123+
route = Route("/example/..../something", GET)
124+
route.matches(GET, "/example/123/456/something") # True, {}
100125
"""
101126

102-
if not other.methods.issubset(self.methods):
103-
return False, {}
127+
if method not in self.methods:
128+
return False, None
129+
130+
path_match = self.path_pattern.match(path)
131+
if path_match is None:
132+
return False, None
104133

105-
regex_match = re.match(f"^{self.path}$", other.path)
106-
if regex_match is None:
107-
return False, {}
134+
url_parameters_values = path_match.groups()
108135

109-
return True, dict(zip(self.parameters_names, regex_match.groups()))
136+
return True, dict(zip(self.parameters_names, url_parameters_values))
110137

111138
def __repr__(self) -> str:
112139
path = repr(self.path)
@@ -168,51 +195,3 @@ def route_decorator(func: Callable) -> Route:
168195
return Route(path, methods, func, append_slash=append_slash)
169196

170197
return route_decorator
171-
172-
173-
class _Routes:
174-
"""A collection of routes and their corresponding handlers."""
175-
176-
def __init__(self) -> None:
177-
self._routes: List[Route] = []
178-
179-
def add(self, route: Route):
180-
"""Adds a route and its handler to the collection."""
181-
self._routes.append(route)
182-
183-
def find_handler(self, route: Route) -> Union[Callable["...", "Response"], None]:
184-
"""
185-
Finds a handler for a given route.
186-
187-
If route used URL parameters, the handler will be wrapped to pass the parameters to the
188-
handler.
189-
190-
Example::
191-
192-
@server.route("/example/<my_parameter>", GET)
193-
def route_func(request, my_parameter):
194-
...
195-
request.path == "/example/123" # True
196-
my_parameter == "123" # True
197-
"""
198-
found_route, _route = False, None
199-
200-
for _route in self._routes:
201-
matches, keyword_parameters = _route.match(route)
202-
203-
if matches:
204-
found_route = True
205-
break
206-
207-
if not found_route:
208-
return None
209-
210-
handler = _route.handler
211-
212-
def wrapped_handler(request):
213-
return handler(request, **keyword_parameters)
214-
215-
return wrapped_handler
216-
217-
def __repr__(self) -> str:
218-
return f"_Routes({repr(self._routes)})"

adafruit_httpserver/server.py

+64-38
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .methods import GET, HEAD
3131
from .request import Request
3232
from .response import Response, FileResponse
33-
from .route import _Routes, Route
33+
from .route import Route
3434
from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404
3535

3636

@@ -65,7 +65,7 @@ def __init__(
6565
self._auths = []
6666
self._buffer = bytearray(1024)
6767
self._timeout = 1
68-
self._routes = _Routes()
68+
self._routes: "List[Route]" = []
6969
self._socket_source = socket_source
7070
self._sock = None
7171
self.headers = Headers()
@@ -132,7 +132,7 @@ def route_func(request):
132132
"""
133133

134134
def route_decorator(func: Callable) -> Callable:
135-
self._routes.add(Route(path, methods, func, append_slash=append_slash))
135+
self._routes.append(Route(path, methods, func, append_slash=append_slash))
136136
return func
137137

138138
return route_decorator
@@ -157,8 +157,7 @@ def add_routes(self, routes: List[Route]) -> None:
157157
external_route2,
158158
]}
159159
"""
160-
for route in routes:
161-
self._routes.add(route)
160+
self._routes.extend(routes)
162161

163162
def _verify_can_start(self, host: str, port: int) -> None:
164163
"""Check if the server can be successfully started. Raises RuntimeError if not."""
@@ -172,7 +171,7 @@ def _verify_can_start(self, host: str, port: int) -> None:
172171
raise RuntimeError(f"Cannot start server on {host}:{port}") from error
173172

174173
def serve_forever(
175-
self, host: str, port: int = 80, *, poll_interval: float = None
174+
self, host: str, port: int = 80, *, poll_interval: float = 0.1
176175
) -> None:
177176
"""
178177
Wait for HTTP requests at the given host and port. Does not return.
@@ -187,16 +186,14 @@ def serve_forever(
187186

188187
while not self.stopped:
189188
try:
190-
self.poll()
189+
if self.poll() == NO_REQUEST and poll_interval is not None:
190+
sleep(poll_interval)
191191
except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development
192192
self.stop()
193193
return
194194
except Exception: # pylint: disable=broad-except
195195
pass # Ignore exceptions in handler function
196196

197-
if poll_interval is not None:
198-
sleep(poll_interval)
199-
200197
def start(self, host: str, port: int = 80) -> None:
201198
"""
202199
Start the HTTP server at the given host and port. Requires calling
@@ -234,32 +231,6 @@ def stop(self) -> None:
234231
if self.debug:
235232
_debug_stopped_server(self)
236233

237-
def _receive_request(
238-
self,
239-
sock: Union["SocketPool.Socket", "socket.socket"],
240-
client_address: Tuple[str, int],
241-
) -> Request:
242-
"""Receive bytes from socket until the whole request is received."""
243-
244-
# Receiving data until empty line
245-
header_bytes = self._receive_header_bytes(sock)
246-
247-
# Return if no data received
248-
if not header_bytes:
249-
return None
250-
251-
request = Request(self, sock, client_address, header_bytes)
252-
253-
content_length = int(request.headers.get_directive("Content-Length", 0))
254-
received_body_bytes = request.body
255-
256-
# Receiving remaining body bytes
257-
request.body = self._receive_body_bytes(
258-
sock, received_body_bytes, content_length
259-
)
260-
261-
return request
262-
263234
def _receive_header_bytes(
264235
self, sock: Union["SocketPool.Socket", "socket.socket"]
265236
) -> bytes:
@@ -296,6 +267,61 @@ def _receive_body_bytes(
296267
raise ex
297268
return received_body_bytes[:content_length]
298269

270+
def _receive_request(
271+
self,
272+
sock: Union["SocketPool.Socket", "socket.socket"],
273+
client_address: Tuple[str, int],
274+
) -> Request:
275+
"""Receive bytes from socket until the whole request is received."""
276+
277+
# Receiving data until empty line
278+
header_bytes = self._receive_header_bytes(sock)
279+
280+
# Return if no data received
281+
if not header_bytes:
282+
return None
283+
284+
request = Request(self, sock, client_address, header_bytes)
285+
286+
content_length = int(request.headers.get_directive("Content-Length", 0))
287+
received_body_bytes = request.body
288+
289+
# Receiving remaining body bytes
290+
request.body = self._receive_body_bytes(
291+
sock, received_body_bytes, content_length
292+
)
293+
294+
return request
295+
296+
def _find_handler( # pylint: disable=cell-var-from-loop
297+
self, method: str, path: str
298+
) -> Union[Callable[..., "Response"], None]:
299+
"""
300+
Finds a handler for a given route.
301+
302+
If route used URL parameters, the handler will be wrapped to pass the parameters to the
303+
handler.
304+
305+
Example::
306+
307+
@server.route("/example/<my_parameter>", GET)
308+
def route_func(request, my_parameter):
309+
...
310+
request.path == "/example/123" # True
311+
my_parameter == "123" # True
312+
"""
313+
for route in self._routes:
314+
route_matches, url_parameters = route.matches(method, path)
315+
316+
if route_matches:
317+
318+
def wrapped_handler(request):
319+
return route.handler(request, **url_parameters)
320+
321+
return wrapped_handler
322+
323+
return None
324+
299325
def _handle_request(
300326
self, request: Request, handler: Union[Callable, None]
301327
) -> Union[Response, None]:
@@ -371,8 +397,8 @@ def poll(self) -> str:
371397
conn.close()
372398
return CONNECTION_TIMED_OUT
373399

374-
# Find a handler for the route
375-
handler = self._routes.find_handler(Route(request.path, request.method))
400+
# Find a route that matches the request's method and path and get its handler
401+
handler = self._find_handler(request.method, request.path)
376402

377403
# Handle the request
378404
response = self._handle_request(request, handler)

0 commit comments

Comments
 (0)