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 8 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
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
92 changes: 86 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
except ImportError:
pass

import re

from .methods import HTTPMethod


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

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

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

self.path = path if not contains_regex else re.sub(r"<\w*>", r"([^/]*)", path)
self.method = method
self._contains_regex = contains_regex
self._last_match_groups: Union[List[str], None] = None

def matches(self, other: "_HTTPRoute") -> bool:
"""
Checks if the route matches the other route.

If the route contains parameters, it will check if the ``other`` route contains values for
them.
"""
if self.method != other.method:
return False

if not self._contains_regex:
return self.path == other.path

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

self._last_match_groups = regex_match.groups()
return True

def last_match_groups(self) -> Union[List[str], None]:
"""
Returns the last match groups from the last call to `matches`.

Useful for getting the values of the parameters from the route, without the need to call
`re.match` again.
"""
return self._last_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
"""

try:
matched_route = next(filter(lambda r: r.matches(route), self._routes))
except StopIteration:
return None

handler = self._handlers[self._routes.index(matched_route)]
args = matched_route.last_match_groups() or []

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

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

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 arguments to the handler function, in order they are specified.

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 of ``None``.

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 sens 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))
77 changes: 77 additions & 0 deletions examples/httpserver_url_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries
#
# SPDX-License-Identifier: Unlicense

import secrets # pylint: disable=no-name-in-module

import socketpool
import wifi

from adafruit_httpserver.mime_type import MIMEType
from adafruit_httpserver.request import HTTPRequest
from adafruit_httpserver.response import HTTPResponse
from adafruit_httpserver.server import HTTPServer


ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member

print("Connecting to", ssid)
wifi.radio.connect(ssid, password)
print("Connected to", ssid)

pool = socketpool.SocketPool(wifi.radio)
server = HTTPServer(pool)


class Device:
def turn_on(self):
raise NotImplementedError

def turn_off(self):
raise NotImplementedError


def get_device(device_id: str) -> Device: # pylint: disable=unused-argument
"""
This is a **made up** function that returns a `Device` object.
"""
return Device()


@server.route("/device/<device_id>/action/<action>")
@server.route("/device/emergency-power-off/<device_id>")
def perform_action(request: HTTPRequest, device_id: str, action: str = None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function intended to have two @server.route()s on it?

I tried testing it as was able to get successfull responses from /device/<device_id>/action/<action>

But I just get 'connection timed out' error with no real response when I try /device/emergency-power-off/<device_id>. I'm guessing it may have been leftover from some testing durrent development and can be removed? Or if it does actually belong something may need tweaked in order to make it functional.

Copy link
Contributor Author

@michalpokusa michalpokusa Mar 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for testing it 👍

Yes, this is intentional, not a leftover. I did it to show that it is possible to have multiple routes for single handler, like Flask.

I also test it on ESP32-S2 TFT, including the /device/emergency-power-off/<device_id>.

My guess is, that error you are encountering is in fact due to the action defaulting to None, thus entering second if with device.turn_off() which is raising NotImplementedError and not returning proper HTTPResponse.

Please check if that is the case, and if yes do you think it should be changed? (I updated the example to use print instead of NotImplementedError)

It is only an example but I agree that it might be confusing.

"""
Performs an "action" on a specified device.
"""

device = get_device(device_id)

if action == "turn_on":
device.turn_on()
elif action == "turn_off" or action is None:
device.turn_off()

with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response:
response.send(f"Action ({action}) performed on device with ID: {device_id}")


@server.route("/something/<route_param_1>/<route_param_2>")
def different_name_parameters(
request: HTTPRequest,
handler_param_1: str, # pylint: disable=unused-argument
handler_param_2: str = None, # pylint: disable=unused-argument
):
"""
Presents that the parameters can be named anything.

``route_param_1`` -> ``handler_param_1``
``route_param_2`` -> ``handler_param_2``
"""

with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response:
response.send("200 OK")


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