diff --git a/README.rst b/README.rst index bb54843..caac2d7 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 9ee5fc0..908b462 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -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 diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index c6cdcea..fbb2e3f 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -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 diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 78d8c6c..b141695 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -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 @@ -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/", 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/", 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)})" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 0a41616..84bb256 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -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 @@ -34,16 +34,16 @@ 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:: @@ -51,10 +51,14 @@ def route(self, path: str, method: HTTPMethod = HTTPMethod.GET): @server.route("/example", HTTPMethod.GET) def route_func(request): ... + + @server.route("/example/", 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 @@ -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: diff --git a/docs/examples.rst b/docs/examples.rst index e9fb5bc..befd552 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -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 @@ -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. ````. + +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: diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index ab7dabd..814a7af 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -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") @@ -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///") +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)) diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py new file mode 100644 index 0000000..2f95163 --- /dev/null +++ b/examples/httpserver_url_parameters.py @@ -0,0 +1,83 @@ +# 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): # pylint: disable=no-self-use + print("Turning on device.") + + def turn_off(self): # pylint: disable=no-self-use + print("Turning off device.") + + +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//action/") +@server.route("/device/emergency-power-off/") +def perform_action( + request: HTTPRequest, device_id: str, action: str = "emergency_power_off" +): + """ + Performs an "action" on a specified device. + """ + + device = get_device(device_id) + + if action in ["turn_on"]: + device.turn_on() + elif action in ["turn_off", "emergency_power_off"]: + device.turn_off() + else: + with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: + response.send(f"Unknown action ({action})") + return + + 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//") +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))