diff --git a/README.rst b/README.rst index 7da0b7a..38a5307 100644 --- a/README.rst +++ b/README.rst @@ -31,6 +31,7 @@ HTTP Server for CircuitPython. - Supports chunked transfer encoding. - Supports URL parameters and wildcard URLs. - Supports HTTP Basic and Bearer Authentication on both server and route per level. +- Supports Websockets and Server-Sent Events. Dependencies diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 187aa4e..5bfe1cb 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -58,7 +58,10 @@ ChunkedResponse, JSONResponse, Redirect, + SSEResponse, + Websocket, ) +from .route import Route, as_route from .server import ( Server, NO_REQUEST, @@ -68,6 +71,7 @@ ) from .status import ( Status, + SWITCHING_PROTOCOLS_101, OK_200, CREATED_201, ACCEPTED_202, diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py index c5b1b60..a57564d 100644 --- a/adafruit_httpserver/authentication.py +++ b/adafruit_httpserver/authentication.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa # # SPDX-License-Identifier: MIT """ diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index 52f000f..13bba7e 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa # # SPDX-License-Identifier: MIT """ diff --git a/adafruit_httpserver/headers.py b/adafruit_httpserver/headers.py index 1b40f65..6ac13c5 100644 --- a/adafruit_httpserver/headers.py +++ b/adafruit_httpserver/headers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa # # SPDX-License-Identifier: MIT """ diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index 450b770..1c6fd47 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa # # SPDX-License-Identifier: MIT """ diff --git a/adafruit_httpserver/mime_types.py b/adafruit_httpserver/mime_types.py index 2bff2bf..2e99293 100644 --- a/adafruit_httpserver/mime_types.py +++ b/adafruit_httpserver/mime_types.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index a589b28..50118d5 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -302,8 +302,8 @@ def form_data(self) -> Union[FormData, None]: return self._form_data def json(self) -> Union[dict, None]: - """Body of the request, as a JSON-decoded dictionary.""" - return json.loads(self.body) if self.body else None + """Body of the request, as a JSON-decoded dictionary. Only available for POST requests.""" + return json.loads(self.body) if (self.body and self.method == "POST") else None @property def _raw_header_bytes(self) -> bytes: diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 78ad180..6df7b74 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -16,7 +16,9 @@ import os import json -from errno import EAGAIN, ECONNRESET +from binascii import b2a_base64 +import hashlib +from errno import EAGAIN, ECONNRESET, ETIMEDOUT, ENOTCONN from .exceptions import ( BackslashInPathError, @@ -25,7 +27,13 @@ ) from .mime_types import MIMETypes from .request import Request -from .status import Status, OK_200, TEMPORARY_REDIRECT_307, PERMANENT_REDIRECT_308 +from .status import ( + Status, + SWITCHING_PROTOCOLS_101, + OK_200, + TEMPORARY_REDIRECT_307, + PERMANENT_REDIRECT_308, +) from .headers import Headers @@ -83,12 +91,12 @@ def _send_headers( headers.setdefault( "Content-Type", content_type or self._content_type or MIMETypes.DEFAULT ) + headers.setdefault("Content-Length", content_length) headers.setdefault("Connection", "close") - if content_length is not None: - headers.setdefault("Content-Length", content_length) for header, value in headers.items(): - response_message_header += f"{header}: {value}\r\n" + if value is not None: + response_message_header += f"{header}: {value}\r\n" response_message_header += "\r\n" self._send_bytes( @@ -102,6 +110,7 @@ def _send(self) -> None: self._send_headers(len(encoded_body), self._content_type) self._send_bytes(self._request.connection, encoded_body) + self._close_connection() def _send_bytes( self, @@ -122,6 +131,12 @@ def _send_bytes( raise self._size += bytes_sent + def _close_connection(self) -> None: + try: + self._request.connection.close() + except (BrokenPipeError, OSError): + pass + class FileResponse(Response): # pylint: disable=too-few-public-methods """ @@ -246,6 +261,7 @@ def _send(self) -> None: with open(self._full_file_path, "rb") as file: while bytes_read := file.read(self._buffer_size): self._send_bytes(self._request.connection, bytes_read) + self._close_connection() class ChunkedResponse(Response): # pylint: disable=too-few-public-methods @@ -310,6 +326,7 @@ def _send(self) -> None: # Empty chunk to indicate end of response self._send_chunk() + self._close_connection() class JSONResponse(Response): # pylint: disable=too-few-public-methods @@ -353,6 +370,7 @@ def _send(self) -> None: self._send_headers(len(encoded_data), "application/json") self._send_bytes(self._request.connection, encoded_data) + self._close_connection() class Redirect(Response): # pylint: disable=too-few-public-methods @@ -392,3 +410,372 @@ def __init__( def _send(self) -> None: self._send_headers() + self._close_connection() + + +class SSEResponse(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for sending Server-Sent Events. + + Allows one way communication with the client using a persistent connection. + + Keep in mind, that in order to send events, the socket must be kept open. This means that you + have to store the response object somewhere, so you can send events to it and close it later. + + **It is very important to close the connection manually, it will not be done automatically.** + + Example:: + + sse = None + + @server.route(path, method) + def route_func(request: Request): + + # Store the response object somewhere in global scope + global sse + sse = SSEResponse(request) + + return sse + + ... + + # Later, when you want to send an event + sse.send_event("Simple message") + sse.send_event("Message", event="event_name", id=1, retry=5000) + + # Close the connection + sse.close() + """ + + def __init__( # pylint: disable=too-many-arguments + self, + request: Request, + headers: Union[Headers, Dict[str, str]] = None, + ) -> None: + """ + :param Request request: Request object + :param Headers headers: Headers to be sent with the response. + """ + super().__init__( + request=request, + headers=headers, + content_type="text/event-stream", + ) + self._headers.setdefault("Cache-Control", "no-cache") + self._headers.setdefault("Connection", "keep-alive") + + def _send(self) -> None: + self._send_headers() + + def send_event( # pylint: disable=too-many-arguments + self, + data: str, + event: str = None, + id: int = None, # pylint: disable=redefined-builtin,invalid-name + retry: int = None, + custom_fields: Dict[str, str] = None, + ) -> None: + """ + Send event to the client. + + :param str data: The data to be sent. + :param str event: (Optional) The name of the event. + :param int id: (Optional) The event ID. + :param int retry: (Optional) The time (in milliseconds) to wait before retrying the event. + :param Dict[str, str] custom_fields: (Optional) Custom fields to be sent with the event. + """ + message = f"data: {data}\n" + if event: + message += f"event: {event}\n" + if id: + message += f"id: {id}\n" + if retry: + message += f"retry: {retry}\n" + if custom_fields: + for field, value in custom_fields.items(): + message += f"{field}: {value}\n" + message += "\n" + + self._send_bytes(self._request.connection, message.encode("utf-8")) + + def close(self): + """ + Close the connection. + + **Always call this method when you are done sending events.** + """ + self._send_bytes(self._request.connection, b"event: close\n") + self._close_connection() + + +class Websocket(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for creating a websocket connection. + + Allows two way communication between the client and the server. + + Keep in mind, that in order to send and receive messages, the socket must be kept open. + This means that you have to store the response object somewhere, so you can send events + to it and close it later. + + **It is very important to close the connection manually, it will not be done automatically.** + + Example:: + + ws = None + + @server.route(path, method) + def route_func(request: Request): + + # Store the response object somewhere in global scope + global ws + ws = Websocket(request) + + return ws + + ... + + # Receive message from client + message = ws.receive() + + # Later, when you want to send an event + ws.send_message("Simple message") + + # Close the connection + ws.close() + """ + + GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + FIN = 0b10000000 # FIN bit indicating the final fragment + + # opcodes + CONT = 0 # Continuation frame, TODO: Currently not supported + TEXT = 1 # Frame contains UTF-8 text + BINARY = 2 # Frame contains binary data + CLOSE = 8 # Frame closes the connection + PING = 9 # Frame is a ping, expecting a pong + PONG = 10 # Frame is a pong, in response to a ping + + @staticmethod + def _check_request_initiates_handshake(request: Request): + if any( + [ + "websocket" not in request.headers.get("Upgrade", "").lower(), + "upgrade" not in request.headers.get("Connection", "").lower(), + "Sec-WebSocket-Key" not in request.headers, + ] + ): + raise ValueError("Request does not initiate websocket handshake") + + @staticmethod + def _process_sec_websocket_key(request: Request) -> str: + key = request.headers.get("Sec-WebSocket-Key") + + if key is None: + raise ValueError("Request does not have Sec-WebSocket-Key header") + + response_key = hashlib.new("sha1", key.encode()) + response_key.update(Websocket.GUID) + + return b2a_base64(response_key.digest()).strip().decode() + + def __init__( # pylint: disable=too-many-arguments + self, + request: Request, + headers: Union[Headers, Dict[str, str]] = None, + buffer_size: int = 1024, + ) -> None: + """ + :param Request request: Request object + :param Headers headers: Headers to be sent with the response. + :param int buffer_size: Size of the buffer used to send and receive messages. + """ + self._check_request_initiates_handshake(request) + + sec_accept_key = self._process_sec_websocket_key(request) + + super().__init__( + request=request, + status=SWITCHING_PROTOCOLS_101, + headers=headers, + ) + self._headers.setdefault("Upgrade", "websocket") + self._headers.setdefault("Connection", "Upgrade") + self._headers.setdefault("Sec-WebSocket-Accept", sec_accept_key) + self._headers.setdefault("Content-Type", None) + self._buffer_size = buffer_size + self.closed = False + + request.connection.setblocking(False) + + @staticmethod + def _parse_frame_header(header): + fin = header[0] & Websocket.FIN + opcode = header[0] & 0b00001111 + has_mask = header[1] & 0b10000000 + length = header[1] & 0b01111111 + + if length == 0b01111110: + length = -2 + elif length == 0b01111111: + length = -8 + + return fin, opcode, has_mask, length + + def _read_frame(self): + buffer = bytearray(self._buffer_size) + + header_length = self._request.connection.recv_into(buffer, 2) + header_bytes = buffer[:header_length] + + fin, opcode, has_mask, length = self._parse_frame_header(header_bytes) + + # TODO: Handle continuation frames, currently not supported + if fin != Websocket.FIN and opcode == Websocket.CONT: + return Websocket.CONT, None + + payload = bytes() + if fin == Websocket.FIN and opcode == Websocket.CLOSE: + return Websocket.CLOSE, payload + + if length < 0: + length = self._request.connection.recv_into(buffer, -length) + length = int.from_bytes(buffer[:length], "big") + + if has_mask: + mask_length = self._request.connection.recv_into(buffer, 4) + mask = buffer[:mask_length] + + while 0 < length: + payload_length = self._request.connection.recv_into(buffer, length) + payload += buffer[: min(payload_length, length)] + length -= min(payload_length, length) + + if has_mask: + payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + + return opcode, payload + + def _handle_frame(self, opcode: int, payload: bytes) -> Union[str, bytes, None]: + # TODO: Handle continuation frames, currently not supported + if opcode == Websocket.CONT: + return None + + if opcode == Websocket.CLOSE: + self.close() + return None + + if opcode == Websocket.PONG: + return None + if opcode == Websocket.PING: + self.send_message(payload, Websocket.PONG) + return payload + + try: + payload = payload.decode() if opcode == Websocket.TEXT else payload + except UnicodeError: + pass + + return payload + + def receive(self, fail_silently: bool = False) -> Union[str, bytes, None]: + """ + Receive a message from the client. + + :param bool fail_silently: If True, no error will be raised if the connection is closed. + """ + if self.closed: + if fail_silently: + return None + raise RuntimeError( + "Websocket connection is closed, cannot receive messages" + ) + + try: + opcode, payload = self._read_frame() + frame_data = self._handle_frame(opcode, payload) + + return frame_data + except OSError as error: + if error.errno == EAGAIN: # No messages available + return None + if error.errno == ETIMEDOUT: # Connection timed out + return None + if error.errno == ENOTCONN: # Client disconnected + self.close() + return None + raise error + + @staticmethod + def _prepare_frame(opcode: int, message: bytes) -> bytearray: + frame = bytearray() + + frame.append(Websocket.FIN | opcode) # Setting FIN bit + + payload_length = len(message) + + # Message under 126 bytes, use 1 byte for length + if payload_length < 126: + frame.append(payload_length) + + # Message between 126 and 65535 bytes, use 2 bytes for length + elif payload_length < 65536: + frame.append(126) + frame.extend(payload_length.to_bytes(2, "big")) + + # Message over 65535 bytes, use 8 bytes for length + else: + frame.append(127) + frame.extend(payload_length.to_bytes(8, "big")) + + frame.extend(message) + return frame + + def send_message( + self, + message: Union[str, bytes], + opcode: int = None, + fail_silently: bool = False, + ): + """ + Send a message to the client. + + :param str message: Message to be sent. + :param int opcode: Opcode of the message. Defaults to TEXT if message is a string and + BINARY for bytes. + :param bool fail_silently: If True, no error will be raised if the connection is closed. + """ + if self.closed: + if fail_silently: + return + raise RuntimeError("Websocket connection is closed, cannot send message") + + determined_opcode = opcode or ( + Websocket.TEXT if isinstance(message, str) else Websocket.BINARY + ) + + if determined_opcode == Websocket.TEXT: + message = message.encode() + + frame = self._prepare_frame(determined_opcode, message) + + try: + self._send_bytes(self._request.connection, frame) + except BrokenPipeError as error: + if fail_silently: + return + raise error + + def _send(self) -> None: + self._send_headers() + + def close(self): + """ + Close the connection. + + **Always call this method when you are done sending events.** + """ + if not self.closed: + self.send_message(b"", Websocket.CLOSE, fail_silently=True) + self._close_connection() + self.closed = True diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 96750f4..ba3b81e 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -8,7 +8,7 @@ """ try: - from typing import Callable, List, Set, Union, Tuple, TYPE_CHECKING + from typing import Callable, List, Set, Union, Tuple, Dict, TYPE_CHECKING if TYPE_CHECKING: from .response import Response @@ -20,16 +20,18 @@ from .methods import GET -class _Route: +class Route: """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" def __init__( self, path: str = "", methods: Union[str, Set[str]] = GET, + handler: Callable = None, + *, append_slash: bool = False, ) -> None: - self._validate_path(path) + self._validate_path(path, append_slash) self.parameters_names = [ name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" @@ -37,17 +39,24 @@ def __init__( self.path = re.sub(r"<\w+>", r"([^/]+)", path).replace("....", r".+").replace( "...", r"[^/]+" ) + ("/?" if append_slash else "") - self.methods = methods if isinstance(methods, set) else {methods} + self.methods = ( + set(methods) if isinstance(methods, (set, list)) else set([methods]) + ) + + self.handler = handler @staticmethod - def _validate_path(path: str) -> None: + def _validate_path(path: str, append_slash: bool) -> None: if not path.startswith("/"): raise ValueError("Path must start with a slash.") if "<>" in path: raise ValueError("All URL parameters must be named.") - def match(self, other: "_Route") -> Tuple[bool, List[str]]: + if path.endswith("/") and append_slash: + raise ValueError("Cannot use append_slash=True when path ends with /") + + def match(self, other: "Route") -> Tuple[bool, Dict[str, str]]: """ Checks if the route matches the other route. @@ -59,67 +68,119 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: Examples:: - route = _Route("/example", GET, True) + route = Route("/example", GET, True) - other1a = _Route("/example", GET) - other1b = _Route("/example/", GET) - route.matches(other1a) # True, [] - route.matches(other1b) # True, [] + other1a = Route("/example", GET) + other1b = Route("/example/", GET) + route.matches(other1a) # True, {} + route.matches(other1b) # True, {} - other2 = _Route("/other-example", GET) - route.matches(other2) # False, [] + other2 = Route("/other-example", GET) + route.matches(other2) # False, {} ... - route = _Route("/example/", GET) + route = Route("/example/", GET) - other1 = _Route("/example/123", GET) - route.matches(other1) # True, ["123"] + other1 = Route("/example/123", GET) + route.matches(other1) # True, {"parameter": "123"} - other2 = _Route("/other-example", GET) - route.matches(other2) # False, [] + other2 = Route("/other-example", GET) + route.matches(other2) # False, {} ... - route1 = _Route("/example/.../something", GET) - other1 = _Route("/example/123/something", GET) - route1.matches(other1) # True, [] + route1 = Route("/example/.../something", GET) + other1 = Route("/example/123/something", GET) + route1.matches(other1) # True, {} - route2 = _Route("/example/..../something", GET) - other2 = _Route("/example/123/456/something", GET) - route2.matches(other2) # True, [] + route2 = Route("/example/..../something", GET) + other2 = Route("/example/123/456/something", GET) + route2.matches(other2) # True, {} """ if not other.methods.issubset(self.methods): - return False, [] + return False, {} regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: - return False, [] + return False, {} - return True, regex_match.groups() + return True, dict(zip(self.parameters_names, regex_match.groups())) def __repr__(self) -> str: path = repr(self.path) methods = repr(self.methods) + handler = repr(self.handler) + + return f"Route({path=}, {methods=}, {handler=})" + + +def as_route( + path: str, + methods: Union[str, Set[str]] = GET, + *, + append_slash: bool = False, +) -> "Callable[[Callable[..., Response]], Route]": + """ + Decorator used to convert a function into a ``Route`` object. + + ``as_route`` can be only used once per function, because it replaces the function with + a ``Route`` object that has the same name as the function. + + Later it can be imported and registered in the ``Server``. + + :param str path: URL path + :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. + :param bool append_slash: If True, the route will be accessible with and without a + trailing slash + + Example:: + + # Converts a function into a Route object + @as_route("/example") + def some_func(request): + ... - return f"_Route(path={path}, methods={methods})" + some_func # Route(path="/example", methods={"GET"}, handler=) + + # WRONG: as_route can be used only once per function + @as_route("/wrong-example1") + @as_route("/wrong-example2") + def wrong_func2(request): + ... + + # If a route is in another file, you can import it and register it to the server + + from .routes import some_func + + ... + + server.add_routes([ + some_func, + ]) + """ + + def route_decorator(func: Callable) -> Route: + if isinstance(func, Route): + raise ValueError("as_route can be used only once per function.") + + return Route(path, methods, func, append_slash=append_slash) + + return route_decorator class _Routes: """A collection of routes and their corresponding handlers.""" def __init__(self) -> None: - self._routes: List[_Route] = [] - self._handlers: List[Callable] = [] + self._routes: List[Route] = [] - def add(self, route: _Route, handler: Callable): + def add(self, route: Route): """Adds a route and its handler to the collection.""" - self._routes.append(route) - self._handlers.append(handler) - def find_handler(self, route: _Route) -> Union[Callable["...", "Response"], None]: + def find_handler(self, route: Route) -> Union[Callable["...", "Response"], None]: """ Finds a handler for a given route. @@ -137,7 +198,7 @@ def route_func(request, my_parameter): found_route, _route = False, None for _route in self._routes: - matches, parameters_values = _route.match(route) + matches, keyword_parameters = _route.match(route) if matches: found_route = True @@ -146,9 +207,7 @@ def route_func(request, my_parameter): if not found_route: return None - handler = self._handlers[self._routes.index(_route)] - - keyword_parameters = dict(zip(_route.parameters_names, parameters_values)) + handler = _route.handler def wrapped_handler(request): return handler(request, **keyword_parameters) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 95d2a52..6168da3 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -29,7 +29,7 @@ from .methods import GET, HEAD from .request import Request from .response import Response, FileResponse -from .route import _Routes, _Route +from .route import _Routes, Route from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 @@ -129,17 +129,36 @@ def route_func(request, my_parameter): def route_func(request): ... """ - if path.endswith("/") and append_slash: - raise ValueError("Cannot use append_slash=True when path ends with /") - - methods = set(methods) if isinstance(methods, (set, list)) else set([methods]) def route_decorator(func: Callable) -> Callable: - self._routes.add(_Route(path, methods, append_slash), func) + self._routes.add(Route(path, methods, func, append_slash=append_slash)) return func return route_decorator + def add_routes(self, routes: List[Route]) -> None: + """ + Add multiple routes at once. + + :param List[Route] routes: List of routes to add to the server + + Example:: + + from separate_file import external_route1, external_route2 + + ... + + server.add_routes([ + Route("/example", GET, route_func1, append_slash=True), + Route("/example/", GET, route_func2), + Route("/example/..../something", [GET, POST], route_func3), + external_route1, + external_route2, + ]} + """ + for route in routes: + self._routes.add(route) + def _verify_can_start(self, host: str, port: int) -> None: """Check if the server can be successfully started. Raises RuntimeError if not.""" @@ -337,33 +356,32 @@ def poll(self) -> str: try: conn, client_address = self._sock.accept() - with conn: - conn.settimeout(self._timeout) + conn.settimeout(self._timeout) - # Receive the whole request - if (request := self._receive_request(conn, client_address)) is None: - return CONNECTION_TIMED_OUT + # Receive the whole request + if (request := self._receive_request(conn, client_address)) is None: + conn.close() + return CONNECTION_TIMED_OUT - # Find a handler for the route - handler = self._routes.find_handler( - _Route(request.path, request.method) - ) + # Find a handler for the route + handler = self._routes.find_handler(Route(request.path, request.method)) - # Handle the request - response = self._handle_request(request, handler) + # Handle the request + response = self._handle_request(request, handler) - if response is None: - return REQUEST_HANDLED_NO_RESPONSE + if response is None: + conn.close() + return REQUEST_HANDLED_NO_RESPONSE - self._set_default_server_headers(response) + self._set_default_server_headers(response) - # Send the response - response._send() # pylint: disable=protected-access + # Send the response + response._send() # pylint: disable=protected-access - if self.debug: - _debug_response_sent(response) + if self.debug: + _debug_response_sent(response) - return REQUEST_HANDLED_RESPONSE_SENT + return REQUEST_HANDLED_RESPONSE_SENT except Exception as error: # pylint: disable=broad-except if isinstance(error, OSError): @@ -377,6 +395,7 @@ def poll(self) -> str: if self.debug: _debug_exception_in_handler(error) + conn.close() raise error # Raise the exception again to be handled by the user. def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 57ae47a..4219d9c 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ @@ -31,6 +31,8 @@ def __eq__(self, other: "Status"): return self.code == other.code and self.text == other.text +SWITCHING_PROTOCOLS_101 = Status(101, "Switching Protocols") + OK_200 = Status(200, "OK") CREATED_201 = Status(201, "Created") diff --git a/docs/examples.rst b/docs/examples.rst index 80941e5..05e07de 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -15,6 +15,14 @@ It also manually connects to the WiFi network. :emphasize-lines: 12-17 :linenos: +It is also possible to use Ethernet instead of WiFi. +The only difference in usage is related to configuring the ``socket_source`` differently. + +.. literalinclude:: ../examples/httpserver_ethernet_simpletest.py + :caption: examples/httpserver_ethernet_simpletest.py + :emphasize-lines: 13-23 + :linenos: + Although there is nothing wrong with this approach, from the version 8.0.0 of CircuitPython, `it is possible to use the environment variables `_ defined in ``settings.toml`` file to store secrets and configure the WiFi network. @@ -32,6 +40,7 @@ Note that we still need to import ``socketpool`` and ``wifi`` modules. .. literalinclude:: ../examples/httpserver_simpletest_auto.py :caption: examples/httpserver_simpletest_auto.py + :emphasize-lines: 11 :linenos: Serving static files @@ -76,7 +85,7 @@ a running total of the last 10 samples. .. literalinclude:: ../examples/httpserver_start_and_poll.py :caption: examples/httpserver_start_and_poll.py - :emphasize-lines: 24,33 + :emphasize-lines: 29,38 :linenos: Server with MDNS @@ -145,7 +154,7 @@ Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py - :emphasize-lines: 25-27,39,51,60,66 + :emphasize-lines: 26-28,41,52,68,74 :linenos: Form data parsing @@ -259,6 +268,43 @@ You can specify wheter the redirect is permanent or temporary by passing ``perma :emphasize-lines: 14-18,26,38 :linenos: +Server-Sent Events +------------------ + +All types of responses until now were synchronous, meaning that the response was sent immediately after the handler function returned. +However, sometimes you might want to send data to the client at a later time, e.g. when some event occurs. +This can be overcomed by periodically polling the server, but it is not an elegant solution. Instead, you can use Server-Sent Events (SSE). + +Response is initialized on ``return``, events can be sent using ``.send_event()`` method. Due to the nature of SSE, it is necessary to store the +response object somewhere, so that it can be accessed later. + +**Because of the limited number of concurrently open sockets, it is not possible to process more than one SSE response at the same time. +This might change in the future, but for now, it is recommended to use SSE only with one client at a time.** + +.. literalinclude:: ../examples/httpserver_sse.py + :caption: examples/httpserver_sse.py + :emphasize-lines: 10,17,46-53,63 + :linenos: + +Websockets +---------- + +Although SSE provide a simple way to send data from the server to the client, they are not suitable for sending data the other way around. + +For that purpose, you can use Websockets. They are more complex than SSE, but they provide a persistent two-way communication channel between +the client and the server. + +Remember, that because Websockets also receive data, you have to explicitly call ``.receive()`` on the ``Websocket`` object to get the message. +This is anologous to calling ``.poll()`` on the ``Server`` object. + +**Because of the limited number of concurrently open sockets, it is not possible to process more than one Websocket response at the same time. +This might change in the future, but for now, it is recommended to use Websocket only with one client at a time.** + +.. literalinclude:: ../examples/httpserver_websocket.py + :caption: examples/httpserver_websocket.py + :emphasize-lines: 12,21,67-73,83,90 + :linenos: + Multiple servers ---------------- diff --git a/examples/home.html b/examples/home.html index 2a17081..635aa50 100644 --- a/examples/home.html +++ b/examples/home.html @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py index bfdb5d1..d917ede 100644 --- a/examples/httpserver_authentication_handlers.py +++ b/examples/httpserver_authentication_handlers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_authentication_server.py b/examples/httpserver_authentication_server.py index e957a41..298e28c 100644 --- a/examples/httpserver_authentication_server.py +++ b/examples/httpserver_authentication_server.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_chunked.py b/examples/httpserver_chunked.py index e357fd3..57c91e6 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_ethernet_simpletest.py b/examples/httpserver_ethernet_simpletest.py index f4a0908..97ac726 100644 --- a/examples/httpserver_ethernet_simpletest.py +++ b/examples/httpserver_ethernet_simpletest.py @@ -3,6 +3,7 @@ import board import digitalio + from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket from adafruit_httpserver import Server, Request, Response @@ -18,10 +19,10 @@ # Initialize ethernet interface with DHCP eth = WIZNET5K(spi_bus, cs) -# set the interface on the socket source +# Set the interface on the socket source socket.set_interface(eth) -# initialize the server +# Initialize the server server = Server(socket, "/static", debug=True) diff --git a/examples/httpserver_form_data.py b/examples/httpserver_form_data.py index f93ed5f..12090eb 100644 --- a/examples/httpserver_form_data.py +++ b/examples/httpserver_form_data.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_handler_serves_file.py b/examples/httpserver_handler_serves_file.py index 8897eaa..0886a4c 100644 --- a/examples/httpserver_handler_serves_file.py +++ b/examples/httpserver_handler_serves_file.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index 377f957..27f32bc 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py index cb99fc9..d579536 100644 --- a/examples/httpserver_methods.py +++ b/examples/httpserver_methods.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py index 88047a3..7c040d4 100644 --- a/examples/httpserver_multiple_servers.py +++ b/examples/httpserver_multiple_servers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 7c1e4d0..ba60fd2 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense @@ -7,7 +7,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response, GET, POST +from adafruit_httpserver import Server, Route, as_route, Request, Response, GET, POST pool = socketpool.SocketPool(wifi.radio) @@ -16,6 +16,7 @@ pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) +# This is the simplest way to register a route. It uses the Server object in current scope. @server.route("/change-neopixel-color", GET) def change_neopixel_color_handler_query_params(request: Request): """Changes the color of the built-in NeoPixel using query/GET params.""" @@ -31,19 +32,9 @@ def change_neopixel_color_handler_query_params(request: Request): return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color/body", POST) -def change_neopixel_color_handler_post_body(request: Request): - """Changes the color of the built-in NeoPixel using POST body.""" - - data = request.body # e.g b"255,0,0" - r, g, b = data.decode().split(",") # ["255", "0", "0"] - - pixel.fill((int(r), int(g), int(b))) - - return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") - - -@server.route("/change-neopixel-color/form-data", POST) +# This is another way to register a route. It uses the decorator that converts the function into +# a Route object that can be imported and registered later. +@as_route("/change-neopixel-color/form-data", POST) def change_neopixel_color_handler_post_form_data(request: Request): """Changes the color of the built-in NeoPixel using POST form data.""" @@ -55,7 +46,6 @@ def change_neopixel_color_handler_post_form_data(request: Request): return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color/json", POST) def change_neopixel_color_handler_post_json(request: Request): """Changes the color of the built-in NeoPixel using JSON POST body.""" @@ -67,7 +57,13 @@ def change_neopixel_color_handler_post_json(request: Request): return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color///", GET) +# You can always manually create a Route object and import or register it later. +# Using this approach you can also use the same handler for multiple routes. +post_json_route = Route( + "/change-neopixel-color/json", POST, change_neopixel_color_handler_post_json +) + + def change_neopixel_color_handler_url_params( request: Request, r: str = "0", g: str = "0", b: str = "0" ): @@ -80,4 +76,19 @@ def change_neopixel_color_handler_url_params( return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") +# Registering Route objects +server.add_routes( + [ + change_neopixel_color_handler_post_form_data, + post_json_route, + # You can also register a inline created Route object + Route( + path="/change-neopixel-color///", + methods=GET, + handler=change_neopixel_color_handler_url_params, + ), + ] +) + + server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_redirects.py b/examples/httpserver_redirects.py index 3278f25..8b38ca9 100644 --- a/examples/httpserver_redirects.py +++ b/examples/httpserver_redirects.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_sse.py b/examples/httpserver_sse.py new file mode 100644 index 0000000..3ad6c73 --- /dev/null +++ b/examples/httpserver_sse.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2023 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +from time import monotonic +import microcontroller +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, SSEResponse, GET + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +sse_response: SSEResponse = None +next_event_time = monotonic() + +HTML_TEMPLATE = """ + + + Server-Sent Events Client + + +

CPU temperature: -°C

+ + + +""" + + +@server.route("/client", GET) +def client(request: Request): + return Response(request, HTML_TEMPLATE, content_type="text/html") + + +@server.route("/connect-client", GET) +def connect_client(request: Request): + global sse_response # pylint: disable=global-statement + + if sse_response is not None: + sse_response.close() # Close any existing connection + + sse_response = SSEResponse(request) + + return sse_response + + +server.start(str(wifi.radio.ipv4_address)) +while True: + server.poll() + + # Send an event every second + if sse_response is not None and next_event_time < monotonic(): + cpu_temp = round(microcontroller.cpu.temperature, 2) + sse_response.send_event(str(cpu_temp)) + next_event_time = monotonic() + 1 diff --git a/examples/httpserver_static_files_serving.py b/examples/httpserver_static_files_serving.py index ba45f2d..0026ce8 100644 --- a/examples/httpserver_static_files_serving.py +++ b/examples/httpserver_static_files_serving.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index fd38988..d1a2714 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense diff --git a/examples/httpserver_websocket.py b/examples/httpserver_websocket.py new file mode 100644 index 0000000..396a6c1 --- /dev/null +++ b/examples/httpserver_websocket.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2023 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +from time import monotonic +import board +import microcontroller +import neopixel +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, Websocket, GET + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) + + +websocket: Websocket = None +next_message_time = monotonic() + +HTML_TEMPLATE = """ + + + Websocket Client + + +

CPU temperature: -°C

+

NeoPixel Color:

+ + + +""" + + +@server.route("/client", GET) +def client(request: Request): + return Response(request, HTML_TEMPLATE, content_type="text/html") + + +@server.route("/connect-websocket", GET) +def connect_client(request: Request): + global websocket # pylint: disable=global-statement + + if websocket is not None: + websocket.close() # Close any existing connection + + websocket = Websocket(request) + + return websocket + + +server.start(str(wifi.radio.ipv4_address)) +while True: + server.poll() + + # Check for incoming messages from client + if websocket is not None: + if (data := websocket.receive(True)) is not None: + r, g, b = int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16) + pixel.fill((r, g, b)) + + # Send a message every second + if websocket is not None and next_message_time < monotonic(): + cpu_temp = round(microcontroller.cpu.temperature, 2) + websocket.send_message(str(cpu_temp)) + next_message_time = monotonic() + 1 diff --git a/examples/settings.toml b/examples/settings.toml index ab4da20..e99fb9c 100644 --- a/examples/settings.toml +++ b/examples/settings.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Michał Pokusa # # SPDX-License-Identifier: Unlicense