Skip to content

Commit fee4570

Browse files
authored
Merge pull request #43 from michalpokusa/feature/url_parameters
Feature: URL Parameters
2 parents c1a47ab + 7ddc32a commit fee4570

File tree

8 files changed

+272
-47
lines changed

8 files changed

+272
-47
lines changed

README.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ HTTP Server for CircuitPython.
2626
- Supports `socketpool` or `socket` as a source of sockets; can be used in CPython.
2727
- HTTP 1.1.
2828
- Serves files from a designated root.
29-
- Routing for serving computed responses from handler.
29+
- Routing for serving computed responses from handlers.
3030
- Gives access to request headers, query parameters, body and client's address, the one from which the request came.
3131
- Supports chunked transfer encoding.
32+
- Supports URL parameters and wildcard URLs.
3233

3334

3435
Dependencies

adafruit_httpserver/request.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st
133133
if "=" in query_param:
134134
key, value = query_param.split("=", 1)
135135
query_params[key] = value
136-
else:
136+
elif query_param:
137137
query_params[query_param] = ""
138138

139139
return method, path, query_params, http_version

adafruit_httpserver/response.py

+23-23
Original file line numberDiff line numberDiff line change
@@ -29,38 +29,38 @@ class HTTPResponse:
2929
3030
Example::
3131
32-
# Response with 'Content-Length' header
33-
@server.route(path, method)
34-
def route_func(request):
32+
# Response with 'Content-Length' header
33+
@server.route(path, method)
34+
def route_func(request):
3535
36-
response = HTTPResponse(request)
37-
response.send("Some content", content_type="text/plain")
36+
response = HTTPResponse(request)
37+
response.send("Some content", content_type="text/plain")
3838
39-
# or
39+
# or
4040
41-
response = HTTPResponse(request)
42-
with response:
43-
response.send(body='Some content', content_type="text/plain")
41+
response = HTTPResponse(request)
42+
with response:
43+
response.send(body='Some content', content_type="text/plain")
4444
45-
# or
45+
# or
4646
47-
with HTTPResponse(request) as response:
48-
response.send("Some content", content_type="text/plain")
47+
with HTTPResponse(request) as response:
48+
response.send("Some content", content_type="text/plain")
4949
50-
# Response with 'Transfer-Encoding: chunked' header
51-
@server.route(path, method)
52-
def route_func(request):
50+
# Response with 'Transfer-Encoding: chunked' header
51+
@server.route(path, method)
52+
def route_func(request):
5353
54-
response = HTTPResponse(request, content_type="text/plain", chunked=True)
55-
with response:
56-
response.send_chunk("Some content")
57-
response.send_chunk("Some more content")
54+
response = HTTPResponse(request, content_type="text/plain", chunked=True)
55+
with response:
56+
response.send_chunk("Some content")
57+
response.send_chunk("Some more content")
5858
59-
# or
59+
# or
6060
61-
with HTTPResponse(request, content_type="text/plain", chunked=True) as response:
62-
response.send_chunk("Some content")
63-
response.send_chunk("Some more content")
61+
with HTTPResponse(request, content_type="text/plain", chunked=True) as response:
62+
response.send_chunk("Some content")
63+
response.send_chunk("Some more content")
6464
"""
6565

6666
request: HTTPRequest

adafruit_httpserver/route.py

+109-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
* Author(s): Dan Halbert, Michał Pokusa
88
"""
99

10+
try:
11+
from typing import Callable, List, Union, Tuple
12+
except ImportError:
13+
pass
14+
15+
import re
16+
1017
from .methods import HTTPMethod
1118

1219

@@ -15,14 +22,110 @@ class _HTTPRoute:
1522

1623
def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None:
1724

18-
self.path = path
25+
contains_parameters = re.search(r"<\w*>", path) is not None
26+
27+
self.path = (
28+
path if not contains_parameters else re.sub(r"<\w*>", r"([^/]*)", path)
29+
)
1930
self.method = method
31+
self._contains_parameters = contains_parameters
32+
33+
def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]:
34+
"""
35+
Checks if the route matches the other route.
36+
37+
If the route contains parameters, it will check if the ``other`` route contains values for
38+
them.
39+
40+
Returns tuple of a boolean and a list of strings. The boolean indicates if the routes match,
41+
and the list contains the values of the url parameters from the ``other`` route.
42+
43+
Examples::
44+
45+
route = _HTTPRoute("/example", HTTPMethod.GET)
46+
47+
other1 = _HTTPRoute("/example", HTTPMethod.GET)
48+
route.matches(other1) # True, []
49+
50+
other2 = _HTTPRoute("/other-example", HTTPMethod.GET)
51+
route.matches(other2) # False, []
52+
53+
...
54+
55+
route = _HTTPRoute("/example/<parameter>", HTTPMethod.GET)
56+
57+
other1 = _HTTPRoute("/example/123", HTTPMethod.GET)
58+
route.matches(other1) # True, ["123"]
59+
60+
other2 = _HTTPRoute("/other-example", HTTPMethod.GET)
61+
route.matches(other2) # False, []
62+
"""
63+
64+
if self.method != other.method:
65+
return False, []
66+
67+
if not self._contains_parameters:
68+
return self.path == other.path, []
69+
70+
regex_match = re.match(self.path, other.path)
71+
if regex_match is None:
72+
return False, []
73+
74+
return True, regex_match.groups()
75+
76+
def __repr__(self) -> str:
77+
return f"_HTTPRoute(path={repr(self.path)}, method={repr(self.method)})"
78+
79+
80+
class _HTTPRoutes:
81+
"""A collection of routes and their corresponding handlers."""
82+
83+
def __init__(self) -> None:
84+
self._routes: List[_HTTPRoute] = []
85+
self._handlers: List[Callable] = []
86+
87+
def add(self, route: _HTTPRoute, handler: Callable):
88+
"""Adds a route and its handler to the collection."""
89+
90+
self._routes.append(route)
91+
self._handlers.append(handler)
92+
93+
def find_handler(self, route: _HTTPRoute) -> Union[Callable, None]:
94+
"""
95+
Finds a handler for a given route.
96+
97+
If route used URL parameters, the handler will be wrapped to pass the parameters to the
98+
handler.
99+
100+
Example::
101+
102+
@server.route("/example/<my_parameter>", HTTPMethod.GET)
103+
def route_func(request, my_parameter):
104+
...
105+
request.path == "/example/123" # True
106+
my_parameter == "123" # True
107+
"""
108+
if not self._routes:
109+
raise ValueError("No routes added")
110+
111+
found_route, _route = False, None
112+
113+
for _route in self._routes:
114+
matches, url_parameters_values = _route.match(route)
115+
116+
if matches:
117+
found_route = True
118+
break
119+
120+
if not found_route:
121+
return None
122+
123+
handler = self._handlers[self._routes.index(_route)]
20124

21-
def __hash__(self) -> int:
22-
return hash(self.method) ^ hash(self.path)
125+
def wrapped_handler(request):
126+
return handler(request, *url_parameters_values)
23127

24-
def __eq__(self, other: "_HTTPRoute") -> bool:
25-
return self.method == other.method and self.path == other.path
128+
return wrapped_handler
26129

27130
def __repr__(self) -> str:
28-
return f"HTTPRoute(path={repr(self.path)}, method={repr(self.method)})"
131+
return f"_HTTPRoutes({repr(self._routes)})"

adafruit_httpserver/server.py

+12-13
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from .methods import HTTPMethod
2020
from .request import HTTPRequest
2121
from .response import HTTPResponse
22-
from .route import _HTTPRoute
22+
from .route import _HTTPRoutes, _HTTPRoute
2323
from .status import CommonHTTPStatus
2424

2525

@@ -34,27 +34,31 @@ def __init__(self, socket_source: Protocol) -> None:
3434
"""
3535
self._buffer = bytearray(1024)
3636
self._timeout = 1
37-
self.route_handlers = {}
37+
self.routes = _HTTPRoutes()
3838
self._socket_source = socket_source
3939
self._sock = None
4040
self.root_path = "/"
4141

42-
def route(self, path: str, method: HTTPMethod = HTTPMethod.GET):
42+
def route(self, path: str, method: HTTPMethod = HTTPMethod.GET) -> Callable:
4343
"""
4444
Decorator used to add a route.
4545
46-
:param str path: filename path
46+
:param str path: URL path
4747
:param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc.
4848
4949
Example::
5050
5151
@server.route("/example", HTTPMethod.GET)
5252
def route_func(request):
5353
...
54+
55+
@server.route("/example/<my_parameter>", HTTPMethod.GET)
56+
def route_func(request, my_parameter):
57+
...
5458
"""
5559

5660
def route_decorator(func: Callable) -> Callable:
57-
self.route_handlers[_HTTPRoute(path, method)] = func
61+
self.routes.add(_HTTPRoute(path, method), func)
5862
return func
5963

6064
return route_decorator
@@ -154,18 +158,13 @@ def poll(self):
154158
conn, received_body_bytes, content_length
155159
)
156160

157-
handler = self.route_handlers.get(
158-
_HTTPRoute(request.path, request.method), None
161+
handler = self.routes.find_handler(
162+
_HTTPRoute(request.path, request.method)
159163
)
160164

161165
# If a handler for route exists and is callable, call it.
162166
if handler is not None and callable(handler):
163-
output = handler(request)
164-
# TODO: Remove this deprecation error in future
165-
if isinstance(output, HTTPResponse):
166-
raise RuntimeError(
167-
"Returning an HTTPResponse from a route handler is deprecated."
168-
)
167+
handler(request)
169168

170169
# If no handler exists and request method is GET, try to serve a file.
171170
elif handler is None and request.method == HTTPMethod.GET:

docs/examples.rst

+27-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Change NeoPixel color
3636
If you want your code to do more than just serve web pages,
3737
use the start/poll methods as shown in this example.
3838

39-
For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` you can change the color of the NeoPixel to red.
39+
For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` or ``/change-neopixel-color/255/0/0``
40+
you can change the color of the NeoPixel to red.
4041
Tested on ESP32-S2 Feather.
4142

4243
.. literalinclude:: ../examples/httpserver_neopixel.py
@@ -62,3 +63,28 @@ To use it, you need to set the ``chunked=True`` when creating a ``HTTPResponse``
6263
.. literalinclude:: ../examples/httpserver_chunked.py
6364
:caption: examples/httpserver_chunked.py
6465
:linenos:
66+
67+
URL parameters
68+
---------------------
69+
70+
Alternatively to using query parameters, you can use URL parameters.
71+
72+
In order to use URL parameters, you need to wrap them inside ``<>`` in ``HTTPServer.route``, e.g. ``<my_parameter>``.
73+
74+
All URL parameters are **passed as positional (not keyword) arguments** to the handler function, in order they are specified in ``HTTPServer.route``.
75+
76+
Notice how the handler function in example below accepts two additional arguments : ``device_id`` and ``action``.
77+
78+
If you specify multiple routes for single handler function and they have different number of URL parameters,
79+
make sure to add default values for all the ones that might not be passed.
80+
In the example below the second route has only one URL parameter, so the ``action`` parameter has a default value.
81+
82+
Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type.
83+
Also note that the names of the function parameters **do not have to match** with the ones used in route, but they **must** be in the same order.
84+
Look at the example below to see how the ``route_param_1`` and ``route_param_1`` are named differently in the handler function.
85+
86+
Although it is possible, it makes more sense be consistent with the names of the parameters in the route and in the handler function.
87+
88+
.. literalinclude:: ../examples/httpserver_url_parameters.py
89+
:caption: examples/httpserver_url_parameters.py
90+
:linenos:

examples/httpserver_neopixel.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828

2929

3030
@server.route("/change-neopixel-color")
31-
def change_neopixel_color_handler(request: HTTPRequest):
31+
def change_neopixel_color_handler_query_params(request: HTTPRequest):
3232
"""
33-
Changes the color of the built-in NeoPixel.
33+
Changes the color of the built-in NeoPixel using query/GET params.
3434
"""
3535
r = request.query_params.get("r")
3636
g = request.query_params.get("g")
@@ -42,5 +42,18 @@ def change_neopixel_color_handler(request: HTTPRequest):
4242
response.send(f"Changed NeoPixel to color ({r}, {g}, {b})")
4343

4444

45+
@server.route("/change-neopixel-color/<r>/<g>/<b>")
46+
def change_neopixel_color_handler_url_params(
47+
request: HTTPRequest, r: str, g: str, b: str
48+
):
49+
"""
50+
Changes the color of the built-in NeoPixel using URL params.
51+
"""
52+
pixel.fill((int(r or 0), int(g or 0), int(b or 0)))
53+
54+
with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response:
55+
response.send(f"Changed NeoPixel to color ({r}, {g}, {b})")
56+
57+
4558
print(f"Listening on http://{wifi.radio.ipv4_address}:80")
4659
server.serve_forever(str(wifi.radio.ipv4_address))

0 commit comments

Comments
 (0)