Skip to content

Feature: URL Parameters #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ HTTP Server for CircuitPython.
- Supports `socketpool` or `socket` as a source of sockets; can be used in CPython.
- HTTP 1.1.
- Serves files from a designated root.
- Routing for serving computed responses from handler.
- Routing for serving computed responses from handlers.
- Gives access to request headers, query parameters, body and client's address, the one from which the request came.
- Supports chunked transfer encoding.
- Supports URL parameters and wildcard URLs.


Dependencies
Expand Down
2 changes: 1 addition & 1 deletion adafruit_httpserver/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st
if "=" in query_param:
key, value = query_param.split("=", 1)
query_params[key] = value
else:
elif query_param:
query_params[query_param] = ""

return method, path, query_params, http_version
Expand Down
46 changes: 23 additions & 23 deletions adafruit_httpserver/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,38 +29,38 @@ class HTTPResponse:

Example::

# Response with 'Content-Length' header
@server.route(path, method)
def route_func(request):
# Response with 'Content-Length' header
@server.route(path, method)
def route_func(request):

response = HTTPResponse(request)
response.send("Some content", content_type="text/plain")
response = HTTPResponse(request)
response.send("Some content", content_type="text/plain")

# or
# or

response = HTTPResponse(request)
with response:
response.send(body='Some content', content_type="text/plain")
response = HTTPResponse(request)
with response:
response.send(body='Some content', content_type="text/plain")

# or
# or

with HTTPResponse(request) as response:
response.send("Some content", content_type="text/plain")
with HTTPResponse(request) as response:
response.send("Some content", content_type="text/plain")

# Response with 'Transfer-Encoding: chunked' header
@server.route(path, method)
def route_func(request):
# Response with 'Transfer-Encoding: chunked' header
@server.route(path, method)
def route_func(request):

response = HTTPResponse(request, content_type="text/plain", chunked=True)
with response:
response.send_chunk("Some content")
response.send_chunk("Some more content")
response = HTTPResponse(request, content_type="text/plain", chunked=True)
with response:
response.send_chunk("Some content")
response.send_chunk("Some more content")

# or
# or

with HTTPResponse(request, content_type="text/plain", chunked=True) as response:
response.send_chunk("Some content")
response.send_chunk("Some more content")
with HTTPResponse(request, content_type="text/plain", chunked=True) as response:
response.send_chunk("Some content")
response.send_chunk("Some more content")
"""

request: HTTPRequest
Expand Down
115 changes: 109 additions & 6 deletions adafruit_httpserver/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
* Author(s): Dan Halbert, Michał Pokusa
"""

try:
from typing import Callable, List, Union, Tuple
except ImportError:
pass

import re

from .methods import HTTPMethod


Expand All @@ -15,14 +22,110 @@ class _HTTPRoute:

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

self.path = path
contains_parameters = re.search(r"<\w*>", path) is not None

self.path = (
path if not contains_parameters else re.sub(r"<\w*>", r"([^/]*)", path)
)
self.method = method
self._contains_parameters = contains_parameters

def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]:
"""
Checks if the route matches the other route.

If the route contains parameters, it will check if the ``other`` route contains values for
them.

Returns tuple of a boolean and a list of strings. The boolean indicates if the routes match,
and the list contains the values of the url parameters from the ``other`` route.

Examples::

route = _HTTPRoute("/example", HTTPMethod.GET)

other1 = _HTTPRoute("/example", HTTPMethod.GET)
route.matches(other1) # True, []

other2 = _HTTPRoute("/other-example", HTTPMethod.GET)
route.matches(other2) # False, []

...

route = _HTTPRoute("/example/<parameter>", HTTPMethod.GET)

other1 = _HTTPRoute("/example/123", HTTPMethod.GET)
route.matches(other1) # True, ["123"]

other2 = _HTTPRoute("/other-example", HTTPMethod.GET)
route.matches(other2) # False, []
"""

if self.method != other.method:
return False, []

if not self._contains_parameters:
return self.path == other.path, []

regex_match = re.match(self.path, other.path)
if regex_match is None:
return False, []

return True, regex_match.groups()

def __repr__(self) -> str:
return f"_HTTPRoute(path={repr(self.path)}, method={repr(self.method)})"


class _HTTPRoutes:
"""A collection of routes and their corresponding handlers."""

def __init__(self) -> None:
self._routes: List[_HTTPRoute] = []
self._handlers: List[Callable] = []

def add(self, route: _HTTPRoute, handler: Callable):
"""Adds a route and its handler to the collection."""

self._routes.append(route)
self._handlers.append(handler)

def find_handler(self, route: _HTTPRoute) -> Union[Callable, None]:
"""
Finds a handler for a given route.

If route used URL parameters, the handler will be wrapped to pass the parameters to the
handler.

Example::

@server.route("/example/<my_parameter>", HTTPMethod.GET)
def route_func(request, my_parameter):
...
request.path == "/example/123" # True
my_parameter == "123" # True
"""
if not self._routes:
raise ValueError("No routes added")

found_route, _route = False, None

for _route in self._routes:
matches, url_parameters_values = _route.match(route)

if matches:
found_route = True
break

if not found_route:
return None

handler = self._handlers[self._routes.index(_route)]

def __hash__(self) -> int:
return hash(self.method) ^ hash(self.path)
def wrapped_handler(request):
return handler(request, *url_parameters_values)

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

def __repr__(self) -> str:
return f"HTTPRoute(path={repr(self.path)}, method={repr(self.method)})"
return f"_HTTPRoutes({repr(self._routes)})"
25 changes: 12 additions & 13 deletions adafruit_httpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from .methods import HTTPMethod
from .request import HTTPRequest
from .response import HTTPResponse
from .route import _HTTPRoute
from .route import _HTTPRoutes, _HTTPRoute
from .status import CommonHTTPStatus


Expand All @@ -34,27 +34,31 @@ def __init__(self, socket_source: Protocol) -> None:
"""
self._buffer = bytearray(1024)
self._timeout = 1
self.route_handlers = {}
self.routes = _HTTPRoutes()
self._socket_source = socket_source
self._sock = None
self.root_path = "/"

def route(self, path: str, method: HTTPMethod = HTTPMethod.GET):
def route(self, path: str, method: HTTPMethod = HTTPMethod.GET) -> Callable:
"""
Decorator used to add a route.

:param str path: filename path
:param str path: URL path
:param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc.

Example::

@server.route("/example", HTTPMethod.GET)
def route_func(request):
...

@server.route("/example/<my_parameter>", HTTPMethod.GET)
def route_func(request, my_parameter):
...
"""

def route_decorator(func: Callable) -> Callable:
self.route_handlers[_HTTPRoute(path, method)] = func
self.routes.add(_HTTPRoute(path, method), func)
return func

return route_decorator
Expand Down Expand Up @@ -154,18 +158,13 @@ def poll(self):
conn, received_body_bytes, content_length
)

handler = self.route_handlers.get(
_HTTPRoute(request.path, request.method), None
handler = self.routes.find_handler(
_HTTPRoute(request.path, request.method)
)

# If a handler for route exists and is callable, call it.
if handler is not None and callable(handler):
output = handler(request)
# TODO: Remove this deprecation error in future
if isinstance(output, HTTPResponse):
raise RuntimeError(
"Returning an HTTPResponse from a route handler is deprecated."
)
handler(request)

# If no handler exists and request method is GET, try to serve a file.
elif handler is None and request.method == HTTPMethod.GET:
Expand Down
28 changes: 27 additions & 1 deletion docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Change NeoPixel color
If you want your code to do more than just serve web pages,
use the start/poll methods as shown in this example.

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

.. literalinclude:: ../examples/httpserver_neopixel.py
Expand All @@ -62,3 +63,28 @@ To use it, you need to set the ``chunked=True`` when creating a ``HTTPResponse``
.. literalinclude:: ../examples/httpserver_chunked.py
:caption: examples/httpserver_chunked.py
:linenos:

URL parameters
---------------------

Alternatively to using query parameters, you can use URL parameters.

In order to use URL parameters, you need to wrap them inside ``<>`` in ``HTTPServer.route``, e.g. ``<my_parameter>``.

All URL parameters are **passed as positional (not keyword) arguments** to the handler function, in order they are specified in ``HTTPServer.route``.

Notice how the handler function in example below accepts two additional arguments : ``device_id`` and ``action``.

If you specify multiple routes for single handler function and they have different number of URL parameters,
make sure to add default values for all the ones that might not be passed.
In the example below the second route has only one URL parameter, so the ``action`` parameter has a default value.

Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type.
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.
Look at the example below to see how the ``route_param_1`` and ``route_param_1`` are named differently in the handler function.

Although it is possible, it makes more sense be consistent with the names of the parameters in the route and in the handler function.

.. literalinclude:: ../examples/httpserver_url_parameters.py
:caption: examples/httpserver_url_parameters.py
:linenos:
17 changes: 15 additions & 2 deletions examples/httpserver_neopixel.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@


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


@server.route("/change-neopixel-color/<r>/<g>/<b>")
def change_neopixel_color_handler_url_params(
request: HTTPRequest, r: str, g: str, b: str
):
"""
Changes the color of the built-in NeoPixel using URL params.
"""
pixel.fill((int(r or 0), int(g or 0), int(b or 0)))

with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response:
response.send(f"Changed NeoPixel to color ({r}, {g}, {b})")


print(f"Listening on http://{wifi.radio.ipv4_address}:80")
server.serve_forever(str(wifi.radio.ipv4_address))
Loading