From 8ee162d5aca19f58da703ee494b84d333ddc2368 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 12 Jul 2023 20:57:33 +0000 Subject: [PATCH 01/20] Changed Route class to public --- adafruit_httpserver/__init__.py | 1 + adafruit_httpserver/route.py | 52 ++++++++++++++++++--------------- adafruit_httpserver/server.py | 9 ++---- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index cb152b2..03360c9 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -59,6 +59,7 @@ JSONResponse, Redirect, ) +from .route import Route from .server import Server from .status import ( Status, diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 96750f4..6ef7ad4 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -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,22 @@ 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, List[str]]: """ Checks if the route matches the other route. @@ -59,34 +66,34 @@ 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) + other1a = Route("/example", GET) + other1b = Route("/example/", GET) route.matches(other1a) # True, [] route.matches(other1b) # True, [] - other2 = _Route("/other-example", GET) + other2 = Route("/other-example", GET) route.matches(other2) # False, [] ... - route = _Route("/example/", GET) + route = Route("/example/", GET) - other1 = _Route("/example/123", GET) + other1 = Route("/example/123", GET) route.matches(other1) # True, ["123"] - other2 = _Route("/other-example", GET) + other2 = Route("/other-example", GET) route.matches(other2) # False, [] ... - route1 = _Route("/example/.../something", GET) - other1 = _Route("/example/123/something", GET) + 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 = Route("/example/..../something", GET) + other2 = Route("/example/123/456/something", GET) route2.matches(other2) # True, [] """ @@ -103,23 +110,20 @@ def __repr__(self) -> str: path = repr(self.path) methods = repr(self.methods) - return f"_Route(path={path}, methods={methods})" + return f"Route(path={path}, methods={methods})" 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. @@ -146,7 +150,7 @@ def route_func(request, my_parameter): if not found_route: return None - handler = self._handlers[self._routes.index(_route)] + handler = _route.handler keyword_parameters = dict(zip(_route.parameters_names, parameters_values)) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1666df5..e5240c8 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -28,7 +28,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 @@ -117,13 +117,8 @@ 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 From e6a0b02e9c0b75e9d87c8f8606795194821ca8a7 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 06:54:34 +0000 Subject: [PATCH 02/20] Minor refactor of passing URL parameters to handler --- adafruit_httpserver/route.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 6ef7ad4..bb8f8e8 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -98,13 +98,13 @@ def match(self, other: "Route") -> Tuple[bool, List[str]]: """ if not other.methods.issubset(self.methods): - return False, [] + return False, dict() regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: - return False, [] + return False, dict() - return True, regex_match.groups() + return True, dict(zip(self.parameters_names, regex_match.groups())) def __repr__(self) -> str: path = repr(self.path) @@ -141,7 +141,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 @@ -152,8 +152,6 @@ def route_func(request, my_parameter): handler = _route.handler - keyword_parameters = dict(zip(_route.parameters_names, parameters_values)) - def wrapped_handler(request): return handler(request, **keyword_parameters) From 802d7fd9db41e770ffeec93ef0669b388b289ada Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 07:11:27 +0000 Subject: [PATCH 03/20] Added Server.add_routes for importing external routes --- adafruit_httpserver/server.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index e5240c8..8bf6c77 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -123,6 +123,29 @@ def route_decorator(func: Callable) -> Callable: 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.""" From 8fa70b69d0e40406a054a1a6c73d0e92855052be Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 07:38:25 +0000 Subject: [PATCH 04/20] Preparing for returning persistent connection responses --- adafruit_httpserver/response.py | 12 +++++++++++ adafruit_httpserver/server.py | 36 +++++++++++++++++---------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 0310f90..578415f 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -102,6 +102,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 +123,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 +253,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 @@ -309,6 +317,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 @@ -352,6 +361,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 @@ -391,3 +401,5 @@ def __init__( def _send(self) -> None: self._send_headers() + self._close_connection() + diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 8bf6c77..48da6b1 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -334,29 +334,30 @@ def poll(self): 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 + # Receive the whole request + if (request := self._receive_request(conn, client_address)) is None: + conn.close() + return - # 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 + if response is None: + conn.close() + return + + # 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 except Exception as error: # pylint: disable=broad-except if isinstance(error, OSError): @@ -370,6 +371,7 @@ def poll(self): 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: From 90085c301bd0c51e8ae62fd72cd48262da973df6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 07:47:12 +0000 Subject: [PATCH 05/20] Minor tweaks in _send_headers --- adafruit_httpserver/response.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 578415f..77ca08f 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -83,12 +83,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( From 28ae6e5c2700fbe44e9ce7e9e74bc9442ccda64e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 08:51:48 +0000 Subject: [PATCH 06/20] Added SSEResponse class --- adafruit_httpserver/__init__.py | 1 + adafruit_httpserver/response.py | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 03360c9..d2f5781 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -58,6 +58,7 @@ ChunkedResponse, JSONResponse, Redirect, + SSEResponse, ) from .route import Route from .server import Server diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 77ca08f..0f6b16d 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -403,3 +403,97 @@ 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() From ebb7ca74fd57c7d969367ce02e58f3c51ae8cd35 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 08:54:39 +0000 Subject: [PATCH 07/20] Added example for SSEResponse --- examples/httpserver_sse.py | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 examples/httpserver_sse.py diff --git a/examples/httpserver_sse.py b/examples/httpserver_sse.py new file mode 100644 index 0000000..feec76d --- /dev/null +++ b/examples/httpserver_sse.py @@ -0,0 +1,62 @@ +# 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 + + + + + +""" + + +@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 + + 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(f"CPU: {cpu_temp}°C") + next_event_time = monotonic() + 1 From 1e1ad58d1712c0ce25c02f629f7d95de4bbbc44c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 09:29:27 +0000 Subject: [PATCH 08/20] Added Websocket class and SWITCHING_PROTOCOLS_101 --- adafruit_httpserver/__init__.py | 2 + adafruit_httpserver/response.py | 285 +++++++++++++++++++++++++++++++- adafruit_httpserver/status.py | 2 + 3 files changed, 287 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index d2f5781..8b1b62b 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -59,11 +59,13 @@ JSONResponse, Redirect, SSEResponse, + Websocket, ) from .route import Route from .server import Server from .status import ( Status, + SWITCHING_PROTOCOLS_101, OK_200, CREATED_201, ACCEPTED_202, diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 0f6b16d..8fe212e 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -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 @@ -497,3 +505,276 @@ def close(self): """ 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): + # 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 + elif opcode == Websocket.PING: + self.send_message(payload, Websocket.PONG) + return payload + + try: + payload = payload.decode() if opcode == Websocket.TEXT else payload + except UnicodeError as error: + print("Payload UnicodeError: ", error, payload) + 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 without closing connection + 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 None + 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 None + 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/status.py b/adafruit_httpserver/status.py index 57ae47a..f1d41a6 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -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") From 4047ef5af6ea12e3a30c0f18e96d2bb4cb1edf1e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 09:32:32 +0000 Subject: [PATCH 09/20] Added example for Websocket --- examples/httpserver_websocket.py | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 examples/httpserver_websocket.py diff --git a/examples/httpserver_websocket.py b/examples/httpserver_websocket.py new file mode 100644 index 0000000..3a572e0 --- /dev/null +++ b/examples/httpserver_websocket.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2023 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +from time import monotonic +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, Websocket, GET + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +websocket: Websocket = None +next_message_time = monotonic() + +HTML_TEMPLATE = """ + + + Websocket Client + + +
+ + + + + + +""" + + +@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 + + 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 (message := websocket.receive(True)) is not None: + print("Received message from client:", message) + + # Send a message every second + if websocket is not None and next_message_time < monotonic(): + websocket.send_message("Hello from server") + next_message_time = monotonic() + 1 From 86d11c9ab86e8b1c83f4d8d771b3095b3fbb14a4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 09:36:26 +0000 Subject: [PATCH 10/20] Modified neopixel example to use Server.add_routes() --- examples/httpserver_neopixel.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 7e7dab3..d35154b 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -7,7 +7,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response, GET, POST +from adafruit_httpserver import Server, Route, Request, Response, GET, POST pool = socketpool.SocketPool(wifi.radio) @@ -43,7 +43,6 @@ def change_neopixel_color_handler_post_body(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.""" @@ -55,7 +54,6 @@ 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) def change_neopixel_color_handler_url_params( request: Request, r: str = "0", g: str = "0", b: str = "0" ): @@ -67,5 +65,15 @@ def change_neopixel_color_handler_url_params( return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") +url_params_route = Route( + "/change-neopixel-color///", GET, change_neopixel_color_handler_url_params +) + +# Alternative way of registering routes. +server.add_routes([ + Route("/change-neopixel-color/json", GET, change_neopixel_color_handler_post_json), + url_params_route, +]) + server.serve_forever(str(wifi.radio.ipv4_address)) From 20a4eda0d392efc891f9782bcf00556f011db4e6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 09:46:44 +0000 Subject: [PATCH 11/20] Updated docs --- README.rst | 1 + docs/examples.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/README.rst b/README.rst index 5977b72..6b53705 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/docs/examples.rst b/docs/examples.rst index 683c094..b9fed74 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -224,6 +224,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,44-51,61 + :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: 10,16-17,60-67,76,81 + :linenos: + Multiple servers ---------------- From d372f8e2164fc23758648c12aaebedaec24c19f9 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 10:25:38 +0000 Subject: [PATCH 12/20] CI fixes, reformating etc. --- adafruit_httpserver/response.py | 46 ++++++++++++++++---------------- adafruit_httpserver/route.py | 8 +++--- adafruit_httpserver/server.py | 1 + examples/httpserver_neopixel.py | 13 ++++++--- examples/httpserver_sse.py | 2 +- examples/httpserver_websocket.py | 2 +- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 8fe212e..9af8c0f 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -548,12 +548,12 @@ def route_func(request: Request): 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 + 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): @@ -573,7 +573,7 @@ def _process_sec_websocket_key(request: Request) -> str: if key is None: raise ValueError("Request does not have Sec-WebSocket-Key header") - response_key = hashlib.new('sha1', key.encode()) + response_key = hashlib.new("sha1", key.encode()) response_key.update(Websocket.GUID) return b2a_base64(response_key.digest()).strip().decode() @@ -607,7 +607,6 @@ def __init__( # pylint: disable=too-many-arguments request.connection.setblocking(False) - @staticmethod def _parse_frame_header(header): fin = header[0] & Websocket.FIN @@ -626,7 +625,7 @@ def _read_frame(self): buffer = bytearray(self._buffer_size) header_length = self._request.connection.recv_into(buffer, 2) - header_bytes = buffer[:header_length] + header_bytes = buffer[:header_length] fin, opcode, has_mask, length = self._parse_frame_header(header_bytes) @@ -640,7 +639,7 @@ def _read_frame(self): if length < 0: length = self._request.connection.recv_into(buffer, -length) - length = int.from_bytes(buffer[:length], 'big') + length = int.from_bytes(buffer[:length], "big") if has_mask: mask_length = self._request.connection.recv_into(buffer, 4) @@ -648,7 +647,7 @@ def _read_frame(self): while 0 < length: payload_length = self._request.connection.recv_into(buffer, length) - payload += buffer[:min(payload_length, length)] + payload += buffer[: min(payload_length, length)] length -= min(payload_length, length) if has_mask: @@ -656,7 +655,7 @@ def _read_frame(self): return opcode, payload - def _handle_frame(self, opcode: int, payload: bytes): + 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 @@ -667,14 +666,13 @@ def _handle_frame(self, opcode: int, payload: bytes): if opcode == Websocket.PONG: return None - elif opcode == Websocket.PING: + if opcode == Websocket.PING: self.send_message(payload, Websocket.PONG) return payload try: payload = payload.decode() if opcode == Websocket.TEXT else payload - except UnicodeError as error: - print("Payload UnicodeError: ", error, payload) + except UnicodeError: pass return payload @@ -688,7 +686,9 @@ def receive(self, fail_silently: bool = False) -> Union[str, bytes, None]: if self.closed: if fail_silently: return None - raise RuntimeError("Websocket connection is closed, cannot receive messages") + raise RuntimeError( + "Websocket connection is closed, cannot receive messages" + ) try: opcode, payload = self._read_frame() @@ -700,7 +700,7 @@ def receive(self, fail_silently: bool = False) -> Union[str, bytes, None]: return None if error.errno == ETIMEDOUT: # Connection timed out return None - if error.errno == ENOTCONN: # Client disconnected without closing connection + if error.errno == ENOTCONN: # Client disconnected self.close() return None raise error @@ -720,12 +720,12 @@ def _prepare_frame(opcode: int, message: bytes) -> bytearray: # 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')) + 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(payload_length.to_bytes(8, "big")) frame.extend(message) return frame @@ -734,7 +734,7 @@ def send_message( self, message: Union[str, bytes], opcode: int = None, - fail_silently: bool = False + fail_silently: bool = False, ): """ Send a message to the client. @@ -746,7 +746,7 @@ def send_message( """ if self.closed: if fail_silently: - return None + return raise RuntimeError("Websocket connection is closed, cannot send message") determined_opcode = opcode or ( @@ -762,7 +762,7 @@ def send_message( self._send_bytes(self._request.connection, frame) except BrokenPipeError as error: if fail_silently: - return None + return raise error def _send(self) -> None: @@ -775,6 +775,6 @@ def close(self): **Always call this method when you are done sending events.** """ if not self.closed: - self.send_message(b'', Websocket.CLOSE, fail_silently=True) + 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 bb8f8e8..1da8708 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -39,7 +39,9 @@ def __init__( self.path = re.sub(r"<\w+>", r"([^/]+)", path).replace("....", r".+").replace( "...", r"[^/]+" ) + ("/?" if append_slash else "") - self.methods = set(methods) if isinstance(methods, (set, list)) else set([methods]) + self.methods = ( + set(methods) if isinstance(methods, (set, list)) else set([methods]) + ) self.handler = handler @@ -98,11 +100,11 @@ def match(self, other: "Route") -> Tuple[bool, List[str]]: """ if not other.methods.issubset(self.methods): - return False, dict() + return False, {} regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: - return False, dict() + return False, {} return True, dict(zip(self.parameters_names, regex_match.groups())) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 48da6b1..5d4331e 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -117,6 +117,7 @@ def route_func(request, my_parameter): def route_func(request): ... """ + def route_decorator(func: Callable) -> Callable: self._routes.add(Route(path, methods, func, append_slash=append_slash)) return func diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index d35154b..fdaa4e4 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -65,15 +65,20 @@ def change_neopixel_color_handler_url_params( return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + url_params_route = Route( "/change-neopixel-color///", GET, change_neopixel_color_handler_url_params ) # Alternative way of registering routes. -server.add_routes([ - Route("/change-neopixel-color/json", GET, change_neopixel_color_handler_post_json), - url_params_route, -]) +server.add_routes( + [ + Route( + "/change-neopixel-color/json", GET, change_neopixel_color_handler_post_json + ), + url_params_route, + ] +) server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_sse.py b/examples/httpserver_sse.py index feec76d..9369fb2 100644 --- a/examples/httpserver_sse.py +++ b/examples/httpserver_sse.py @@ -41,7 +41,7 @@ def client(request: Request): @server.route("/connect-client", GET) def connect_client(request: Request): - global sse_response + global sse_response # pylint: disable=global-statement if sse_response is not None: sse_response.close() # Close any existing connection diff --git a/examples/httpserver_websocket.py b/examples/httpserver_websocket.py index 3a572e0..1165fe8 100644 --- a/examples/httpserver_websocket.py +++ b/examples/httpserver_websocket.py @@ -57,7 +57,7 @@ def client(request: Request): @server.route("/connect-websocket", GET) def connect_client(request: Request): - global websocket + global websocket # pylint: disable=global-statement if websocket is not None: websocket.close() # Close any existing connection From e34d27dcbb1897b7b92aa0deaaf7af30678a5716 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:47:27 +0000 Subject: [PATCH 13/20] Fix: Wrong returns in docstring --- adafruit_httpserver/route.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 1da8708..a9a8500 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -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 @@ -56,7 +56,7 @@ def _validate_path(path: str, append_slash: bool) -> None: if path.endswith("/") and append_slash: raise ValueError("Cannot use append_slash=True when path ends with /") - def match(self, other: "Route") -> Tuple[bool, List[str]]: + def match(self, other: "Route") -> Tuple[bool, Dict[str, str]]: """ Checks if the route matches the other route. @@ -72,31 +72,31 @@ def match(self, other: "Route") -> Tuple[bool, List[str]]: other1a = Route("/example", GET) other1b = Route("/example/", GET) - route.matches(other1a) # True, [] - route.matches(other1b) # True, [] + route.matches(other1a) # True, {} + route.matches(other1b) # True, {} other2 = Route("/other-example", GET) - route.matches(other2) # False, [] + route.matches(other2) # False, {} ... route = Route("/example/", GET) other1 = Route("/example/123", GET) - route.matches(other1) # True, ["123"] + route.matches(other1) # True, {"parameter": "123"} other2 = Route("/other-example", GET) - route.matches(other2) # False, [] + route.matches(other2) # False, {} ... route1 = Route("/example/.../something", GET) other1 = Route("/example/123/something", GET) - route1.matches(other1) # True, [] + route1.matches(other1) # True, {} route2 = Route("/example/..../something", GET) other2 = Route("/example/123/456/something", GET) - route2.matches(other2) # True, [] + route2.matches(other2) # True, {} """ if not other.methods.issubset(self.methods): From 5c30a2a31bb39c2ec775a26084f9a4761c9c1bef Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 16 Jul 2023 18:44:10 +0000 Subject: [PATCH 14/20] Added as_route decorator as shorthand for creating Route objects --- adafruit_httpserver/__init__.py | 2 +- adafruit_httpserver/route.py | 49 ++++++++++++++++++++++++++++++++- examples/httpserver_neopixel.py | 28 +++++++++++++------ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 8b1b62b..b0df420 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -61,7 +61,7 @@ SSEResponse, Websocket, ) -from .route import Route +from .route import Route, as_route from .server import Server from .status import ( Status, diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index a9a8500..31ff950 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -111,8 +111,55 @@ def match(self, other: "Route") -> Tuple[bool, Dict[str, str]]: def __repr__(self) -> str: path = repr(self.path) methods = repr(self.methods) + handler = repr(self.handler) - return f"Route(path={path}, methods={methods})" + 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. + + It is a shorthand for manually creating a ``Route`` object, that can be used only one time + per 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:: + + # Default method is GET + @as_route("/example") + def some_func(request): + ... + + some_func # Route(path="/example", methods={"GET"}, handler=) + + # If a route 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: diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index fdaa4e4..5b73ef5 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -7,7 +7,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Route, 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,7 +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", 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", POST) def change_neopixel_color_handler_post_body(request: Request): """Changes the color of the built-in NeoPixel using POST body.""" @@ -54,6 +57,13 @@ def change_neopixel_color_handler_post_json(request: Request): return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") +# 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", GET, change_neopixel_color_handler_post_json +) + + def change_neopixel_color_handler_url_params( request: Request, r: str = "0", g: str = "0", b: str = "0" ): @@ -66,17 +76,17 @@ def change_neopixel_color_handler_url_params( return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -url_params_route = Route( - "/change-neopixel-color///", GET, change_neopixel_color_handler_url_params -) - -# Alternative way of registering routes. +# Registering Route objects server.add_routes( [ + change_neopixel_color_handler_post_body, + post_json_route, + # You can also register a inline created Route object Route( - "/change-neopixel-color/json", GET, change_neopixel_color_handler_post_json + path="/change-neopixel-color///", + methods=GET, + handler=change_neopixel_color_handler_url_params, ), - url_params_route, ] ) From d4dc768242a07df6304b040ecd2be7a41e81732c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 18 Jul 2023 18:49:11 +0000 Subject: [PATCH 15/20] Included ethernet example in docs, fixes to emphasized lines --- docs/examples.rst | 15 ++++++++++++--- examples/httpserver_ethernet_simpletest.py | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index aaffb35..34800a0 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 @@ -293,7 +302,7 @@ This might change in the future, but for now, it is recommended to use Websocket .. literalinclude:: ../examples/httpserver_websocket.py :caption: examples/httpserver_websocket.py - :emphasize-lines: 10,16-17,60-67,76,81 + :emphasize-lines: 9,16-17,60-67,76,81 :linenos: Multiple servers 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) From 978a0c9509fdbb8f591131ec05f30b645b914b8e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 21 Jul 2023 00:02:13 +0000 Subject: [PATCH 16/20] Minor change in as_route docstring --- adafruit_httpserver/route.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 31ff950..7a2345c 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -135,14 +135,14 @@ def as_route( Example:: - # Default method is GET + # Converts a function into a Route object @as_route("/example") def some_func(request): ... some_func # Route(path="/example", methods={"GET"}, handler=) - # If a route in another file, you can import it and register it to the server + # If a route is in another file, you can import it and register it to the server from .routes import some_func From d3890130eeaa8436de2e12b7a598a39954cbdaa8 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 21 Jul 2023 08:39:20 +0000 Subject: [PATCH 17/20] Modified as_route docstring to be more verbose --- adafruit_httpserver/route.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 7a2345c..58c6b7e 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -125,8 +125,10 @@ def as_route( """ Decorator used to convert a function into a ``Route`` object. - It is a shorthand for manually creating a ``Route`` object, that can be used only one time - per function. Later it can be imported and registered in the ``Server``. + ``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. @@ -142,6 +144,12 @@ def some_func(request): 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 From 4063b5a39bfc32d71df3dff2497c25e18c46083e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Jul 2023 20:09:30 +0000 Subject: [PATCH 18/20] Updated Copyright headers --- adafruit_httpserver/__init__.py | 2 +- adafruit_httpserver/authentication.py | 2 +- adafruit_httpserver/exceptions.py | 2 +- adafruit_httpserver/headers.py | 2 +- adafruit_httpserver/methods.py | 2 +- adafruit_httpserver/mime_types.py | 2 +- adafruit_httpserver/request.py | 2 +- adafruit_httpserver/response.py | 2 +- adafruit_httpserver/route.py | 2 +- adafruit_httpserver/server.py | 2 +- adafruit_httpserver/status.py | 2 +- examples/home.html | 2 +- examples/httpserver_authentication_handlers.py | 2 +- examples/httpserver_authentication_server.py | 2 +- examples/httpserver_chunked.py | 2 +- examples/httpserver_form_data.py | 2 +- examples/httpserver_handler_serves_file.py | 2 +- examples/httpserver_mdns.py | 2 +- examples/httpserver_methods.py | 2 +- examples/httpserver_multiple_servers.py | 2 +- examples/httpserver_neopixel.py | 2 +- examples/httpserver_redirects.py | 2 +- examples/httpserver_static_files_serving.py | 2 +- examples/httpserver_url_parameters.py | 2 +- examples/settings.toml | 2 +- 25 files changed, 25 insertions(+), 25 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index b3a6874..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 """ 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..b2f0490 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 """ diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index c3e2531..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 """ diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 58c6b7e..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 """ diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 59f03e9..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 """ diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index f1d41a6..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 """ 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_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 5a2d6fe..1449e05 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 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_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/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 From 7d8c0b1271f450f38ab53f6353373d2187d17189 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 31 Jul 2023 07:01:43 +0000 Subject: [PATCH 19/20] Made SSE and Websocket examples more visual --- docs/examples.rst | 4 +-- examples/httpserver_sse.py | 8 ++++-- examples/httpserver_websocket.py | 49 +++++++++++++++++++------------- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 34800a0..05e07de 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -283,7 +283,7 @@ This might change in the future, but for now, it is recommended to use SSE only .. literalinclude:: ../examples/httpserver_sse.py :caption: examples/httpserver_sse.py - :emphasize-lines: 10,17,44-51,61 + :emphasize-lines: 10,17,46-53,63 :linenos: Websockets @@ -302,7 +302,7 @@ This might change in the future, but for now, it is recommended to use Websocket .. literalinclude:: ../examples/httpserver_websocket.py :caption: examples/httpserver_websocket.py - :emphasize-lines: 9,16-17,60-67,76,81 + :emphasize-lines: 12,21,67-73,83,90 :linenos: Multiple servers diff --git a/examples/httpserver_sse.py b/examples/httpserver_sse.py index 9369fb2..3ad6c73 100644 --- a/examples/httpserver_sse.py +++ b/examples/httpserver_sse.py @@ -23,11 +23,13 @@ Server-Sent Events Client +

CPU temperature: -°C

@@ -58,5 +60,5 @@ def connect_client(request: Request): # 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(f"CPU: {cpu_temp}°C") + sse_response.send_event(str(cpu_temp)) next_event_time = monotonic() + 1 diff --git a/examples/httpserver_websocket.py b/examples/httpserver_websocket.py index 1165fe8..396a6c1 100644 --- a/examples/httpserver_websocket.py +++ b/examples/httpserver_websocket.py @@ -3,6 +3,9 @@ # SPDX-License-Identifier: Unlicense from time import monotonic +import board +import microcontroller +import neopixel import socketpool import wifi @@ -12,6 +15,8 @@ pool = socketpool.SocketPool(wifi.radio) server = Server(pool, debug=True) +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) + websocket: Websocket = None next_message_time = monotonic() @@ -22,28 +27,30 @@ Websocket Client -
- - - +

CPU temperature: -°C

+

NeoPixel Color:

@@ -73,10 +80,12 @@ def connect_client(request: Request): # Check for incoming messages from client if websocket is not None: - if (message := websocket.receive(True)) is not None: - print("Received message from client:", message) + 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(): - websocket.send_message("Hello from server") + cpu_temp = round(microcontroller.cpu.temperature, 2) + websocket.send_message(str(cpu_temp)) next_message_time = monotonic() + 1 From 5e57a6496a50b34658aca0234f565daf1a18269a Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:26:41 +0000 Subject: [PATCH 20/20] Fix: Wrong method in example and .json() for non-POST requests --- adafruit_httpserver/request.py | 4 ++-- examples/httpserver_neopixel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index b2f0490..50118d5 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -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/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 1449e05..ba60fd2 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -60,7 +60,7 @@ def change_neopixel_color_handler_post_json(request: Request): # 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", GET, change_neopixel_color_handler_post_json + "/change-neopixel-color/json", POST, change_neopixel_color_handler_post_json )