From 0fff6023705277729f9180b9105e11844326f7f4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 24 Apr 2023 15:11:48 +0000 Subject: [PATCH 01/44] Replaced public header_body_bytes by private methods --- adafruit_httpserver/request.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 908b462..6f627da 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -83,7 +83,7 @@ def __init__( if raw_request is None: raise ValueError("raw_request cannot be None") - header_bytes = self.header_body_bytes[0] + header_bytes = self._raw_header_bytes try: ( @@ -99,21 +99,25 @@ def __init__( @property def body(self) -> bytes: """Body of the request, as bytes.""" - return self.header_body_bytes[1] + return self._raw_body_bytes @body.setter def body(self, body: bytes) -> None: - self.raw_request = self.header_body_bytes[0] + b"\r\n\r\n" + body + self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body @property - def header_body_bytes(self) -> Tuple[bytes, bytes]: - """Return tuple of header and body bytes.""" + def _raw_header_bytes(self) -> bytes: + """Returns headers bytes.""" + empty_line_index = self.raw_request.find(b"\r\n\r\n") + + return self.raw_request[:empty_line_index] + @property + def _raw_body_bytes(self) -> bytes: + """Returns body bytes.""" empty_line_index = self.raw_request.find(b"\r\n\r\n") - header_bytes = self.raw_request[:empty_line_index] - body_bytes = self.raw_request[empty_line_index + 4 :] - return header_bytes, body_bytes + return self.raw_request[empty_line_index + 4 :] @staticmethod def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], str]: From 75ac0f2b499868495621ce4b06b873d066bb51ed Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 25 Apr 2023 17:02:59 +0000 Subject: [PATCH 02/44] Added authentication logic, AuthenticationError, UNAUTHORIZED_401 status --- adafruit_httpserver/authentication.py | 68 +++++++++++++++++++++++++++ adafruit_httpserver/exceptions.py | 6 +++ adafruit_httpserver/server.py | 9 +++- adafruit_httpserver/status.py | 3 ++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 adafruit_httpserver/authentication.py diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py new file mode 100644 index 0000000..b5df705 --- /dev/null +++ b/adafruit_httpserver/authentication.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.authentication` +==================================================== +* Author(s): Michał Pokusa +""" + +try: + from typing import Union, List +except ImportError: + pass + +from binascii import b2a_base64 + +from .exceptions import AuthenticationError +from .request import HTTPRequest + + +class Basic: + """Represents HTTP Basic Authentication.""" + + def __init__(self, username: str, password: str) -> None: + self._value = b2a_base64(f"{username}:{password}".encode()).decode().strip() + + def __str__(self) -> str: + return f"Basic {self._value}" + + +class Bearer: + """Represents HTTP Bearer Token Authentication.""" + + def __init__(self, token: str) -> None: + self._value = token + + def __str__(self) -> str: + return f"Bearer {self._value}" + + +def check_authentication( + request: HTTPRequest, auths: List[Union[Basic, Bearer]] +) -> bool: + """ + Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise. + """ + + auth_header = request.headers.get("Authorization") + + if auth_header is None: + return False + + return any(auth_header == str(auth) for auth in auths) + + +def require_authentication( + request: HTTPRequest, auths: List[Union[Basic, Bearer]] +) -> None: + """ + Checks if the request is authorized and raises ``AuthenticationError`` if not. + + If the error is not caught, the server will return ``401 Unauthorized``. + """ + + if not check_authentication(request, auths): + raise AuthenticationError( + "Request is not authenticated by any of the provided authentications" + ) diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index ca70712..118e8fc 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -8,6 +8,12 @@ """ +class AuthenticationError(Exception): + """ + Raised by ``require_authentication`` when the ``HTTPRequest`` is not authorized. + """ + + class InvalidPathError(Exception): """ Parent class for all path related errors. diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index a45eb1a..e6e4388 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -16,7 +16,7 @@ from errno import EAGAIN, ECONNRESET, ETIMEDOUT -from .exceptions import FileNotExistsError, InvalidPathError +from .exceptions import AuthenticationError, FileNotExistsError, InvalidPathError from .methods import HTTPMethod from .request import HTTPRequest from .response import HTTPResponse @@ -185,6 +185,13 @@ def poll(self): request, status=CommonHTTPStatus.BAD_REQUEST_400 ).send() + except AuthenticationError: + HTTPResponse( + request, + status=CommonHTTPStatus.UNAUTHORIZED_401, + headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, + ).send() + except InvalidPathError as error: HTTPResponse(request, status=CommonHTTPStatus.FORBIDDEN_403).send( str(error) diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 8a7b198..d93577a 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -39,6 +39,9 @@ class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") """400 Bad Request""" + UNAUTHORIZED_401 = HTTPStatus(401, "Unauthorized") + """401 Unauthorized""" + FORBIDDEN_403 = HTTPStatus(403, "Forbidden") """403 Forbidden""" From d4a8a8dc3fb4f3cdc932971956e6691dde7ceaaa Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 25 Apr 2023 19:03:42 +0000 Subject: [PATCH 03/44] Removed HTTP... prefix from class names --- adafruit_httpserver/authentication.py | 10 ++---- adafruit_httpserver/exceptions.py | 4 +-- adafruit_httpserver/headers.py | 9 +++-- adafruit_httpserver/methods.py | 41 ++++++++++------------ adafruit_httpserver/request.py | 14 ++++---- adafruit_httpserver/response.py | 46 ++++++++++++------------ adafruit_httpserver/route.py | 39 ++++++++++----------- adafruit_httpserver/server.py | 50 ++++++++++++--------------- adafruit_httpserver/status.py | 13 +++---- 9 files changed, 106 insertions(+), 120 deletions(-) diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py index b5df705..211ab63 100644 --- a/adafruit_httpserver/authentication.py +++ b/adafruit_httpserver/authentication.py @@ -15,7 +15,7 @@ from binascii import b2a_base64 from .exceptions import AuthenticationError -from .request import HTTPRequest +from .request import Request class Basic: @@ -38,9 +38,7 @@ def __str__(self) -> str: return f"Bearer {self._value}" -def check_authentication( - request: HTTPRequest, auths: List[Union[Basic, Bearer]] -) -> bool: +def check_authentication(request: Request, auths: List[Union[Basic, Bearer]]) -> bool: """ Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise. """ @@ -53,9 +51,7 @@ def check_authentication( return any(auth_header == str(auth) for auth in auths) -def require_authentication( - request: HTTPRequest, auths: List[Union[Basic, Bearer]] -) -> None: +def require_authentication(request: Request, auths: List[Union[Basic, Bearer]]) -> None: """ Checks if the request is authorized and raises ``AuthenticationError`` if not. diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index 118e8fc..7c2ffd5 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -10,7 +10,7 @@ class AuthenticationError(Exception): """ - Raised by ``require_authentication`` when the ``HTTPRequest`` is not authorized. + Raised by ``require_authentication`` when the ``Request`` is not authorized. """ @@ -42,7 +42,7 @@ def __init__(self, path: str) -> None: class ResponseAlreadySentError(Exception): """ - Another ``HTTPResponse`` has already been sent. There can only be one per ``HTTPRequest``. + Another ``Response`` has already been sent. There can only be one per ``Request``. """ diff --git a/adafruit_httpserver/headers.py b/adafruit_httpserver/headers.py index cf9ea20..1b40f65 100644 --- a/adafruit_httpserver/headers.py +++ b/adafruit_httpserver/headers.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.headers.HTTPHeaders` +`adafruit_httpserver.headers` ==================================================== * Author(s): Michał Pokusa """ @@ -13,7 +13,7 @@ pass -class HTTPHeaders: +class Headers: """ A dict-like class for storing HTTP headers. @@ -23,7 +23,7 @@ class HTTPHeaders: Examples:: - headers = HTTPHeaders({"Content-Type": "text/html", "Content-Length": "1024"}) + headers = Headers({"Content-Type": "text/html", "Content-Length": "1024"}) len(headers) # 2 @@ -48,7 +48,6 @@ class HTTPHeaders: _storage: Dict[str, Tuple[str, str]] def __init__(self, headers: Dict[str, str] = None) -> None: - headers = headers or {} self._storage = {key.lower(): [key, value] for key, value in headers.items()} @@ -81,7 +80,7 @@ def update(self, headers: Dict[str, str]): def copy(self): """Returns a copy of the headers.""" - return HTTPHeaders(dict(self._storage.values())) + return Headers(dict(self._storage.values())) def __getitem__(self, name: str): return self._storage[name.lower()][1] diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index 319b631..4b74d87 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -2,38 +2,35 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.methods.HTTPMethod` +`adafruit_httpserver.methods` ==================================================== * Author(s): Michał Pokusa """ -class HTTPMethod: # pylint: disable=too-few-public-methods - """Enum with HTTP methods.""" +GET = "GET" +"""GET method.""" - GET = "GET" - """GET method.""" +POST = "POST" +"""POST method.""" - POST = "POST" - """POST method.""" +PUT = "PUT" +"""PUT method""" - PUT = "PUT" - """PUT method""" +DELETE = "DELETE" +"""DELETE method""" - DELETE = "DELETE" - """DELETE method""" +PATCH = "PATCH" +"""PATCH method""" - PATCH = "PATCH" - """PATCH method""" +HEAD = "HEAD" +"""HEAD method""" - HEAD = "HEAD" - """HEAD method""" +OPTIONS = "OPTIONS" +"""OPTIONS method""" - OPTIONS = "OPTIONS" - """OPTIONS method""" +TRACE = "TRACE" +"""TRACE method""" - TRACE = "TRACE" - """TRACE method""" - - CONNECT = "CONNECT" - """CONNECT method""" +CONNECT = "CONNECT" +"""CONNECT method""" diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 6f627da..2eb5fe7 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.request.HTTPRequest` +`adafruit_httpserver.request` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ @@ -14,10 +14,10 @@ except ImportError: pass -from .headers import HTTPHeaders +from .headers import Headers -class HTTPRequest: +class Request: """ Incoming request, constructed from raw incoming bytes. It is passed as first argument to route handlers. @@ -50,7 +50,7 @@ class HTTPRequest: Example:: - request = HTTPRequest(raw_request=b"GET /?foo=bar HTTP/1.1...") + request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...") request.query_params # {"foo": "bar"} """ @@ -58,7 +58,7 @@ class HTTPRequest: http_version: str """HTTP version, e.g. "HTTP/1.1".""" - headers: HTTPHeaders + headers: Headers """ Headers from the request. """ @@ -143,11 +143,11 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st return method, path, query_params, http_version @staticmethod - def _parse_headers(header_bytes: bytes) -> HTTPHeaders: + def _parse_headers(header_bytes: bytes) -> Headers: """Parse HTTP headers from raw request.""" header_lines = header_bytes.decode("utf8").splitlines()[1:] - return HTTPHeaders( + return Headers( { name: value for header_line in header_lines diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index ee35550..fdf7090 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.response.HTTPResponse` +`adafruit_httpserver.response` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ @@ -24,9 +24,9 @@ ResponseAlreadySentError, ) from .mime_type import MIMEType -from .request import HTTPRequest -from .status import HTTPStatus, CommonHTTPStatus -from .headers import HTTPHeaders +from .request import Request +from .status import Status, CommonHTTPStatus +from .headers import Headers def _prevent_multiple_send_calls(function: Callable): @@ -34,7 +34,7 @@ def _prevent_multiple_send_calls(function: Callable): Decorator that prevents calling ``send`` or ``send_file`` more than once. """ - def wrapper(self: "HTTPResponse", *args, **kwargs): + def wrapper(self: "Response", *args, **kwargs): if self._response_already_sent: # pylint: disable=protected-access raise ResponseAlreadySentError @@ -44,9 +44,9 @@ def wrapper(self: "HTTPResponse", *args, **kwargs): return wrapper -class HTTPResponse: +class Response: """ - Response to a given `HTTPRequest`. Use in `HTTPServer.route` decorator functions. + Response to a given `Request`. Use in `Server.route` handler functions. Example:: @@ -54,42 +54,42 @@ class HTTPResponse: @server.route(path, method) def route_func(request): - response = HTTPResponse(request) + response = Response(request) response.send("Some content", content_type="text/plain") # or - response = HTTPResponse(request) + response = Response(request) with response: response.send(body='Some content', content_type="text/plain") # or - with HTTPResponse(request) as response: + with Response(request) as response: response.send("Some content", content_type="text/plain") # Response with 'Transfer-Encoding: chunked' header @server.route(path, method) def route_func(request): - response = HTTPResponse(request, content_type="text/plain", chunked=True) + response = Response(request, content_type="text/plain", chunked=True) with response: response.send_chunk("Some content") response.send_chunk("Some more content") # or - with HTTPResponse(request, content_type="text/plain", chunked=True) as response: + with Response(request, content_type="text/plain", chunked=True) as response: response.send_chunk("Some content") response.send_chunk("Some more content") """ - request: HTTPRequest + request: Request """The request that this is a response to.""" http_version: str - status: HTTPStatus - headers: HTTPHeaders + status: Status + headers: Headers content_type: str """ Defaults to ``text/plain`` if not set. @@ -102,9 +102,9 @@ def route_func(request): def __init__( # pylint: disable=too-many-arguments self, - request: HTTPRequest, - status: Union[HTTPStatus, Tuple[int, str]] = CommonHTTPStatus.OK_200, - headers: Union[HTTPHeaders, Dict[str, str]] = None, + request: Request, + status: Union[Status, Tuple[int, str]] = CommonHTTPStatus.OK_200, + headers: Union[Headers, Dict[str, str]] = None, content_type: str = None, http_version: str = "HTTP/1.1", chunked: bool = False, @@ -117,12 +117,12 @@ def __init__( # pylint: disable=too-many-arguments To send the response, call ``send`` or ``send_file``. For chunked response use - ``with HTTPRequest(request, content_type=..., chunked=True) as r:`` and `send_chunk`. + ``with Response(request, content_type=..., chunked=True) as r:`` and `send_chunk`. """ self.request = request - self.status = status if isinstance(status, HTTPStatus) else HTTPStatus(*status) + self.status = status if isinstance(status, Status) else Status(*status) self.headers = ( - headers.copy() if isinstance(headers, HTTPHeaders) else HTTPHeaders(headers) + headers.copy() if isinstance(headers, Headers) else Headers(headers) ) self.content_type = content_type self.http_version = http_version @@ -137,7 +137,7 @@ def _send_headers( """ Sends headers. Implicitly called by ``send`` and ``send_file`` and in - ``with HTTPResponse(request, chunked=True) as response:`` context manager. + ``with Response(request, chunked=True) as response:`` context manager. """ headers = self.headers.copy() @@ -259,7 +259,7 @@ def send_chunk(self, chunk: str = "") -> None: Sends chunk of response. Should be used **only** inside - ``with HTTPResponse(request, chunked=True) as response:`` context manager. + ``with Response(request, chunked=True) as response:`` context manager. :param str chunk: String data to be sent. """ diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index fac06a1..996fc32 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.route._HTTPRoute` +`adafruit_httpserver.route` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ @@ -14,14 +14,13 @@ import re -from .methods import HTTPMethod +from .methods import GET -class _HTTPRoute: - """Route definition for different paths, see `adafruit_httpserver.server.HTTPServer.route`.""" - - def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: +class _Route: + """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" + def __init__(self, path: str = "", method: str = GET) -> None: contains_parameters = re.search(r"<\w*>", path) is not None self.path = ( @@ -30,7 +29,7 @@ def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: self.method = method self._contains_parameters = contains_parameters - def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]: + def match(self, other: "_Route") -> Tuple[bool, List[str]]: """ Checks if the route matches the other route. @@ -42,22 +41,22 @@ def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]: Examples:: - route = _HTTPRoute("/example", HTTPMethod.GET) + route = _Route("/example", GET) - other1 = _HTTPRoute("/example", HTTPMethod.GET) + other1 = _Route("/example", GET) route.matches(other1) # True, [] - other2 = _HTTPRoute("/other-example", HTTPMethod.GET) + other2 = _Route("/other-example", GET) route.matches(other2) # False, [] ... - route = _HTTPRoute("/example/", HTTPMethod.GET) + route = _Route("/example/", GET) - other1 = _HTTPRoute("/example/123", HTTPMethod.GET) + other1 = _Route("/example/123", GET) route.matches(other1) # True, ["123"] - other2 = _HTTPRoute("/other-example", HTTPMethod.GET) + other2 = _Route("/other-example", GET) route.matches(other2) # False, [] """ @@ -74,23 +73,23 @@ def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]: return True, regex_match.groups() def __repr__(self) -> str: - return f"_HTTPRoute(path={repr(self.path)}, method={repr(self.method)})" + return f"_Route(path={repr(self.path)}, method={repr(self.method)})" -class _HTTPRoutes: +class _Routes: """A collection of routes and their corresponding handlers.""" def __init__(self) -> None: - self._routes: List[_HTTPRoute] = [] + self._routes: List[_Route] = [] self._handlers: List[Callable] = [] - def add(self, route: _HTTPRoute, handler: Callable): + def add(self, route: _Route, handler: Callable): """Adds a route and its handler to the collection.""" self._routes.append(route) self._handlers.append(handler) - def find_handler(self, route: _HTTPRoute) -> Union[Callable, None]: + def find_handler(self, route: _Route) -> Union[Callable, None]: """ Finds a handler for a given route. @@ -99,7 +98,7 @@ def find_handler(self, route: _HTTPRoute) -> Union[Callable, None]: Example:: - @server.route("/example/", HTTPMethod.GET) + @server.route("/example/", GET) def route_func(request, my_parameter): ... request.path == "/example/123" # True @@ -128,4 +127,4 @@ def wrapped_handler(request): return wrapped_handler def __repr__(self) -> str: - return f"_HTTPRoutes({repr(self._routes)})" + return f"_Routes({repr(self._routes)})" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index e6e4388..bb527bd 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.server.HTTPServer` +`adafruit_httpserver.server` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ @@ -17,14 +17,14 @@ from errno import EAGAIN, ECONNRESET, ETIMEDOUT from .exceptions import AuthenticationError, FileNotExistsError, InvalidPathError -from .methods import HTTPMethod -from .request import HTTPRequest -from .response import HTTPResponse -from .route import _HTTPRoutes, _HTTPRoute +from .methods import GET, HEAD +from .request import Request +from .response import Response +from .route import _Routes, _Route from .status import CommonHTTPStatus -class HTTPServer: +class Server: """A basic socket-based HTTP server.""" def __init__(self, socket_source: Protocol, root_path: str) -> None: @@ -36,21 +36,22 @@ def __init__(self, socket_source: Protocol, root_path: str) -> None: """ self._buffer = bytearray(1024) self._timeout = 1 - self.routes = _HTTPRoutes() + self.routes = _Routes() self._socket_source = socket_source self._sock = None self.root_path = root_path - def route(self, path: str, method: HTTPMethod = HTTPMethod.GET) -> Callable: + def route(self, path: str, method: str = GET) -> Callable: """ Decorator used to add a route. :param str path: URL path - :param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc. + :param str method: HTTP method: `"GET"`, `"POST"`, etc. Example:: - @server.route("/example", HTTPMethod.GET) + # Default method is GET + @server.route("/example") def route_func(request): ... @@ -148,7 +149,7 @@ def poll(self): if not header_bytes: return - request = HTTPRequest(conn, client_address, header_bytes) + request = Request(conn, client_address, header_bytes) content_length = int(request.headers.get("Content-Length", 0)) received_body_bytes = request.body @@ -159,9 +160,7 @@ def poll(self): ) # Find a handler for the route - handler = self.routes.find_handler( - _HTTPRoute(request.path, request.method) - ) + handler = self.routes.find_handler(_Route(request.path, request.method)) try: # If a handler for route exists and is callable, call it. @@ -169,16 +168,13 @@ def poll(self): handler(request) # If no handler exists and request method is GET or HEAD, try to serve a file. - elif handler is None and request.method in ( - HTTPMethod.GET, - HTTPMethod.HEAD, - ): + elif handler is None and request.method in [GET, HEAD]: filename = "index.html" if request.path == "/" else request.path - HTTPResponse(request).send_file( + Response(request).send_file( filename=filename, root_path=self.root_path, buffer_size=self.request_buffer_size, - head_only=(request.method == HTTPMethod.HEAD), + head_only=(request.method == HEAD), ) else: HTTPResponse( @@ -186,19 +182,19 @@ def poll(self): ).send() except AuthenticationError: - HTTPResponse( + Response( request, status=CommonHTTPStatus.UNAUTHORIZED_401, headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, ).send() except InvalidPathError as error: - HTTPResponse(request, status=CommonHTTPStatus.FORBIDDEN_403).send( + Response(request, status=CommonHTTPStatus.FORBIDDEN_403).send( str(error) ) except FileNotExistsError as error: - HTTPResponse(request, status=CommonHTTPStatus.NOT_FOUND_404).send( + Response(request, status=CommonHTTPStatus.NOT_FOUND_404).send( str(error) ) @@ -223,7 +219,7 @@ def request_buffer_size(self) -> int: Example:: - server = HTTPServer(pool, "/static") + server = Server(pool, "/static") server.request_buffer_size = 2048 server.serve_forever(str(wifi.radio.ipv4_address)) @@ -245,7 +241,7 @@ def socket_timeout(self) -> int: Example:: - server = HTTPServer(pool, "/static") + server = Server(pool, "/static") server.socket_timeout = 3 server.serve_forever(str(wifi.radio.ipv4_address)) @@ -257,6 +253,4 @@ def socket_timeout(self, value: int) -> None: if isinstance(value, (int, float)) and value > 0: self._timeout = value else: - raise ValueError( - "HTTPServer.socket_timeout must be a positive numeric value." - ) + raise ValueError("Server.socket_timeout must be a positive numeric value.") diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index d93577a..a2736b4 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -2,17 +2,18 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.status.HTTPStatus` +`adafruit_httpserver.status` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ -class HTTPStatus: # pylint: disable=too-few-public-methods - """HTTP status codes.""" +class Status: # pylint: disable=too-few-public-methods + """HTTP status code.""" def __init__(self, code: int, text: str): - """Define a status code. + """ + Define a status code. :param int code: Numeric value: 200, 404, etc. :param str text: Short phrase: "OK", "Not Found', etc. @@ -21,12 +22,12 @@ def __init__(self, code: int, text: str): self.text = text def __repr__(self): - return f'HTTPStatus({self.code}, "{self.text}")' + return f'Status({self.code}, "{self.text}")' def __str__(self): return f"{self.code} {self.text}" - def __eq__(self, other: "HTTPStatus"): + def __eq__(self, other: "Status"): return self.code == other.code and self.text == other.text From 4224ac87c4abd4b3647aba1347973220f91a67c4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 25 Apr 2023 19:15:33 +0000 Subject: [PATCH 04/44] Replaced CommonHTTPStatus with direct values --- adafruit_httpserver/response.py | 4 +-- adafruit_httpserver/server.py | 16 ++++-------- adafruit_httpserver/status.py | 43 ++++++++++++++++++++++----------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index fdf7090..4e462d3 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -25,7 +25,7 @@ ) from .mime_type import MIMEType from .request import Request -from .status import Status, CommonHTTPStatus +from .status import Status, OK_200 from .headers import Headers @@ -103,7 +103,7 @@ def route_func(request): def __init__( # pylint: disable=too-many-arguments self, request: Request, - status: Union[Status, Tuple[int, str]] = CommonHTTPStatus.OK_200, + status: Union[Status, Tuple[int, str]] = OK_200, headers: Union[Headers, Dict[str, str]] = None, content_type: str = None, http_version: str = "HTTP/1.1", diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index bb527bd..1dd591e 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -21,7 +21,7 @@ from .request import Request from .response import Response from .route import _Routes, _Route -from .status import CommonHTTPStatus +from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 class Server: @@ -177,26 +177,20 @@ def poll(self): head_only=(request.method == HEAD), ) else: - HTTPResponse( - request, status=CommonHTTPStatus.BAD_REQUEST_400 - ).send() + Response(request, status=BAD_REQUEST_400).send() except AuthenticationError: Response( request, - status=CommonHTTPStatus.UNAUTHORIZED_401, + status=UNAUTHORIZED_401, headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, ).send() except InvalidPathError as error: - Response(request, status=CommonHTTPStatus.FORBIDDEN_403).send( - str(error) - ) + Response(request, status=FORBIDDEN_403).send(str(error)) except FileNotExistsError as error: - Response(request, status=CommonHTTPStatus.NOT_FOUND_404).send( - str(error) - ) + Response(request, status=NOT_FOUND_404).send(str(error)) except OSError as error: # Handle EAGAIN and ECONNRESET diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index a2736b4..28c00b6 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -31,23 +31,38 @@ def __eq__(self, other: "Status"): return self.code == other.code and self.text == other.text -class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods - """Common HTTP status codes.""" +OK_200 = Status(200, "OK") +"""200 OK""" - OK_200 = HTTPStatus(200, "OK") - """200 OK""" +NO_CONTENT_204 = Status(204, "No Content") +"""204 No Content""" - BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") - """400 Bad Request""" +TEMPORARY_REDIRECT_307 = Status(307, "Temporary Redirect") +"""307 Temporary Redirect""" - UNAUTHORIZED_401 = HTTPStatus(401, "Unauthorized") - """401 Unauthorized""" +PERMANENT_REDIRECT_308 = Status(308, "Permanent Redirect") +"""308 Permanent Redirect""" - FORBIDDEN_403 = HTTPStatus(403, "Forbidden") - """403 Forbidden""" +BAD_REQUEST_400 = Status(400, "Bad Request") +"""400 Bad Request""" - NOT_FOUND_404 = HTTPStatus(404, "Not Found") - """404 Not Found""" +UNAUTHORIZED_401 = Status(401, "Unauthorized") +"""401 Unauthorized""" - INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") - """500 Internal Server Error""" +FORBIDDEN_403 = Status(403, "Forbidden") +"""403 Forbidden""" + +NOT_FOUND_404 = Status(404, "Not Found") +"""404 Not Found""" + +METHOD_NOT_ALLOWED_405 = Status(405, "Method Not Allowed") +"""405 Method Not Allowed""" + +INTERNAL_SERVER_ERROR_500 = Status(500, "Internal Server Error") +"""500 Internal Server Error""" + +NOT_IMPLEMENTED_501 = Status(501, "Not Implemented") +"""501 Not Implemented""" + +SERVICE_UNAVAILABLE_503 = Status(503, "Service Unavailable") +"""503 Service Unavailable""" From 33fecc982776ae70fd8155f491c4352333c54623 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 25 Apr 2023 19:17:31 +0000 Subject: [PATCH 05/44] Allowed passing multiple methods at the same time to .route --- adafruit_httpserver/server.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1dd591e..cb4aceb 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,7 +8,7 @@ """ try: - from typing import Callable, Protocol, Union + from typing import Callable, Protocol, Union, List from socket import socket from socketpool import SocketPool except ImportError: @@ -41,7 +41,7 @@ def __init__(self, socket_source: Protocol, root_path: str) -> None: self._sock = None self.root_path = root_path - def route(self, path: str, method: str = GET) -> Callable: + def route(self, path: str, methods: Union[str, List[str]] = GET) -> Callable: """ Decorator used to add a route. @@ -55,13 +55,27 @@ def route(self, path: str, method: str = GET) -> Callable: def route_func(request): ... - @server.route("/example/", HTTPMethod.GET) + # It is necessary to specify other methods like POST, PUT, etc. + @server.route("/example", POST) + def route_func(request): + ... + + # Multiple methods can be specified + @server.route("/example", [GET, POST]) + def route_func(request): + ... + + # URL parameters can be specified + @server.route("/example/", GET) def route_func(request, my_parameter): ... """ + if isinstance(methods, str): + methods = [methods] def route_decorator(func: Callable) -> Callable: - self.routes.add(_HTTPRoute(path, method), func) + for method in methods: + self.routes.add(_Route(path, method), func) return func return route_decorator From ee7a8b06fb7621f6127f965a9f7643d9d824c45d Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 25 Apr 2023 20:43:36 +0000 Subject: [PATCH 06/44] Major refactor of MIMETypes --- adafruit_httpserver/mime_type.py | 100 --------------- adafruit_httpserver/mime_types.py | 207 ++++++++++++++++++++++++++++++ adafruit_httpserver/response.py | 8 +- 3 files changed, 211 insertions(+), 104 deletions(-) delete mode 100644 adafruit_httpserver/mime_type.py create mode 100644 adafruit_httpserver/mime_types.py diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py deleted file mode 100644 index 39e592e..0000000 --- a/adafruit_httpserver/mime_type.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries -# -# SPDX-License-Identifier: MIT -""" -`adafruit_httpserver.mime_type.MIMEType` -==================================================== -* Author(s): Dan Halbert, Michał Pokusa -""" - - -class MIMEType: - """Common MIME types. - From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - """ - - TYPE_AAC = "audio/aac" - TYPE_ABW = "application/x-abiword" - TYPE_ARC = "application/x-freearc" - TYPE_AVI = "video/x-msvideo" - TYPE_AZW = "application/vnd.amazon.ebook" - TYPE_BIN = "application/octet-stream" - TYPE_BMP = "image/bmp" - TYPE_BZ = "application/x-bzip" - TYPE_BZ2 = "application/x-bzip2" - TYPE_CSH = "application/x-csh" - TYPE_CSS = "text/css" - TYPE_CSV = "text/csv" - TYPE_DOC = "application/msword" - TYPE_DOCX = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) - TYPE_EOT = "application/vnd.ms-fontobject" - TYPE_EPUB = "application/epub+zip" - TYPE_GZ = "application/gzip" - TYPE_GIF = "image/gif" - TYPE_HTML = "text/html" - TYPE_HTM = "text/html" - TYPE_ICO = "image/vnd.microsoft.icon" - TYPE_ICS = "text/calendar" - TYPE_JAR = "application/java-archive" - TYPE_JPEG = "image/jpeg" - TYPE_JPG = "image/jpeg" - TYPE_JS = "text/javascript" - TYPE_JSON = "application/json" - TYPE_JSONLD = "application/ld+json" - TYPE_MID = "audio/midi" - TYPE_MIDI = "audio/midi" - TYPE_MJS = "text/javascript" - TYPE_MP3 = "audio/mpeg" - TYPE_CDA = "application/x-cdf" - TYPE_MP4 = "video/mp4" - TYPE_MPEG = "video/mpeg" - TYPE_MPKG = "application/vnd.apple.installer+xml" - TYPE_ODP = "application/vnd.oasis.opendocument.presentation" - TYPE_ODS = "application/vnd.oasis.opendocument.spreadsheet" - TYPE_ODT = "application/vnd.oasis.opendocument.text" - TYPE_OGA = "audio/ogg" - TYPE_OGV = "video/ogg" - TYPE_OGX = "application/ogg" - TYPE_OPUS = "audio/opus" - TYPE_OTF = "font/otf" - TYPE_PNG = "image/png" - TYPE_PDF = "application/pdf" - TYPE_PHP = "application/x-httpd-php" - TYPE_PPT = "application/vnd.ms-powerpoint" - TYPE_PPTX = ( - "application/vnd.openxmlformats-officedocument.presentationml.presentation" - ) - TYPE_RAR = "application/vnd.rar" - TYPE_RTF = "application/rtf" - TYPE_SH = "application/x-sh" - TYPE_SVG = "image/svg+xml" - TYPE_SWF = "application/x-shockwave-flash" - TYPE_TAR = "application/x-tar" - TYPE_TIFF = "image/tiff" - TYPE_TIF = "image/tiff" - TYPE_TS = "video/mp2t" - TYPE_TTF = "font/ttf" - TYPE_TXT = "text/plain" - TYPE_VSD = "application/vnd.visio" - TYPE_WAV = "audio/wav" - TYPE_WEBA = "audio/webm" - TYPE_WEBM = "video/webm" - TYPE_WEBP = "image/webp" - TYPE_WOFF = "font/woff" - TYPE_WOFF2 = "font/woff2" - TYPE_XHTML = "application/xhtml+xml" - TYPE_XLS = "application/vnd.ms-excel" - TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - TYPE_XML = "application/xml" - TYPE_XUL = "application/vnd.mozilla.xul+xml" - TYPE_ZIP = "application/zip" - TYPE_7Z = "application/x-7z-compressed" - - @staticmethod - def from_file_name(filename: str): - """Return the mime type for the given filename. If not known, return "text/plain".""" - attr_name = "TYPE_" + filename.split(".")[-1].upper() - - return getattr(MIMEType, attr_name, MIMEType.TYPE_TXT) diff --git a/adafruit_httpserver/mime_types.py b/adafruit_httpserver/mime_types.py new file mode 100644 index 0000000..d8c5912 --- /dev/null +++ b/adafruit_httpserver/mime_types.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.mime_types` +==================================================== +* Author(s): Michał Pokusa +""" + +try: + from typing import List, Dict +except ImportError: + pass + + +class MIMETypes: + """ + Contains MIME types for common file extensions. + Allows to set default type for unknown files, unregister unused types and register new ones + using the ``MIMETypes.configure()``. + """ + + DEFAULT = "text/plain" + + REGISTERED = { + ".7z": "application/x-7z-compressed", + ".aac": "audio/aac", + ".abw": "application/x-abiword", + ".arc": "application/x-freearc", + ".avi": "video/x-msvideo", + ".azw": "application/vnd.amazon.ebook", + ".bin": "application/octet-stream", + ".bmp": "image/bmp", + ".bz": "application/x-bzip", + ".bz2": "application/x-bzip2", + ".cda": "application/x-cdf", + ".csh": "application/x-csh", + ".css": "text/css", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".eot": "application/vnd.ms-fontobject", + ".epub": "application/epub+zip", + ".gif": "image/gif", + ".gz": "application/gzip", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/vnd.microsoft.icon", + ".ics": "text/calendar", + ".jar": "application/java-archive", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript", + ".json": "application/json", + ".jsonld": "application/ld+json", + ".mid": "audio/midi", + ".midi": "audio/midi", + ".mjs": "text/javascript", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpkg": "application/vnd.apple.installer+xml", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".oga": "audio/ogg", + ".ogv": "video/ogg", + ".ogx": "application/ogg", + ".opus": "audio/opus", + ".otf": "font/otf", + ".pdf": "application/pdf", + ".php": "application/x-httpd-php", + ".png": "image/png", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".rar": "application/vnd.rar", + ".rtf": "application/rtf", + ".sh": "application/x-sh", + ".svg": "image/svg+xml", + ".swf": "application/x-shockwave-flash", + ".tar": "application/x-tar", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".ts": "video/mp2t", + ".ttf": "font/ttf", + ".txt": "text/plain", + ".vsd": "application/vnd.visio", + ".wav": "audio/wav", + ".weba": "audio/webm", + ".webm": "video/webm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xhtml": "application/xhtml+xml", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "application/xml", + ".xul": "application/vnd.mozilla.xul+xml", + ".zip": "application/zip", + } + + @staticmethod + def __check_all_start_with_dot(extensions: List[str]) -> None: + for extension in extensions: + if not extension.startswith("."): + raise ValueError( + f'Invalid extension: "{extension}". All extensions must start with a dot.' + ) + + @classmethod + def __check_all_are_registered(cls, extensions: List[str]) -> None: + registered_extensions = cls.REGISTERED.keys() + + for extension in extensions: + if not extension in registered_extensions: + raise ValueError(f'Extension "{extension}" is not registered. ') + + @classmethod + def _default_to(cls, mime_type: str) -> None: + """ + Set the default MIME type for unknown files. + + :param str mime_type: The MIME type to use for unknown files. + """ + cls.DEFAULT = mime_type + + @classmethod + def _keep_for(cls, extensions: List[str]) -> None: + """ + Unregisters all MIME types except the ones for the given extensions,\ + **decreasing overall memory usage**. + + It is recommended to **always** call this function before starting the server. + + Example:: + + keep_for([".jpg", ".mp4", ".txt"]) + """ + + cls.__check_all_start_with_dot(extensions) + cls.__check_all_are_registered(extensions) + + current_extensions = iter(cls.REGISTERED.keys()) + + cls.REGISTERED = { + extension: cls.REGISTERED[extension] + for extension in current_extensions + if extension in extensions + } + + @classmethod + def _register(cls, mime_types: dict) -> None: + """ + Register multiple MIME types. + + Example:: + + register({ + ".foo": "application/foo", + ".bar": "application/bar", + }) + + :param dict mime_types: A dictionary mapping file extensions to MIME types. + """ + cls.__check_all_start_with_dot(mime_types.keys()) + cls.REGISTERED.update(mime_types) + + @classmethod + def configure( + cls, + default_to: str = None, + keep_for: List[str] = None, + register: Dict[str, str] = None, + ) -> None: + """ + Allows to globally configure the MIME types. + + Example:: + + MIMETypes.configure( + default_to="text/plain", + keep_for=[".jpg", ".mp4", ".txt"], + register={".foo": "text/foo", ".bar": "text/bar", ".baz": "text/baz"}, + ) + """ + if default_to is not None: + cls._default_to(default_to) + if keep_for is not None: + cls._keep_for(keep_for) + if register is not None: + cls._register(register) + + @classmethod + def get_for_filename(cls, filename: str, default: str = None) -> str: + """ + Return the MIME type for the given file name. + + :param str filename: The file name to look up. + """ + if default is None: + default = cls.DEFAULT + + try: + extension = filename.rsplit(".", 1)[-1].lower() + return cls.REGISTERED.get(f".{extension}", default) + except IndexError: + return default diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 4e462d3..b633efe 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -23,7 +23,7 @@ ParentDirectoryReferenceError, ResponseAlreadySentError, ) -from .mime_type import MIMEType +from .mime_types import MIMETypes from .request import Request from .status import Status, OK_200 from .headers import Headers @@ -97,7 +97,7 @@ def route_func(request): Can be explicitly provided in the constructor, in ``send()`` or implicitly determined from filename in ``send_file()``. - Common MIME types are defined in `adafruit_httpserver.mime_type.MIMEType`. + Common MIME types are defined in `adafruit_httpserver.mime_types`. """ def __init__( # pylint: disable=too-many-arguments @@ -146,7 +146,7 @@ def _send_headers( ) headers.setdefault( - "Content-Type", content_type or self.content_type or MIMEType.TYPE_TXT + "Content-Type", content_type or self.content_type or MIMETypes.DEFAULT ) headers.setdefault("Connection", "close") if self.chunked: @@ -244,7 +244,7 @@ def send_file( # pylint: disable=too-many-arguments file_length = self._get_file_length(full_file_path) self._send_headers( - content_type=MIMEType.from_file_name(filename), + content_type=MIMETypes.get_for_filename(filename), content_length=file_length, ) From 142c89ba6a9befd6d611a1e140f2670ef3326fda Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 26 Apr 2023 18:28:44 +0000 Subject: [PATCH 07/44] Added option to restrict access to whole Server with Authentication --- adafruit_httpserver/server.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index cb4aceb..d5bd56a 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -16,6 +16,7 @@ from errno import EAGAIN, ECONNRESET, ETIMEDOUT +from .authentication import Basic, Bearer, require_authentication from .exceptions import AuthenticationError, FileNotExistsError, InvalidPathError from .methods import GET, HEAD from .request import Request @@ -34,6 +35,7 @@ def __init__(self, socket_source: Protocol, root_path: str) -> None: in CircuitPython or the `socket` module in CPython. :param str root_path: Root directory to serve files from """ + self._auths = [] self._buffer = bytearray(1024) self._timeout = 1 self.routes = _Routes() @@ -177,6 +179,10 @@ def poll(self): handler = self.routes.find_handler(_Route(request.path, request.method)) try: + # Check server authentications if necessary + if self._auths: + require_authentication(request, self._auths) + # If a handler for route exists and is callable, call it. if handler is not None and callable(handler): handler(request) @@ -216,6 +222,18 @@ def poll(self): return raise + def restrict_access(self, auths: List[Union[Basic, Bearer]]) -> None: + """ + Restricts access to the whole ``Server``. + It applies to all routes and files in ``root_path``. + + Example:: + + server = Server(pool, "/static") + server.restrict_access([Basic("user", "pass")]) + """ + self._auths = auths + @property def request_buffer_size(self) -> int: """ From ffa62d90beadd264f528b4cce7579f7f83517e0f Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 26 Apr 2023 19:31:18 +0000 Subject: [PATCH 08/44] Replaced decorator that prevents sending Response multiple times with method IDE was getting confused by decorated method and was not displaying the type hint properly --- adafruit_httpserver/response.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index b633efe..9498647 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -8,7 +8,7 @@ """ try: - from typing import Optional, Dict, Union, Tuple, Callable + from typing import Optional, Dict, Union, Tuple from socket import socket from socketpool import SocketPool except ImportError: @@ -29,21 +29,6 @@ from .headers import Headers -def _prevent_multiple_send_calls(function: Callable): - """ - Decorator that prevents calling ``send`` or ``send_file`` more than once. - """ - - def wrapper(self: "Response", *args, **kwargs): - if self._response_already_sent: # pylint: disable=protected-access - raise ResponseAlreadySentError - - result = function(self, *args, **kwargs) - return result - - return wrapper - - class Response: """ Response to a given `Request`. Use in `Server.route` handler functions. @@ -162,7 +147,11 @@ def _send_headers( self.request.connection, response_message_header.encode("utf-8") ) - @_prevent_multiple_send_calls + def _check_if_not_already_sent(self) -> None: + """Prevents calling ``send`` or ``send_file`` more than once.""" + if self._response_already_sent: + raise ResponseAlreadySentError + def send( self, body: str = "", @@ -174,6 +163,7 @@ def send( Should be called **only once** per response. """ + self._check_if_not_already_sent() if getattr(body, "encode", None): encoded_response_message_body = body.encode("utf-8") @@ -214,7 +204,6 @@ def _get_file_length(file_path: str) -> int: except OSError: raise FileNotExistsError(file_path) # pylint: disable=raise-missing-from - @_prevent_multiple_send_calls def send_file( # pylint: disable=too-many-arguments self, filename: str = "index.html", @@ -230,6 +219,7 @@ def send_file( # pylint: disable=too-many-arguments Should be called **only once** per response. """ + self._check_if_not_already_sent() if safe: self._check_file_path_is_valid(filename) From ee67bdbbb2e86dee652b8bcb26f714aa1d73fd61 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 26 Apr 2023 22:19:36 +0000 Subject: [PATCH 09/44] _Route now respects "/" suffix of path --- adafruit_httpserver/route.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 996fc32..bba3eef 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -66,7 +66,7 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: if not self._contains_parameters: return self.path == other.path, [] - regex_match = re.match(self.path, other.path) + regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: return False, [] From 18d4a537b1a01ab9d3f98e356e688e2540e7bd10 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 26 Apr 2023 22:56:27 +0000 Subject: [PATCH 10/44] Changed positional url parameters to keyword --- adafruit_httpserver/route.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index bba3eef..7dffd4a 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -21,13 +21,29 @@ class _Route: """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" def __init__(self, path: str = "", method: str = GET) -> None: - contains_parameters = re.search(r"<\w*>", path) is not None + self._validate_path(path) + self.parameters_names = [ + name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" + ] self.path = ( - path if not contains_parameters else re.sub(r"<\w*>", r"([^/]*)", path) + path + if not self._contains_parameters + else re.sub(r"<\w*>", r"([^/]*)", path) ) self.method = method - self._contains_parameters = contains_parameters + + @staticmethod + def _validate_path(path: str) -> None: + if not path.startswith("/"): + raise ValueError("Path must start with a slash.") + + if "<>" in path: + raise ValueError("All URL parameters must be named.") + + @property + def _contains_parameters(self) -> bool: + return 0 < len(self.parameters_names) def match(self, other: "_Route") -> Tuple[bool, List[str]]: """ @@ -110,7 +126,7 @@ def route_func(request, my_parameter): found_route, _route = False, None for _route in self._routes: - matches, url_parameters_values = _route.match(route) + matches, parameters_values = _route.match(route) if matches: found_route = True @@ -121,8 +137,10 @@ def route_func(request, my_parameter): handler = self._handlers[self._routes.index(_route)] + keyword_parameters = dict(zip(_route.parameters_names, parameters_values)) + def wrapped_handler(request): - return handler(request, *url_parameters_values) + return handler(request, **keyword_parameters) return wrapped_handler From e5ddaaf0d7b59f67e26fe2b53e4ad4bea9f1152c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 26 Apr 2023 23:00:02 +0000 Subject: [PATCH 11/44] Refactor of .poll and .server_forever, added option to disable filesystem access --- adafruit_httpserver/exceptions.py | 6 ++ adafruit_httpserver/server.py | 147 ++++++++++++++++++------------ 2 files changed, 96 insertions(+), 57 deletions(-) diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index 7c2ffd5..df8046b 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -46,6 +46,12 @@ class ResponseAlreadySentError(Exception): """ +class ServingFilesDisabledError(Exception): + """ + Raised when ``root_path`` is not set and there is no handler for `request`. + """ + + class FileNotExistsError(Exception): """ Raised when a file does not exist. diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index d5bd56a..ac5b7ec 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,7 +8,7 @@ """ try: - from typing import Callable, Protocol, Union, List + from typing import Callable, Protocol, Union, List, Tuple from socket import socket from socketpool import SocketPool except ImportError: @@ -17,7 +17,12 @@ from errno import EAGAIN, ECONNRESET, ETIMEDOUT from .authentication import Basic, Bearer, require_authentication -from .exceptions import AuthenticationError, FileNotExistsError, InvalidPathError +from .exceptions import ( + AuthenticationError, + FileNotExistsError, + InvalidPathError, + ServingFilesDisabledError, +) from .methods import GET, HEAD from .request import Request from .response import Response @@ -28,7 +33,7 @@ class Server: """A basic socket-based HTTP server.""" - def __init__(self, socket_source: Protocol, root_path: str) -> None: + def __init__(self, socket_source: Protocol, root_path: str = None) -> None: """Create a server, and get it ready to run. :param socket: An object that is a source of sockets. This could be a `socketpool` @@ -83,17 +88,19 @@ def route_decorator(func: Callable) -> Callable: return route_decorator def serve_forever(self, host: str, port: int = 80) -> None: - """Wait for HTTP requests at the given host and port. Does not return. + """ + Wait for HTTP requests at the given host and port. Does not return. + Ignores any exceptions raised by the handler function and continues to serve. :param str host: host name or IP address :param int port: port """ self.start(host, port) - while True: + while "Serving forever": try: self.poll() - except OSError: + except: # pylint: disable=bare-except continue def start(self, host: str, port: int = 80) -> None: @@ -111,6 +118,32 @@ def start(self, host: str, port: int = 80) -> None: self._sock.listen(10) self._sock.setblocking(False) # non-blocking socket + def _receive_request( + self, + sock: Union["SocketPool.Socket", "socket.socket"], + client_address: Tuple[str, int], + ) -> Request: + """Receive bytes from socket until the whole request is received.""" + + # Receiving data until empty line + header_bytes = self._receive_header_bytes(sock) + + # Return if no data received + if not header_bytes: + return None + + request = Request(sock, client_address, header_bytes) + + content_length = int(request.headers.get("Content-Length", 0)) + received_body_bytes = request.body + + # Receiving remaining body bytes + request.body = self._receive_body_bytes( + sock, received_body_bytes, content_length + ) + + return request + def _receive_header_bytes( self, sock: Union["SocketPool.Socket", "socket.socket"] ) -> bytes: @@ -147,6 +180,51 @@ def _receive_body_bytes( raise ex return received_body_bytes[:content_length] + def _serve_file_from_filesystem(self, request: Request): + filename = "index.html" if request.path == "/" else request.path + root_path = self.root_path + buffer_size = self.request_buffer_size + head_only = request.method == HEAD + + with Response(request) as response: + response.send_file(filename, root_path, buffer_size, head_only) + + def _handle_request(self, request: Request, handler: Union[Callable, None]): + try: + # Check server authentications if necessary + if self._auths: + require_authentication(request, self._auths) + + # Handler for route exists and is callable + if handler is not None and callable(handler): + handler(request) + + # Handler is not found... + + # ...no root_path, access to filesystem disabled, return 404. + elif self.root_path is None: + # Response(request, status=NOT_FOUND_404).send() + raise ServingFilesDisabledError + + # ..root_path is set, access to filesystem enabled... + + # ...request.method is GET or HEAD, try to serve a file from the filesystem. + elif request.method in [GET, HEAD]: + self._serve_file_from_filesystem(request) + # ... + else: + Response(request, status=BAD_REQUEST_400).send() + + except AuthenticationError: + headers = {"WWW-Authenticate": 'Basic charset="UTF-8"'} + Response(request, status=UNAUTHORIZED_401, headers=headers).send() + + except InvalidPathError as error: + Response(request, status=FORBIDDEN_403).send(str(error)) + + except (FileNotExistsError, ServingFilesDisabledError) as error: + Response(request, status=NOT_FOUND_404).send(str(error)) + def poll(self): """ Call this method inside your main event loop to get the server to @@ -158,67 +236,22 @@ def poll(self): with conn: conn.settimeout(self._timeout) - # Receiving data until empty line - header_bytes = self._receive_header_bytes(conn) - - # Return if no data received - if not header_bytes: + # Receive the whole request + if (request := self._receive_request(conn, client_address)) is None: return - request = Request(conn, client_address, header_bytes) - - content_length = int(request.headers.get("Content-Length", 0)) - received_body_bytes = request.body - - # Receiving remaining body bytes - request.body = self._receive_body_bytes( - conn, received_body_bytes, content_length - ) - # Find a handler for the route handler = self.routes.find_handler(_Route(request.path, request.method)) - try: - # Check server authentications if necessary - if self._auths: - require_authentication(request, self._auths) - - # If a handler for route exists and is callable, call it. - if handler is not None and callable(handler): - handler(request) - - # If no handler exists and request method is GET or HEAD, try to serve a file. - elif handler is None and request.method in [GET, HEAD]: - filename = "index.html" if request.path == "/" else request.path - Response(request).send_file( - filename=filename, - root_path=self.root_path, - buffer_size=self.request_buffer_size, - head_only=(request.method == HEAD), - ) - else: - Response(request, status=BAD_REQUEST_400).send() - - except AuthenticationError: - Response( - request, - status=UNAUTHORIZED_401, - headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, - ).send() - - except InvalidPathError as error: - Response(request, status=FORBIDDEN_403).send(str(error)) - - except FileNotExistsError as error: - Response(request, status=NOT_FOUND_404).send(str(error)) + # Handle the request + self._handle_request(request, handler) except OSError as error: - # Handle EAGAIN and ECONNRESET + # There is no data available right now, try again later. if error.errno == EAGAIN: - # There is no data available right now, try again later. return + # Connection reset by peer, try again later. if error.errno == ECONNRESET: - # Connection reset by peer, try again later. return raise From 85254e5b9205ae752e474ff16a00f79d30053163 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 27 Apr 2023 20:40:23 +0000 Subject: [PATCH 12/44] Changes to docstrings --- adafruit_httpserver/exceptions.py | 2 +- adafruit_httpserver/mime_types.py | 13 +++++++++++++ adafruit_httpserver/request.py | 10 +++++----- adafruit_httpserver/response.py | 8 +++++++- adafruit_httpserver/route.py | 3 --- adafruit_httpserver/server.py | 13 ++++++------- adafruit_httpserver/status.py | 20 ++++++++------------ 7 files changed, 40 insertions(+), 29 deletions(-) diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index df8046b..e76c1be 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -48,7 +48,7 @@ class ResponseAlreadySentError(Exception): class ServingFilesDisabledError(Exception): """ - Raised when ``root_path`` is not set and there is no handler for `request`. + Raised when ``root_path`` is not set and there is no handler for ``request``. """ diff --git a/adafruit_httpserver/mime_types.py b/adafruit_httpserver/mime_types.py index d8c5912..1397d72 100644 --- a/adafruit_httpserver/mime_types.py +++ b/adafruit_httpserver/mime_types.py @@ -21,6 +21,10 @@ class MIMETypes: """ DEFAULT = "text/plain" + """ + Default MIME type for unknown files. + Can be changed using ``MIMETypes.configure(default_to=...)``. + """ REGISTERED = { ".7z": "application/x-7z-compressed", @@ -175,6 +179,13 @@ def configure( """ Allows to globally configure the MIME types. + It is recommended to **always** call this method before starting the ``Server``. + Unregistering unused MIME types will **decrease overall memory usage**. + + :param str default_to: The MIME type to use for unknown files. + :param List[str] keep_for: File extensions to keep. All other will be unregistered. + :param Dict[str, str] register: A dictionary mapping file extensions to MIME types. + Example:: MIMETypes.configure( @@ -194,8 +205,10 @@ def configure( def get_for_filename(cls, filename: str, default: str = None) -> str: """ Return the MIME type for the given file name. + If the file extension is not registered, ``default`` is returned. :param str filename: The file name to look up. + :param str default: Default MIME type to return if the file extension is not registered. """ if default is None: default = cls.DEFAULT diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 2eb5fe7..8188b96 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -20,12 +20,12 @@ class Request: """ Incoming request, constructed from raw incoming bytes. - It is passed as first argument to route handlers. + It is passed as first argument to all route handlers. """ connection: Union["SocketPool.Socket", "socket.socket"] """ - Socket object usable to send and receive data on the connection. + Socket object used to send and receive data on the connection. """ client_address: Tuple[str, int] @@ -42,7 +42,7 @@ class Request: """Request method e.g. "GET" or "POST".""" path: str - """Path of the request.""" + """Path of the request, e.g. ``"/foo/bar"``.""" query_params: Dict[str, str] """ @@ -56,7 +56,7 @@ class Request: """ http_version: str - """HTTP version, e.g. "HTTP/1.1".""" + """HTTP version, e.g. ``"HTTP/1.1"``.""" headers: Headers """ @@ -65,7 +65,7 @@ class Request: raw_request: bytes """ - Raw 'bytes' passed to the constructor and body 'bytes' received later. + Raw 'bytes' that were received from the client. Should **not** be modified directly. """ diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 9498647..fa9297e 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -73,8 +73,13 @@ def route_func(request): """The request that this is a response to.""" http_version: str + status: Status + """Status code of the response. Defaults to ``200 OK``.""" + headers: Headers + """Headers to be sent in the response.""" + content_type: str """ Defaults to ``text/plain`` if not set. @@ -180,7 +185,8 @@ def send( @staticmethod def _check_file_path_is_valid(file_path: str) -> bool: """ - Checks if ``file_path`` is valid. + Checks if ``file_path`` does not contain backslashes or parent directory references. + If not raises error corresponding to the problem. """ diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 7dffd4a..8a8add8 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -120,9 +120,6 @@ def route_func(request, my_parameter): request.path == "/example/123" # True my_parameter == "123" # True """ - if not self._routes: - return None - found_route, _route = False, None for _route in self._routes: diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ac5b7ec..4612f7b 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -53,7 +53,7 @@ def route(self, path: str, methods: Union[str, List[str]] = GET) -> Callable: Decorator used to add a route. :param str path: URL path - :param str method: HTTP method: `"GET"`, `"POST"`, etc. + :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. Example:: @@ -227,9 +227,8 @@ def _handle_request(self, request: Request, handler: Union[Callable, None]): def poll(self): """ - Call this method inside your main event loop to get the server to - check for new incoming client requests. When a request comes in, - the application callable will be invoked. + Call this method inside your main loop to get the server to check for new incoming client + requests. When a request comes in, it will be handled by the handler function. """ try: conn, client_address = self._sock.accept() @@ -255,10 +254,10 @@ def poll(self): return raise - def restrict_access(self, auths: List[Union[Basic, Bearer]]) -> None: + def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: """ - Restricts access to the whole ``Server``. - It applies to all routes and files in ``root_path``. + Requires authentication for all routes and files in ``root_path``. + Any non-authenticated request will be rejected with a 401 status code. Example:: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 28c00b6..57ae47a 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -32,37 +32,33 @@ def __eq__(self, other: "Status"): OK_200 = Status(200, "OK") -"""200 OK""" + +CREATED_201 = Status(201, "Created") + +ACCEPTED_202 = Status(202, "Accepted") NO_CONTENT_204 = Status(204, "No Content") -"""204 No Content""" + +PARTIAL_CONTENT_206 = Status(206, "Partial Content") TEMPORARY_REDIRECT_307 = Status(307, "Temporary Redirect") -"""307 Temporary Redirect""" PERMANENT_REDIRECT_308 = Status(308, "Permanent Redirect") -"""308 Permanent Redirect""" BAD_REQUEST_400 = Status(400, "Bad Request") -"""400 Bad Request""" UNAUTHORIZED_401 = Status(401, "Unauthorized") -"""401 Unauthorized""" FORBIDDEN_403 = Status(403, "Forbidden") -"""403 Forbidden""" NOT_FOUND_404 = Status(404, "Not Found") -"""404 Not Found""" METHOD_NOT_ALLOWED_405 = Status(405, "Method Not Allowed") -"""405 Method Not Allowed""" + +TOO_MANY_REQUESTS_429 = Status(429, "Too Many Requests") INTERNAL_SERVER_ERROR_500 = Status(500, "Internal Server Error") -"""500 Internal Server Error""" NOT_IMPLEMENTED_501 = Status(501, "Not Implemented") -"""501 Not Implemented""" SERVICE_UNAVAILABLE_503 = Status(503, "Service Unavailable") -"""503 Service Unavailable""" From 15b00cb1ba76784e5ae2e25bac8b8be888b6c5df Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 10:11:41 +0000 Subject: [PATCH 13/44] Added server parameter to Request --- adafruit_httpserver/request.py | 12 +++++++++++- adafruit_httpserver/server.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 8188b96..f56581e 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -8,9 +8,12 @@ """ try: - from typing import Dict, Tuple, Union + from typing import Dict, Tuple, Union, TYPE_CHECKING from socket import socket from socketpool import SocketPool + + if TYPE_CHECKING: + from .server import Server except ImportError: pass @@ -23,6 +26,11 @@ class Request: It is passed as first argument to all route handlers. """ + server: "Server" + """ + Server object that received the request. + """ + connection: Union["SocketPool.Socket", "socket.socket"] """ Socket object used to send and receive data on the connection. @@ -72,10 +80,12 @@ class Request: def __init__( self, + server: "Server", connection: Union["SocketPool.Socket", "socket.socket"], client_address: Tuple[str, int], raw_request: bytes = None, ) -> None: + self.server = server self.connection = connection self.client_address = client_address self.raw_request = raw_request diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 4612f7b..ce861a8 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -132,7 +132,7 @@ def _receive_request( if not header_bytes: return None - request = Request(sock, client_address, header_bytes) + request = Request(self, sock, client_address, header_bytes) content_length = int(request.headers.get("Content-Length", 0)) received_body_bytes = request.body From 5d533dadb7f10b05f46cf6fd5a08790bd1c6f2e6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 10:17:46 +0000 Subject: [PATCH 14/44] Default .send_file to server's root_path --- adafruit_httpserver/response.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index fa9297e..578488e 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -199,6 +199,19 @@ def _check_file_path_is_valid(file_path: str) -> bool: if part == "..": raise ParentDirectoryReferenceError(file_path) + @staticmethod + def _combine_path(root_path: str, filename: str) -> str: + """ + Combines ``root_path`` and ``filename`` into a single path. + """ + + if not root_path.endswith("/"): + root_path += "/" + if filename.startswith("/"): + filename = filename[1:] + + return root_path + filename + @staticmethod def _get_file_length(file_path: str) -> int: """ @@ -213,7 +226,7 @@ def _get_file_length(file_path: str) -> int: def send_file( # pylint: disable=too-many-arguments self, filename: str = "index.html", - root_path: str = "./", + root_path: str = None, buffer_size: int = 1024, head_only: bool = False, safe: bool = True, @@ -230,12 +243,8 @@ def send_file( # pylint: disable=too-many-arguments if safe: self._check_file_path_is_valid(filename) - if not root_path.endswith("/"): - root_path += "/" - if filename.startswith("/"): - filename = filename[1:] - - full_file_path = root_path + filename + root_path = root_path or self.request.server.root_path + full_file_path = self._combine_path(root_path, filename) file_length = self._get_file_length(full_file_path) From 223086d2750c09dd293c4a4bc0f4aea349c30b45 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 10:54:47 +0000 Subject: [PATCH 15/44] Added imports directly from adafruit_httpserver --- adafruit_httpserver/__init__.py | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index fb2966f..88d5c5f 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -21,3 +21,54 @@ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" + + +from .authentication import ( + Basic, + Bearer, + check_authentication, + require_authentication, +) +from .exceptions import ( + AuthenticationError, + BackslashInPathError, + FileNotExistsError, + InvalidPathError, + ParentDirectoryReferenceError, + ResponseAlreadySentError, +) +from .headers import Headers +from .methods import ( + GET, + POST, + PUT, + DELETE, + PATCH, + HEAD, + OPTIONS, + TRACE, + CONNECT, +) +from .mime_types import MIMETypes +from .request import Request +from .response import Response +from .server import Server +from .status import ( + Status, + OK_200, + CREATED_201, + ACCEPTED_202, + NO_CONTENT_204, + PARTIAL_CONTENT_206, + TEMPORARY_REDIRECT_307, + PERMANENT_REDIRECT_308, + BAD_REQUEST_400, + UNAUTHORIZED_401, + FORBIDDEN_403, + NOT_FOUND_404, + METHOD_NOT_ALLOWED_405, + TOO_MANY_REQUESTS_429, + INTERNAL_SERVER_ERROR_500, + NOT_IMPLEMENTED_501, + SERVICE_UNAVAILABLE_503, +) From 19148a5d1e2cc97f01556359e74ce1d9ba45702c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 12:23:54 +0000 Subject: [PATCH 16/44] Extensive updates and expansion of docs and examples --- README.rst | 1 + adafruit_httpserver/__init__.py | 2 +- adafruit_httpserver/methods.py | 9 -- adafruit_httpserver/server.py | 2 +- docs/api.rst | 2 +- docs/examples.rst | 107 +++++++++++++++--- .../httpserver_authentication_handlers.py | 68 +++++++++++ examples/httpserver_authentication_server.py | 33 ++++++ examples/httpserver_chunked.py | 19 +--- examples/httpserver_cpu_information.py | 21 +--- examples/httpserver_handler_serves_file.py | 27 +++++ examples/httpserver_mdns.py | 20 +--- examples/httpserver_methods.py | 38 +++++++ examples/httpserver_neopixel.py | 26 +---- examples/httpserver_simpletest_auto.py | 26 +++++ ...est.py => httpserver_simpletest_manual.py} | 14 +-- ...e_poll.py => httpserver_start_and_poll.py} | 20 +--- examples/httpserver_static_files_serving.py | 29 +++++ examples/httpserver_url_parameters.py | 43 +------ examples/settings.toml | 3 + 20 files changed, 351 insertions(+), 159 deletions(-) create mode 100644 examples/httpserver_authentication_handlers.py create mode 100644 examples/httpserver_authentication_server.py create mode 100644 examples/httpserver_handler_serves_file.py create mode 100644 examples/httpserver_methods.py create mode 100644 examples/httpserver_simpletest_auto.py rename examples/{httpserver_simpletest.py => httpserver_simpletest_manual.py} (58%) rename examples/{httpserver_simple_poll.py => httpserver_start_and_poll.py} (58%) create mode 100644 examples/httpserver_static_files_serving.py create mode 100644 examples/settings.toml diff --git a/README.rst b/README.rst index e2cd23b..5977b72 100644 --- a/README.rst +++ b/README.rst @@ -30,6 +30,7 @@ HTTP Server for CircuitPython. - Gives access to request headers, query parameters, body and client's address, the one from which the request came. - Supports chunked transfer encoding. - Supports URL parameters and wildcard URLs. +- Supports HTTP Basic and Bearer Authentication on both server and route per level. Dependencies diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 88d5c5f..28e65d5 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -5,7 +5,7 @@ `adafruit_httpserver` ================================================================================ -Simple HTTP Server for CircuitPython +Socket based HTTP Server for CircuitPython * Author(s): Dan Halbert diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index 4b74d87..450b770 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -9,28 +9,19 @@ GET = "GET" -"""GET method.""" POST = "POST" -"""POST method.""" PUT = "PUT" -"""PUT method""" DELETE = "DELETE" -"""DELETE method""" PATCH = "PATCH" -"""PATCH method""" HEAD = "HEAD" -"""HEAD method""" OPTIONS = "OPTIONS" -"""OPTIONS method""" TRACE = "TRACE" -"""TRACE method""" CONNECT = "CONNECT" -"""CONNECT method""" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ce861a8..1f50ba7 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -262,7 +262,7 @@ def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: Example:: server = Server(pool, "/static") - server.restrict_access([Basic("user", "pass")]) + server.require_authentication([Basic("user", "pass")]) """ self._auths = auths diff --git a/docs/api.rst b/docs/api.rst index 64bb534..ac5170b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -25,7 +25,7 @@ .. automodule:: adafruit_httpserver.methods :members: -.. automodule:: adafruit_httpserver.mime_type +.. automodule:: adafruit_httpserver.mime_types :members: .. automodule:: adafruit_httpserver.exceptions diff --git a/docs/examples.rst b/docs/examples.rst index 433b66c..995586b 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,12 +1,56 @@ Simple Test -------------------- +----------- + +This is the minimal example of using the library. +This example is serving a simple static text message. + +It also manually connects to the WiFi network. + +.. literalinclude:: ../examples/httpserver_simpletest_manual.py + :caption: examples/httpserver_simpletest_manual.py + :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. + +This is the same example as above, but it uses the ``settings.toml`` file to configure the WiFi network. + +**From now on, all the examples will use the** ``settings.toml`` **file to configure the WiFi network.** + +.. literalinclude:: ../examples/settings.toml + :caption: settings.toml + :linenos: -Serving a simple static text message. +Note that we still need to import ``socketpool`` and ``wifi`` modules. -.. literalinclude:: ../examples/httpserver_simpletest.py - :caption: examples/httpserver_simpletest.py +.. literalinclude:: ../examples/httpserver_simpletest_auto.py + :caption: examples/httpserver_simpletest_auto.py :linenos: +Serving static files +-------------------- + +It is possible to serve static files from the filesystem. +In this example we are serving files from the ``/static`` directory. + +In order to save memory, we are unregistering unused MIME types and registering additional ones. +`More about MIME types. `_ + +.. literalinclude:: ../examples/httpserver_static_files_serving.py + :caption: examples/httpserver_static_files_serving.py + :linenos: + +You can also serve a specific file from the handler. +By default ``Response.send_file()`` looks for the file in the server's ``root_path`` directory, but you can change it. + +.. literalinclude:: ../examples/httpserver_handler_serves_file.py + :caption: examples/httpserver_handler_serves_file.py + :linenos: + +Tasks in the background +----------------------- + If you want your code to do more than just serve web pages, use the ``.start()``/``.poll()`` methods as shown in this example. @@ -14,8 +58,8 @@ Between calling ``.poll()`` you can do something useful, for example read a sensor and capture an average or a running total of the last 10 samples. -.. literalinclude:: ../examples/httpserver_simple_poll.py - :caption: examples/httpserver_simple_poll.py +.. literalinclude:: ../examples/httpserver_start_and_poll.py + :caption: examples/httpserver_start_and_poll.py :linenos: Server with MDNS @@ -30,6 +74,22 @@ In this example, the server is accessible via ``http://custom-mdns-hostname/`` a :caption: examples/httpserver_mdns.py :linenos: +Handling different methods +--------------------------------------- + +On every ``server.route()`` call you can specify which HTTP methods are allowed. +By default, only ``GET`` method is allowed. + +You can pass a list of methods or a single method as a string. + +It is recommended to use the the values in ``adafruit_httpserver.methods`` module to avoid typos and for future proofness. + +In example below, handler for ``/api`` route will be called when any of ``GET``, ``POST``, ``PUT``, ``DELETE`` methods is used. + +.. literalinclude:: ../examples/httpserver_methods.py + :caption: examples/httpserver_methods.py + :linenos: + Change NeoPixel color --------------------- @@ -45,7 +105,7 @@ Tested on ESP32-S2 Feather. :linenos: Get CPU information ---------------------- +------------------- You can return data from sensors or any computed value as JSON. That makes it easy to use the data in other applications. @@ -55,23 +115,23 @@ That makes it easy to use the data in other applications. :linenos: Chunked response ---------------------- +---------------- Library supports chunked responses. This is useful for streaming data. -To use it, you need to set the ``chunked=True`` when creating a ``HTTPResponse`` object. +To use it, you need to set the ``chunked=True`` when creating a ``Response`` object. .. literalinclude:: ../examples/httpserver_chunked.py :caption: examples/httpserver_chunked.py :linenos: URL parameters ---------------------- +-------------- Alternatively to using query parameters, you can use URL parameters. -In order to use URL parameters, you need to wrap them inside ``<>`` in ``HTTPServer.route``, e.g. ````. +In order to use URL parameters, you need to wrap them inside ``<>`` in ``Server.route``, e.g. ````. -All URL parameters are **passed as positional (not keyword) arguments** to the handler function, in order they are specified in ``HTTPServer.route``. +All URL parameters values are **passed as keyword arguments** to the handler function. Notice how the handler function in example below accepts two additional arguments : ``device_id`` and ``action``. @@ -80,11 +140,26 @@ make sure to add default values for all the ones that might not be passed. In the example below the second route has only one URL parameter, so the ``action`` parameter has a default value. Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type. -Also note that the names of the function parameters **do not have to match** with the ones used in route, but they **must** be in the same order. -Look at the example below to see how the ``route_param_1`` and ``route_param_1`` are named differently in the handler function. - -Although it is possible, it makes more sense be consistent with the names of the parameters in the route and in the handler function. +Also note that the names of the function parameters **have to match** with the ones used in route, but they **do not have to** be in the same order. .. literalinclude:: ../examples/httpserver_url_parameters.py :caption: examples/httpserver_url_parameters.py :linenos: + +Authentication +-------------- + +In order to increase security of your server, you can use ``Basic`` and ``Bearer`` authentication. + +If you want to apply authentication to the whole server, you need to call ``.require_authentication`` on ``Server`` instance. + +.. literalinclude:: ../examples/httpserver_authentication_server.py + :caption: examples/httpserver_authentication_server.py + :linenos: + +On the other hand, if you want to apply authentication to a set of routes, you need to call ``require_authentication`` function. +In both cases you can check if ``request`` is authenticated by calling ``check_authentication`` on it. + +.. literalinclude:: ../examples/httpserver_authentication_handlers.py + :caption: examples/httpserver_authentication_handlers.py + :linenos: diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py new file mode 100644 index 0000000..dc8c565 --- /dev/null +++ b/examples/httpserver_authentication_handlers.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, UNATUHORIZED_401 +from adafruit_httpserver.authentication import ( + AuthenticationError, + Basic, + Bearer, + check_authentication, + require_authentication, +) + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool) + +# Create a list of available authentication methods. +auths = [ + Basic("user", "password"), + Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), +] + + +@server.route("/check") +def check_if_authenticated(request: Request): + """ + Check if the request is authenticated and return a appropriate response. + """ + is_authenticated = check_authentication(request, auths) + + with Response(request, content_type="text/plain") as response: + response.send("Authenticated" if is_authenticated else "Not authenticated") + + +@server.route("/require-or-401") +def require_authentication_or_401(request: Request): + """ + Require authentication and return a default server 401 response if not authenticated. + """ + require_authentication(request, auths) + + with Response(request, content_type="text/plain") as response: + response.send("Authenticated") + + +@server.route("/require-or-handle") +def require_authentication_or_manually_handle(request: Request): + """ + Require authentication and manually handle request if not authenticated. + """ + + try: + require_authentication(request, auths) + + with Response(request, content_type="text/plain") as response: + response.send("Authenticated") + + except AuthenticationError: + with Response(request, status=UNATUHORIZED_401) as response: + response.send("Not authenticated - Manually handled") + + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_authentication_server.py b/examples/httpserver_authentication_server.py new file mode 100644 index 0000000..dedce5f --- /dev/null +++ b/examples/httpserver_authentication_server.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, Basic, Bearer + + +# Create a list of available authentication methods. +auths = [ + Basic("user", "password"), + Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), +] + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static") +server.require_authentication(auths) + + +@server.route("/implicit-require") +def implicit_require_authentication(request: Request): + """ + Implicitly require authentication because of the server.require_authentication() call. + """ + + with Response(request, content_type="text/plain") as response: + response.send("Authenticated") + + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_chunked.py b/examples/httpserver_chunked.py index ed67fc6..7876cfd 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -2,34 +2,23 @@ # # SPDX-License-Identifier: Unlicense -import os - import socketpool import wifi -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") +from adafruit_httpserver import Server, Request, Response -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool) @server.route("/chunked") -def chunked(request: HTTPRequest): +def chunked(request: Request): """ Return the response with ``Transfer-Encoding: chunked``. """ - with HTTPResponse(request, chunked=True) as response: + with Response(request, chunked=True) as response: response.send_chunk("Adaf") response.send_chunk("ruit") response.send_chunk(" Indus") diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index 41d7a05..de7e252 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -2,32 +2,19 @@ # # SPDX-License-Identifier: Unlicense -import os - import json import microcontroller import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) +from adafruit_httpserver import Server, Request, Response pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool) @server.route("/cpu-information") -def cpu_information_handler(request: HTTPRequest): +def cpu_information_handler(request: Request): """ Return the current CPU temperature, frequency, and voltage as JSON. """ @@ -38,7 +25,7 @@ def cpu_information_handler(request: HTTPRequest): "voltage": microcontroller.cpu.voltage, } - with HTTPResponse(request, content_type=MIMEType.TYPE_JSON) as response: + with Response(request, content_type="application/json") as response: response.send(json.dumps(data)) diff --git a/examples/httpserver_handler_serves_file.py b/examples/httpserver_handler_serves_file.py new file mode 100644 index 0000000..b3ad0ce --- /dev/null +++ b/examples/httpserver_handler_serves_file.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static") + + +@server.route("/home") +def home(request: Request): + """ + Serves the file /www/home.html. + """ + + with Response(request, content_type="text/html") as response: + response.send_file("home.html", root_path="/www") + + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index bebdc2a..f8da394 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -2,39 +2,27 @@ # # SPDX-License-Identifier: Unlicense -import os - import mdns import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") +from adafruit_httpserver import Server, Request, Response -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) mdns_server = mdns.Server(wifi.radio) mdns_server.hostname = "custom-mdns-hostname" mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=80) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool, "/static") @server.route("/") -def base(request: HTTPRequest): +def base(request: Request): """ Serve the default index.html file. """ - with HTTPResponse(request, content_type=MIMEType.TYPE_HTML) as response: + with Response(request, content_type="text/html") as response: response.send_file("index.html") diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py new file mode 100644 index 0000000..b8e359e --- /dev/null +++ b/examples/httpserver_methods.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, GET, POST, PUT, DELETE + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool) + + +@server.route("/api", [GET, POST, PUT, DELETE]) +def api(request: Request): + """ + Performs different operations depending on the HTTP method. + """ + + if request.method == GET: + # Get objects + with Response(request) as response: + response.send("Objects: ...") + + if request.method in [POST, PUT]: + # Upload or update objects + with Response(request) as response: + response.send("Object uploaded/updated") + + if request.method == DELETE: + # Delete objects + with Response(request) as response: + response.send("Object deleted") + + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index baff3de..4663259 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -2,34 +2,22 @@ # # SPDX-License-Identifier: Unlicense -import os - import board import neopixel import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") +from adafruit_httpserver import Server, Request, Response -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool, "/static") pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) @server.route("/change-neopixel-color") -def change_neopixel_color_handler_query_params(request: HTTPRequest): +def change_neopixel_color_handler_query_params(request: Request): """ Changes the color of the built-in NeoPixel using query/GET params. """ @@ -39,20 +27,18 @@ def change_neopixel_color_handler_query_params(request: HTTPRequest): pixel.fill((int(r or 0), int(g or 0), int(b or 0))) - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: + with Response(request, content_type="text/plain") as response: response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") @server.route("/change-neopixel-color///") -def change_neopixel_color_handler_url_params( - request: HTTPRequest, r: str, g: str, b: str -): +def change_neopixel_color_handler_url_params(request: Request, r: str, g: str, b: str): """ Changes the color of the built-in NeoPixel using URL params. """ pixel.fill((int(r or 0), int(g or 0), int(b or 0))) - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: + with Response(request, content_type="text/plain") as response: response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") diff --git a/examples/httpserver_simpletest_auto.py b/examples/httpserver_simpletest_auto.py new file mode 100644 index 0000000..a186b95 --- /dev/null +++ b/examples/httpserver_simpletest_auto.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static") + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + with Response(request, content_type="text/plain") as response: + message = "Hello from the CircuitPython HTTP Server!" + response.send(message) + + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest.py b/examples/httpserver_simpletest_manual.py similarity index 58% rename from examples/httpserver_simpletest.py rename to examples/httpserver_simpletest_manual.py index c04ce2d..0c2ee1c 100644 --- a/examples/httpserver_simpletest.py +++ b/examples/httpserver_simpletest_manual.py @@ -7,11 +7,7 @@ import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - +from adafruit_httpserver import Server, Request, Response ssid = os.getenv("WIFI_SSID") password = os.getenv("WIFI_PASSWORD") @@ -21,16 +17,16 @@ print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool, "/static") @server.route("/") -def base(request: HTTPRequest): +def base(request: Request): """ Serve a default static plain text message. """ - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - message = "Hello from the CircuitPython HTTPServer!" + with Response(request, content_type="text/plain") as response: + message = "Hello from the CircuitPython HTTP Server!" response.send(message) diff --git a/examples/httpserver_simple_poll.py b/examples/httpserver_start_and_poll.py similarity index 58% rename from examples/httpserver_simple_poll.py rename to examples/httpserver_start_and_poll.py index 1ed5027..4ff742c 100644 --- a/examples/httpserver_simple_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -2,34 +2,22 @@ # # SPDX-License-Identifier: Unlicense -import os - import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") +from adafruit_httpserver import Server, Request, Response -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool, "/static") @server.route("/") -def base(request: HTTPRequest): +def base(request: Request): """ Serve the default index.html file. """ - with HTTPResponse(request, content_type=MIMEType.TYPE_HTML) as response: + with Response(request, content_type="text/html") as response: response.send_file("index.html") diff --git a/examples/httpserver_static_files_serving.py b/examples/httpserver_static_files_serving.py new file mode 100644 index 0000000..c29c89d --- /dev/null +++ b/examples/httpserver_static_files_serving.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + + +import socketpool +import wifi + +from adafruit_httpserver import Server, MIMETypes + + +MIMETypes.configure( + default_to="text/plain", + # Unregistering unnecessary MIME types can save memory + keep_for=[".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico"], + # If you need to, you can add additional MIME types + register={".foo": "text/foo", ".bar": "text/bar"}, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static") + +# You don't have to add any routes, by default the server will serve files +# from it's root_path, which is set to "/static" in this example. + +# If you don't set a root_path, the server will not serve any files. + +print(f"Listening on http://{wifi.radio.ipv4_address}:80") +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index 22f9f3b..283c5c0 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -2,26 +2,14 @@ # # SPDX-License-Identifier: Unlicense -import os - import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") +from adafruit_httpserver import Server, Request, Response -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool) class Device: @@ -42,7 +30,7 @@ def get_device(device_id: str) -> Device: # pylint: disable=unused-argument @server.route("/device//action/") @server.route("/device/emergency-power-off/") def perform_action( - request: HTTPRequest, device_id: str, action: str = "emergency_power_off" + request: Request, device_id: str, action: str = "emergency_power_off" ): """ Performs an "action" on a specified device. @@ -55,30 +43,9 @@ def perform_action( elif action in ["turn_off", "emergency_power_off"]: device.turn_off() else: - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: + with Response(request, content_type="text/plain") as response: response.send(f"Unknown action ({action})") return - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: + with Response(request, content_type="text/plain") as response: response.send(f"Action ({action}) performed on device with ID: {device_id}") - - -@server.route("/something//") -def different_name_parameters( - request: HTTPRequest, - handler_param_1: str, # pylint: disable=unused-argument - handler_param_2: str = None, # pylint: disable=unused-argument -): - """ - Presents that the parameters can be named anything. - - ``route_param_1`` -> ``handler_param_1`` - ``route_param_2`` -> ``handler_param_2`` - """ - - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - response.send("200 OK") - - -print(f"Listening on http://{wifi.radio.ipv4_address}:80") -server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/settings.toml b/examples/settings.toml new file mode 100644 index 0000000..7a5be9d --- /dev/null +++ b/examples/settings.toml @@ -0,0 +1,3 @@ +# Setting these variables will automatically connect board to WiFi on boot +CIRCUITPY_WIFI_SSID="Your WiFi SSID Here" +CIRCUITPY_WIFI_PASSWORD="Your WiFi Password Here" From 240a2c4dbb2570e114734a178ef9518ba6d985fb Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:13:04 +0000 Subject: [PATCH 17/44] Added home.html file to one of the examples --- docs/examples.rst | 5 +++++ examples/home.html | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 examples/home.html diff --git a/docs/examples.rst b/docs/examples.rst index 995586b..563e3a2 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -48,6 +48,11 @@ By default ``Response.send_file()`` looks for the file in the server's ``root_pa :caption: examples/httpserver_handler_serves_file.py :linenos: +.. literalinclude:: ../examples/home.html + :language: html + :caption: www/home.html + :linenos: + Tasks in the background ----------------------- diff --git a/examples/home.html b/examples/home.html new file mode 100644 index 0000000..ff80bf1 --- /dev/null +++ b/examples/home.html @@ -0,0 +1,11 @@ + + + + + + Adafruit HTTPServer + + +

Hello from the CircuitPython HTTP Server!

+ + From b9d1eacc5cfd95a5d08d28fbe8b7f3b3393be10e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:38:36 +0000 Subject: [PATCH 18/44] Added :emphasize-lines: and minor change in one of example descriptions --- docs/examples.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 563e3a2..5392561 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -8,6 +8,7 @@ It also manually connects to the WiFi network. .. literalinclude:: ../examples/httpserver_simpletest_manual.py :caption: examples/httpserver_simpletest_manual.py + :emphasize-lines: 12-17 :linenos: Although there is nothing wrong with this approach, from the version 8.0.0 of CircuitPython, @@ -39,6 +40,7 @@ In order to save memory, we are unregistering unused MIME types and registering .. literalinclude:: ../examples/httpserver_static_files_serving.py :caption: examples/httpserver_static_files_serving.py + :emphasize-lines: 12-18,23-26 :linenos: You can also serve a specific file from the handler. @@ -46,6 +48,7 @@ By default ``Response.send_file()`` looks for the file in the server's ``root_pa .. literalinclude:: ../examples/httpserver_handler_serves_file.py :caption: examples/httpserver_handler_serves_file.py + :emphasize-lines: 22-23 :linenos: .. literalinclude:: ../examples/home.html @@ -65,6 +68,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: 26-39 :linenos: Server with MDNS @@ -77,6 +81,7 @@ In this example, the server is accessible via ``http://custom-mdns-hostname/`` a .. literalinclude:: ../examples/httpserver_mdns.py :caption: examples/httpserver_mdns.py + :emphasize-lines: 12-14 :linenos: Handling different methods @@ -93,13 +98,15 @@ In example below, handler for ``/api`` route will be called when any of ``GET``, .. literalinclude:: ../examples/httpserver_methods.py :caption: examples/httpserver_methods.py + :emphasize-lines: 8,15 :linenos: Change NeoPixel color --------------------- -If you want your code to do more than just serve web pages, -use the start/poll methods as shown in this example. +In your handler function you can access the query/GET parameters using ``request.query_params``. +This allows you to pass data to the handler function and use it in your code. +Alternatively you can use URL parameters, which are described later in this document. For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` or ``/change-neopixel-color/255/0/0`` you can change the color of the NeoPixel to red. @@ -107,6 +114,7 @@ Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py + :emphasize-lines: 24-26,34-35 :linenos: Get CPU information @@ -117,6 +125,7 @@ That makes it easy to use the data in other applications. .. literalinclude:: ../examples/httpserver_cpu_information.py :caption: examples/httpserver_cpu_information.py + :emphasize-lines: 28-29 :linenos: Chunked response @@ -127,6 +136,7 @@ To use it, you need to set the ``chunked=True`` when creating a ``Response`` obj .. literalinclude:: ../examples/httpserver_chunked.py :caption: examples/httpserver_chunked.py + :emphasize-lines: 21-26 :linenos: URL parameters @@ -149,6 +159,7 @@ Also note that the names of the function parameters **have to match** with the o .. literalinclude:: ../examples/httpserver_url_parameters.py :caption: examples/httpserver_url_parameters.py + :emphasize-lines: 30-34 :linenos: Authentication @@ -160,6 +171,7 @@ If you want to apply authentication to the whole server, you need to call ``.req .. literalinclude:: ../examples/httpserver_authentication_server.py :caption: examples/httpserver_authentication_server.py + :emphasize-lines: 8,11-15,19 :linenos: On the other hand, if you want to apply authentication to a set of routes, you need to call ``require_authentication`` function. @@ -167,4 +179,5 @@ In both cases you can check if ``request`` is authenticated by calling ``check_a .. literalinclude:: ../examples/httpserver_authentication_handlers.py :caption: examples/httpserver_authentication_handlers.py + :emphasize-lines: 9-15,21-25,33,44,57 :linenos: From cd8146e421f3a91fa8a10f1faea5e1eb204c95ea Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:45:17 +0000 Subject: [PATCH 19/44] Fix: Missing Copyright information --- docs/examples.rst | 2 ++ examples/home.html | 4 ++++ examples/settings.toml | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index 5392561..7dca4b4 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -21,6 +21,7 @@ This is the same example as above, but it uses the ``settings.toml`` file to con .. literalinclude:: ../examples/settings.toml :caption: settings.toml + :lines: 5- :linenos: Note that we still need to import ``socketpool`` and ``wifi`` modules. @@ -54,6 +55,7 @@ By default ``Response.send_file()`` looks for the file in the server's ``root_pa .. literalinclude:: ../examples/home.html :language: html :caption: www/home.html + :lines: 5- :linenos: Tasks in the background diff --git a/examples/home.html b/examples/home.html index ff80bf1..2a17081 100644 --- a/examples/home.html +++ b/examples/home.html @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + diff --git a/examples/settings.toml b/examples/settings.toml index 7a5be9d..ab4da20 100644 --- a/examples/settings.toml +++ b/examples/settings.toml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + # Setting these variables will automatically connect board to WiFi on boot CIRCUITPY_WIFI_SSID="Your WiFi SSID Here" CIRCUITPY_WIFI_PASSWORD="Your WiFi Password Here" From eba7a911466cbed62dd9b4f300baeea06528e89f Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 29 Apr 2023 00:02:04 +0000 Subject: [PATCH 20/44] Added Server.stop --- adafruit_httpserver/exceptions.py | 6 ++++++ adafruit_httpserver/server.py | 22 +++++++++++++++++++--- examples/httpserver_start_and_poll.py | 2 ++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index e76c1be..0dd14ec 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -8,6 +8,12 @@ """ +class ServerStoppedError(Exception): + """ + Raised when ``.poll`` is called on a stopped ``Server``. + """ + + class AuthenticationError(Exception): """ Raised by ``require_authentication`` when the ``Request`` is not authorized. diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1f50ba7..d1d2b99 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -18,6 +18,7 @@ from .authentication import Basic, Bearer, require_authentication from .exceptions import ( + ServerStoppedError, AuthenticationError, FileNotExistsError, InvalidPathError, @@ -47,6 +48,7 @@ def __init__(self, socket_source: Protocol, root_path: str = None) -> None: self._socket_source = socket_source self._sock = None self.root_path = root_path + self.stopped = False def route(self, path: str, methods: Union[str, List[str]] = GET) -> Callable: """ @@ -91,13 +93,14 @@ def serve_forever(self, host: str, port: int = 80) -> None: """ Wait for HTTP requests at the given host and port. Does not return. Ignores any exceptions raised by the handler function and continues to serve. + Returns only when the server is stopped by calling ``.stop()``. :param str host: host name or IP address :param int port: port """ self.start(host, port) - while "Serving forever": + while not self.stopped: try: self.poll() except: # pylint: disable=bare-except @@ -106,17 +109,27 @@ def serve_forever(self, host: str, port: int = 80) -> None: def start(self, host: str, port: int = 80) -> None: """ Start the HTTP server at the given host and port. Requires calling - poll() in a while loop to handle incoming requests. + ``.poll()`` in a while loop to handle incoming requests. :param str host: host name or IP address :param int port: port """ + self.stopped = False self._sock = self._socket_source.socket( self._socket_source.AF_INET, self._socket_source.SOCK_STREAM ) self._sock.bind((host, port)) self._sock.listen(10) - self._sock.setblocking(False) # non-blocking socket + self._sock.setblocking(False) # Non-blocking socket + + def stop(self) -> None: + """ + Stops the server from listening for new connections and closes the socket. + Current requests will be processed. Server can be started again by calling ``.start()`` + or ``.serve_forever()``. + """ + self.stopped = True + self._sock.close() def _receive_request( self, @@ -230,6 +243,9 @@ def poll(self): Call this method inside your main loop to get the server to check for new incoming client requests. When a request comes in, it will be handled by the handler function. """ + if self.stopped: + raise ServerStoppedError + try: conn, client_address = self._sock.accept() with conn: diff --git a/examples/httpserver_start_and_poll.py b/examples/httpserver_start_and_poll.py index 4ff742c..ab64ac3 100644 --- a/examples/httpserver_start_and_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -34,6 +34,8 @@ def base(request: Request): # Process any waiting requests server.poll() + + # If you want you can stop the server by calling server.stop() anywhere in your code except OSError as error: print(error) continue From e5bf8311a66c2d9ff20e27fbf2e3312afa3d8a7c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 29 Apr 2023 00:34:09 +0000 Subject: [PATCH 21/44] Added Multiple servers example --- docs/examples.rst | 16 +++++++ examples/httpserver_multiple_servers.py | 61 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 examples/httpserver_multiple_servers.py diff --git a/docs/examples.rst b/docs/examples.rst index 7dca4b4..630a107 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -183,3 +183,19 @@ In both cases you can check if ``request`` is authenticated by calling ``check_a :caption: examples/httpserver_authentication_handlers.py :emphasize-lines: 9-15,21-25,33,44,57 :linenos: + +Multiple servers +---------------- + +Although it is not the primary use case, it is possible to run multiple servers at the same time. +In order to do that, you need to create multiple ``Server`` instances and call ``.start()`` and ``.poll()`` on each of them. +Using ``.serve_forever()`` for this is not possible because of it's blocking behaviour. + +Each server **must have a different port number**. + +In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. + +.. literalinclude:: ../examples/httpserver_multiple_servers.py + :caption: examples/httpserver_multiple_servers.py + :emphasize-lines: 13-14,17,26,35-36,51-52,57-58 + :linenos: diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py new file mode 100644 index 0000000..2d765a0 --- /dev/null +++ b/examples/httpserver_multiple_servers.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response + + +pool = socketpool.SocketPool(wifi.radio) + +bedroom_server = Server(pool, "/bedroom") +office_server = Server(pool, "/office") + + +@bedroom_server.route("/bedroom") +def bedroom(request: Request): + """ + This route is registered only with ``bedroom_server``. + """ + with Response(request) as response: + response.send("Hello from the bedroom!") + + +@office_server.route("/office") +def office(request: Request): + """ + This route is registered only with ``office_server``. + """ + with Response(request) as response: + response.send("Hello from the office!") + + +@bedroom_server.route("/home") +@office_server.route("/home") +def home(request: Request): + """ + This route is registered with both servers. + """ + with Response(request) as response: + response.send("Hello from home!") + + +id_address = str(wifi.radio.ipv4_address) + +# Start the servers. + +print(f"[bedroom_server] Listening on http://{id_address}:5000") +print(f"[office_server] Listening on http://{id_address}:8000") +bedroom_server.start(id_address, 5000) +office_server.start(id_address, 8000) + +while True: + try: + # Process any waiting requests for both servers. + bedroom_server.poll() + office_server.poll() + except OSError as error: + print(error) + continue From 07782b68e7ff29d2780cc20046ad1ce925ee7261 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 29 Apr 2023 13:34:37 +0000 Subject: [PATCH 22/44] Removed accidentally commited leftover --- adafruit_httpserver/mime_types.py | 13 ------------- adafruit_httpserver/server.py | 1 - 2 files changed, 14 deletions(-) diff --git a/adafruit_httpserver/mime_types.py b/adafruit_httpserver/mime_types.py index 1397d72..2bff2bf 100644 --- a/adafruit_httpserver/mime_types.py +++ b/adafruit_httpserver/mime_types.py @@ -133,12 +133,6 @@ def _keep_for(cls, extensions: List[str]) -> None: """ Unregisters all MIME types except the ones for the given extensions,\ **decreasing overall memory usage**. - - It is recommended to **always** call this function before starting the server. - - Example:: - - keep_for([".jpg", ".mp4", ".txt"]) """ cls.__check_all_start_with_dot(extensions) @@ -157,13 +151,6 @@ def _register(cls, mime_types: dict) -> None: """ Register multiple MIME types. - Example:: - - register({ - ".foo": "application/foo", - ".bar": "application/bar", - }) - :param dict mime_types: A dictionary mapping file extensions to MIME types. """ cls.__check_all_start_with_dot(mime_types.keys()) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index d1d2b99..1d92a2e 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -216,7 +216,6 @@ def _handle_request(self, request: Request, handler: Union[Callable, None]): # ...no root_path, access to filesystem disabled, return 404. elif self.root_path is None: - # Response(request, status=NOT_FOUND_404).send() raise ServingFilesDisabledError # ..root_path is set, access to filesystem enabled... From 61135f152cc7410942f40a57ffede52fffd0bac6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 29 Apr 2023 14:18:07 +0000 Subject: [PATCH 23/44] Added checking if compatible send method is used --- adafruit_httpserver/response.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 578488e..a6a9a5e 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -157,6 +157,15 @@ def _check_if_not_already_sent(self) -> None: if self._response_already_sent: raise ResponseAlreadySentError + def _check_chunked(self, expected_value: bool) -> None: + """Prevents calling incompatible methods on chunked/non-chunked response.""" + if self.chunked != expected_value: + raise RuntimeError( + "Trying to send non-chunked data in chunked response." + if self.chunked + else "Trying to send chunked data in non-chunked response." + ) + def send( self, body: str = "", @@ -169,6 +178,7 @@ def send( Should be called **only once** per response. """ self._check_if_not_already_sent() + self._check_chunked(False) if getattr(body, "encode", None): encoded_response_message_body = body.encode("utf-8") @@ -239,6 +249,7 @@ def send_file( # pylint: disable=too-many-arguments Should be called **only once** per response. """ self._check_if_not_already_sent() + self._check_chunked(False) if safe: self._check_file_path_is_valid(filename) @@ -268,6 +279,8 @@ def send_chunk(self, chunk: str = "") -> None: :param str chunk: String data to be sent. """ + self._check_chunked(True) + if getattr(chunk, "encode", None): chunk = chunk.encode("utf-8") From 752dcaf34350ed4eebcb5269e641124ed0bf3ed6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 11:18:42 +0000 Subject: [PATCH 24/44] Returning from serve_forever on KeyboardInterrupt --- adafruit_httpserver/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1d92a2e..51b7642 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -103,6 +103,8 @@ def serve_forever(self, host: str, port: int = 80) -> None: while not self.stopped: try: self.poll() + except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development + return except: # pylint: disable=bare-except continue From e5f506b9ba3f96531091f2b046e004a910f73837 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 11:42:25 +0000 Subject: [PATCH 25/44] Added Request.json() --- adafruit_httpserver/request.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index f56581e..e911570 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -17,6 +17,8 @@ except ImportError: pass +import json + from .headers import Headers @@ -115,6 +117,10 @@ def body(self) -> bytes: def body(self, body: bytes) -> None: self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body + 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 + @property def _raw_header_bytes(self) -> bytes: """Returns headers bytes.""" From e1d3e3b820506ab54c42ff127814b7cd2bcb33bc Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 21:03:24 +0000 Subject: [PATCH 26/44] Added method for verifying that server can be started on given host:port --- adafruit_httpserver/server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 51b7642..0c38a98 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -89,6 +89,17 @@ def route_decorator(func: Callable) -> Callable: return route_decorator + def _verify_can_start(self, host: str, port: int) -> None: + """Check if the server can be successfully started. Raises RuntimeError if not.""" + + if host is None or port is None: + raise RuntimeError("Host and port cannot be None") + + try: + self._socket_source.getaddrinfo(host, port) + except OSError as error: + raise RuntimeError(f"Cannot start server on {host}:{port}") from error + def serve_forever(self, host: str, port: int = 80) -> None: """ Wait for HTTP requests at the given host and port. Does not return. @@ -116,6 +127,8 @@ def start(self, host: str, port: int = 80) -> None: :param str host: host name or IP address :param int port: port """ + self._verify_can_start(host, port) + self.stopped = False self._sock = self._socket_source.socket( self._socket_source.AF_INET, self._socket_source.SOCK_STREAM From 0607776e7abc871fb18fb6d4e8ef9c13c6405de9 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 21:05:59 +0000 Subject: [PATCH 27/44] Added host and port attributes to Server --- adafruit_httpserver/server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 0c38a98..6525a57 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -34,6 +34,9 @@ class Server: """A basic socket-based HTTP server.""" + host: str = None + port: int = None + def __init__(self, socket_source: Protocol, root_path: str = None) -> None: """Create a server, and get it ready to run. @@ -129,6 +132,8 @@ def start(self, host: str, port: int = 80) -> None: """ self._verify_can_start(host, port) + self.host, self.port = host, port + self.stopped = False self._sock = self._socket_source.socket( self._socket_source.AF_INET, self._socket_source.SOCK_STREAM @@ -143,6 +148,8 @@ def stop(self) -> None: Current requests will be processed. Server can be started again by calling ``.start()`` or ``.serve_forever()``. """ + self.host, self.port = None, None + self.stopped = True self._sock.close() From dbdbacd7f415a2827b6b02c2198a7ef22bfcc5dd Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 21:51:22 +0000 Subject: [PATCH 28/44] Added Server.debug for verbose messages during development --- adafruit_httpserver/server.py | 42 +++++++++++++++++-- docs/examples.rst | 3 ++ .../httpserver_authentication_handlers.py | 3 +- examples/httpserver_authentication_server.py | 3 +- examples/httpserver_chunked.py | 3 +- examples/httpserver_cpu_information.py | 3 +- examples/httpserver_handler_serves_file.py | 3 +- examples/httpserver_mdns.py | 3 +- examples/httpserver_methods.py | 3 +- examples/httpserver_multiple_servers.py | 7 +--- examples/httpserver_neopixel.py | 3 +- examples/httpserver_simpletest_auto.py | 3 +- examples/httpserver_simpletest_manual.py | 3 +- examples/httpserver_start_and_poll.py | 4 +- examples/httpserver_static_files_serving.py | 3 +- examples/httpserver_url_parameters.py | 5 ++- 16 files changed, 59 insertions(+), 35 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 6525a57..12c482b 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -15,6 +15,7 @@ pass from errno import EAGAIN, ECONNRESET, ETIMEDOUT +from traceback import print_exception from .authentication import Basic, Bearer, require_authentication from .exceptions import ( @@ -37,12 +38,15 @@ class Server: host: str = None port: int = None - def __init__(self, socket_source: Protocol, root_path: str = None) -> None: + def __init__( + self, socket_source: Protocol, root_path: str = None, *, debug: bool = False + ) -> None: """Create a server, and get it ready to run. :param socket: An object that is a source of sockets. This could be a `socketpool` in CircuitPython or the `socket` module in CPython. :param str root_path: Root directory to serve files from + :param bool debug: Enables debug messages useful during development """ self._auths = [] self._buffer = bytearray(1024) @@ -53,6 +57,8 @@ def __init__(self, socket_source: Protocol, root_path: str = None) -> None: self.root_path = root_path self.stopped = False + self.debug = debug + def route(self, path: str, methods: Union[str, List[str]] = GET) -> Callable: """ Decorator used to add a route. @@ -119,8 +125,9 @@ def serve_forever(self, host: str, port: int = 80) -> None: self.poll() except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development return - except: # pylint: disable=bare-except - continue + except Exception as error: # pylint: disable=broad-except + if self.debug: + _debug_exception_in_handler(error) def start(self, host: str, port: int = 80) -> None: """ @@ -142,6 +149,9 @@ def start(self, host: str, port: int = 80) -> None: self._sock.listen(10) self._sock.setblocking(False) # Non-blocking socket + if self.debug: + _debug_started_server(self) + def stop(self) -> None: """ Stops the server from listening for new connections and closes the socket. @@ -245,7 +255,7 @@ def _handle_request(self, request: Request, handler: Union[Callable, None]): # ...request.method is GET or HEAD, try to serve a file from the filesystem. elif request.method in [GET, HEAD]: self._serve_file_from_filesystem(request) - # ... + else: Response(request, status=BAD_REQUEST_400).send() @@ -276,6 +286,9 @@ def poll(self): if (request := self._receive_request(conn, client_address)) is None: return + if self.debug: + _debug_incoming_request(request) + # Find a handler for the route handler = self.routes.find_handler(_Route(request.path, request.method)) @@ -349,3 +362,24 @@ def socket_timeout(self, value: int) -> None: self._timeout = value else: raise ValueError("Server.socket_timeout must be a positive numeric value.") + + +def _debug_started_server(server: "Server"): + """Prints a message when the server starts.""" + host, port = server.host, server.port + + print(f"Started development server on http://{host}:{port}") + + +def _debug_incoming_request(request: "Request"): + """Prints a message when a request is received.""" + client_ip = request.client_address[0] + method = request.method + size = len(request.raw_request) + + print(f"{client_ip} -- {method} {request.path} {size}") + + +def _debug_exception_in_handler(error: Exception): + """Prints a message when an exception is raised in a handler.""" + print_exception(error) diff --git a/docs/examples.rst b/docs/examples.rst index 630a107..b461a83 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,6 +1,9 @@ Simple Test ----------- +**All examples in this document are using** ``Server`` **in** ``debug`` **mode.** +**This mode is useful for development, but it is not recommended to use it in production.** + This is the minimal example of using the library. This example is serving a simple static text message. diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py index dc8c565..d790af7 100644 --- a/examples/httpserver_authentication_handlers.py +++ b/examples/httpserver_authentication_handlers.py @@ -16,7 +16,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool) +server = Server(pool, debug=True) # Create a list of available authentication methods. auths = [ @@ -64,5 +64,4 @@ def require_authentication_or_manually_handle(request: Request): response.send("Not authenticated - Manually handled") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_authentication_server.py b/examples/httpserver_authentication_server.py index dedce5f..4f8c494 100644 --- a/examples/httpserver_authentication_server.py +++ b/examples/httpserver_authentication_server.py @@ -15,7 +15,7 @@ ] pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) server.require_authentication(auths) @@ -29,5 +29,4 @@ def implicit_require_authentication(request: Request): response.send("Authenticated") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_chunked.py b/examples/httpserver_chunked.py index 7876cfd..3e990a7 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -9,7 +9,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool) +server = Server(pool, debug=True) @server.route("/chunked") @@ -26,5 +26,4 @@ def chunked(request: Request): response.send_chunk("ies") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index de7e252..c40b5b2 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -10,7 +10,7 @@ from adafruit_httpserver import Server, Request, Response pool = socketpool.SocketPool(wifi.radio) -server = Server(pool) +server = Server(pool, debug=True) @server.route("/cpu-information") @@ -29,5 +29,4 @@ def cpu_information_handler(request: Request): response.send(json.dumps(data)) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_handler_serves_file.py b/examples/httpserver_handler_serves_file.py index b3ad0ce..4978fa6 100644 --- a/examples/httpserver_handler_serves_file.py +++ b/examples/httpserver_handler_serves_file.py @@ -10,7 +10,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) @server.route("/home") @@ -23,5 +23,4 @@ def home(request: Request): response.send_file("home.html", root_path="/www") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index f8da394..3f3a260 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -14,7 +14,7 @@ mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=80) pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) @server.route("/") @@ -26,5 +26,4 @@ def base(request: Request): response.send_file("index.html") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py index b8e359e..3f1382e 100644 --- a/examples/httpserver_methods.py +++ b/examples/httpserver_methods.py @@ -9,7 +9,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool) +server = Server(pool, debug=True) @server.route("/api", [GET, POST, PUT, DELETE]) @@ -34,5 +34,4 @@ def api(request: Request): response.send("Object deleted") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py index 2d765a0..ebf8426 100644 --- a/examples/httpserver_multiple_servers.py +++ b/examples/httpserver_multiple_servers.py @@ -10,8 +10,8 @@ pool = socketpool.SocketPool(wifi.radio) -bedroom_server = Server(pool, "/bedroom") -office_server = Server(pool, "/office") +bedroom_server = Server(pool, "/bedroom", debug=True) +office_server = Server(pool, "/office", debug=True) @bedroom_server.route("/bedroom") @@ -45,9 +45,6 @@ def home(request: Request): id_address = str(wifi.radio.ipv4_address) # Start the servers. - -print(f"[bedroom_server] Listening on http://{id_address}:5000") -print(f"[office_server] Listening on http://{id_address}:8000") bedroom_server.start(id_address, 5000) office_server.start(id_address, 8000) diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 4663259..df7e0e0 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -11,7 +11,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) @@ -42,5 +42,4 @@ def change_neopixel_color_handler_url_params(request: Request, r: str, g: str, b response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_auto.py b/examples/httpserver_simpletest_auto.py index a186b95..db006ed 100644 --- a/examples/httpserver_simpletest_auto.py +++ b/examples/httpserver_simpletest_auto.py @@ -9,7 +9,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) @server.route("/") @@ -22,5 +22,4 @@ def base(request: Request): response.send(message) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_manual.py b/examples/httpserver_simpletest_manual.py index 0c2ee1c..e8b23b0 100644 --- a/examples/httpserver_simpletest_manual.py +++ b/examples/httpserver_simpletest_manual.py @@ -17,7 +17,7 @@ print("Connected to", ssid) pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) @server.route("/") @@ -30,5 +30,4 @@ def base(request: Request): response.send(message) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_start_and_poll.py b/examples/httpserver_start_and_poll.py index ab64ac3..e1b5a56 100644 --- a/examples/httpserver_start_and_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -9,7 +9,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) @server.route("/") @@ -21,8 +21,6 @@ def base(request: Request): response.send_file("index.html") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") - # Start the server. server.start(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_static_files_serving.py b/examples/httpserver_static_files_serving.py index c29c89d..ba45f2d 100644 --- a/examples/httpserver_static_files_serving.py +++ b/examples/httpserver_static_files_serving.py @@ -18,12 +18,11 @@ ) pool = socketpool.SocketPool(wifi.radio) -server = Server(pool, "/static") +server = Server(pool, "/static", debug=True) # You don't have to add any routes, by default the server will serve files # from it's root_path, which is set to "/static" in this example. # If you don't set a root_path, the server will not serve any files. -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index 283c5c0..b5e0774 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -9,7 +9,7 @@ pool = socketpool.SocketPool(wifi.radio) -server = Server(pool) +server = Server(pool, debug=True) class Device: @@ -49,3 +49,6 @@ def perform_action( with Response(request, content_type="text/plain") as response: response.send(f"Action ({action}) performed on device with ID: {device_id}") + + +server.serve_forever(str(wifi.radio.ipv4_address)) From 7c6ae2a1d67725b9d80752a237c6cff9eba014ff Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Apr 2023 23:12:34 +0000 Subject: [PATCH 29/44] Updated the NeoPixel example to also show using values from POST data --- docs/examples.rst | 14 +++++--- examples/httpserver_neopixel.py | 60 ++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index b461a83..26a243b 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -109,9 +109,15 @@ In example below, handler for ``/api`` route will be called when any of ``GET``, Change NeoPixel color --------------------- -In your handler function you can access the query/GET parameters using ``request.query_params``. -This allows you to pass data to the handler function and use it in your code. -Alternatively you can use URL parameters, which are described later in this document. +There are several ways to pass data to the handler function: + +- In your handler function you can access the query/GET parameters using ``request.query_params`` +- You can also access the POST data directly using ``request.body`` or if you data is in JSON format, + you can use ``request.json()`` to parse it into a dictionary +- Alternatively for short pieces of data you can use URL parameters, which are described later in this document + For more complex data, it is recommended to use JSON format. + +All of these approaches allow you to pass data to the handler function and use it in your code. For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` or ``/change-neopixel-color/255/0/0`` you can change the color of the NeoPixel to red. @@ -119,7 +125,7 @@ Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py - :emphasize-lines: 24-26,34-35 + :emphasize-lines: 25-27,39-40,52-53,61,69 :linenos: Get CPU information diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index df7e0e0..4694f31 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 +from adafruit_httpserver import Server, Request, Response, GET, POST pool = socketpool.SocketPool(wifi.radio) @@ -16,27 +16,57 @@ pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) -@server.route("/change-neopixel-color") +@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. - """ - r = request.query_params.get("r") - g = request.query_params.get("g") - b = request.query_params.get("b") + """Changes the color of the built-in NeoPixel using query/GET params.""" - pixel.fill((int(r or 0), int(g or 0), int(b or 0))) + # e.g. /change-neopixel-color?r=255&g=0&b=0 + + r = request.query_params.get("r") or 0 + g = request.query_params.get("g") or 0 + b = request.query_params.get("b") or 0 + + pixel.fill((int(r), int(g), int(b))) + + with Response(request, content_type="text/plain") as response: + response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + + +@server.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.""" + + 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))) with Response(request, content_type="text/plain") as response: response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color///") -def change_neopixel_color_handler_url_params(request: Request, r: str, g: str, b: str): - """ - Changes the color of the built-in NeoPixel using URL params. - """ - pixel.fill((int(r or 0), int(g or 0), int(b or 0))) +@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.""" + + data = request.json() # e.g {"r": 255, "g": 0, "b": 0} + r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) + + pixel.fill((r, g, b)) + + with Response(request, content_type="text/plain") as response: + response.send(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" +): + """Changes the color of the built-in NeoPixel using URL params.""" + + # e.g. /change-neopixel-color/255/0/0 + + pixel.fill((int(r), int(g), int(b))) with Response(request, content_type="text/plain") as response: response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") From 0eef2e86bdd4c61cb742fd09aef751767daed99f Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 1 May 2023 00:19:59 +0000 Subject: [PATCH 30/44] Changes/fixes in docs --- docs/examples.rst | 52 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 26a243b..913f51c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -3,6 +3,7 @@ Simple Test **All examples in this document are using** ``Server`` **in** ``debug`` **mode.** **This mode is useful for development, but it is not recommended to use it in production.** +**More about Debug mode at the end of Examples section.** This is the minimal example of using the library. This example is serving a simple static text message. @@ -61,8 +62,8 @@ By default ``Response.send_file()`` looks for the file in the server's ``root_pa :lines: 5- :linenos: -Tasks in the background ------------------------ +Tasks between requests +---------------------- If you want your code to do more than just serve web pages, use the ``.start()``/``.poll()`` methods as shown in this example. @@ -73,14 +74,15 @@ a running total of the last 10 samples. .. literalinclude:: ../examples/httpserver_start_and_poll.py :caption: examples/httpserver_start_and_poll.py - :emphasize-lines: 26-39 + :emphasize-lines: 25,34 :linenos: Server with MDNS ---------------- -It is possible to use the MDNS protocol to make the server -accessible via a hostname in addition to an IP address. +It is possible to use the MDNS protocol to make the server accessible via a hostname in addition +to an IP address. It is worth noting that it takes a bit longer to get the response from the server +when accessing it via the hostname. In this example, the server is accessible via ``http://custom-mdns-hostname/`` and ``http://custom-mdns-hostname.local/``. @@ -154,6 +156,10 @@ URL parameters -------------- Alternatively to using query parameters, you can use URL parameters. +They are a better choice when you want to perform different actions based on the URL. +Query/GET parameters are better suited for modifying the behaviour of the handler function. + +Of course it is only a suggestion, you can use them interchangeably and/or both at the same time. In order to use URL parameters, you need to wrap them inside ``<>`` in ``Server.route``, e.g. ````. @@ -177,6 +183,7 @@ Authentication -------------- In order to increase security of your server, you can use ``Basic`` and ``Bearer`` authentication. +Remember that it is **not a replacement for HTTPS**, traffic is still sent **in plain text**, but it can be used to protect your server from unauthorized access. If you want to apply authentication to the whole server, you need to call ``.require_authentication`` on ``Server`` instance. @@ -203,8 +210,41 @@ Using ``.serve_forever()`` for this is not possible because of it's blocking beh Each server **must have a different port number**. In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. +You can share same handler functions between servers or use different ones for each server. .. literalinclude:: ../examples/httpserver_multiple_servers.py :caption: examples/httpserver_multiple_servers.py - :emphasize-lines: 13-14,17,26,35-36,51-52,57-58 + :emphasize-lines: 13-14,17,26,35-36,48-49,54-55 :linenos: + +Debug mode +---------------- + +It is highly recommended to **disable debug mode in production**. + +During development it is useful to see the logs from the server. +You can enable debug mode by setting ``debug=True`` on ``Server`` instance or in constructor, +it is disabled by default. + +Debug mode prints messages on server startup, when a request is received and if exception +occurs during handling of the request in ``.serve_forever()``. + +This is how the logs might look like when debug mode is enabled:: + + Started development server on http://192.168.0.100:80 + 192.168.0.101 -- GET / 194 + 192.168.0.101 -- GET /example 194 + 192.168.0.102 -- POST /api 241 + Traceback (most recent call last): + ... + File "code.py", line 55, in example_handler + KeyError: non_existent_key + 192.168.0.103 -- GET /index.html 242 + ... + + +If you need more information about the request or you want it in a different format you can modify +functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. + +NOTE: +*This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.* From a5be235a9644fbb9131c1993849fa06d5f45cd29 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 1 May 2023 00:47:00 +0000 Subject: [PATCH 31/44] Added stopping server on Ctrl-C and debug message on stop --- adafruit_httpserver/server.py | 9 +++++++++ docs/examples.rst | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 12c482b..1f7f650 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -124,6 +124,7 @@ def serve_forever(self, host: str, port: int = 80) -> None: try: self.poll() except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development + self.stop() return except Exception as error: # pylint: disable=broad-except if self.debug: @@ -163,6 +164,9 @@ def stop(self) -> None: self.stopped = True self._sock.close() + if self.debug: + _debug_stopped_server(self) + def _receive_request( self, sock: Union["SocketPool.Socket", "socket.socket"], @@ -371,6 +375,11 @@ def _debug_started_server(server: "Server"): print(f"Started development server on http://{host}:{port}") +def _debug_stopped_server(server: "Server"): # pylint: disable=unused-argument + """Prints a message when the server stops.""" + print("Stopped development server") + + def _debug_incoming_request(request: "Request"): """Prints a message when a request is received.""" client_ip = request.client_address[0] diff --git a/docs/examples.rst b/docs/examples.rst index 913f51c..c340046 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -240,7 +240,7 @@ This is how the logs might look like when debug mode is enabled:: File "code.py", line 55, in example_handler KeyError: non_existent_key 192.168.0.103 -- GET /index.html 242 - ... + Stopped development server If you need more information about the request or you want it in a different format you can modify From 33d6b25c5ea12fb18e0950fcccef51f7122f2957 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 1 May 2023 12:08:19 +0000 Subject: [PATCH 32/44] Refactor of _Route and _Routes, added append_slash --- adafruit_httpserver/route.py | 31 ++++++++++----------- adafruit_httpserver/server.py | 37 ++++++++++++++++++++------ docs/examples.rst | 4 ++- examples/httpserver_cpu_information.py | 2 +- examples/httpserver_methods.py | 2 +- 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 8a8add8..44dd842 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -8,7 +8,7 @@ """ try: - from typing import Callable, List, Union, Tuple + from typing import Callable, List, Set, Union, Tuple except ImportError: pass @@ -20,18 +20,19 @@ class _Route: """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" - def __init__(self, path: str = "", method: str = GET) -> None: + def __init__( + self, + path: str = "", + methods: Union[str, Set[str]] = GET, + append_slash: bool = False, + ) -> None: self._validate_path(path) self.parameters_names = [ name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" ] - self.path = ( - path - if not self._contains_parameters - else re.sub(r"<\w*>", r"([^/]*)", path) - ) - self.method = method + self.path = re.sub(r"<\w*>", r"([^/]*)", path) + ("/?" if append_slash else "") + self.methods = methods if isinstance(methods, set) else {methods} @staticmethod def _validate_path(path: str) -> None: @@ -41,10 +42,6 @@ def _validate_path(path: str) -> None: if "<>" in path: raise ValueError("All URL parameters must be named.") - @property - def _contains_parameters(self) -> bool: - return 0 < len(self.parameters_names) - def match(self, other: "_Route") -> Tuple[bool, List[str]]: """ Checks if the route matches the other route. @@ -76,12 +73,9 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: route.matches(other2) # False, [] """ - if self.method != other.method: + if not other.methods.issubset(self.methods): return False, [] - if not self._contains_parameters: - return self.path == other.path, [] - regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: return False, [] @@ -89,7 +83,10 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: return True, regex_match.groups() def __repr__(self) -> str: - return f"_Route(path={repr(self.path)}, method={repr(self.method)})" + path = repr(self.path) + methods = repr(self.methods) + + return f"_Route(path={path}, methods={methods})" class _Routes: diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1f7f650..84f9511 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,7 +8,7 @@ """ try: - from typing import Callable, Protocol, Union, List, Tuple + from typing import Callable, Protocol, Union, List, Set, Tuple from socket import socket from socketpool import SocketPool except ImportError: @@ -51,7 +51,7 @@ def __init__( self._auths = [] self._buffer = bytearray(1024) self._timeout = 1 - self.routes = _Routes() + self._routes = _Routes() self._socket_source = socket_source self._sock = None self.root_path = root_path @@ -59,12 +59,22 @@ def __init__( self.debug = debug - def route(self, path: str, methods: Union[str, List[str]] = GET) -> Callable: + def route( + self, + path: str, + methods: Union[str, Set[str]] = GET, + *, + append_slash: bool = False, + ) -> Callable: """ Decorator used to add a route. + If request matches multiple routes, the first matched one added will be used. + :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:: @@ -78,6 +88,14 @@ def route_func(request): def route_func(request): ... + # If you want to access URL with and without trailing slash, use append_slash=True + @server.route("/example-with-slash", append_slash=True) + # which is equivalent to + @server.route("/example-with-slash") + @server.route("/example-with-slash/") + def route_func(request): + ... + # Multiple methods can be specified @server.route("/example", [GET, POST]) def route_func(request): @@ -88,12 +106,13 @@ def route_func(request): def route_func(request, my_parameter): ... """ - if isinstance(methods, str): - methods = [methods] + if path.endswith("/") and append_slash: + raise ValueError("Cannot use append_slash=True when path ends with /") + + methods = methods if isinstance(methods, set) else {methods} def route_decorator(func: Callable) -> Callable: - for method in methods: - self.routes.add(_Route(path, method), func) + self._routes.add(_Route(path, methods, append_slash), func) return func return route_decorator @@ -294,7 +313,9 @@ def poll(self): _debug_incoming_request(request) # Find a handler for the route - handler = self.routes.find_handler(_Route(request.path, request.method)) + handler = self._routes.find_handler( + _Route(request.path, request.method) + ) # Handle the request self._handle_request(request, handler) diff --git a/docs/examples.rst b/docs/examples.rst index c340046..672b839 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -101,7 +101,9 @@ You can pass a list of methods or a single method as a string. It is recommended to use the the values in ``adafruit_httpserver.methods`` module to avoid typos and for future proofness. -In example below, handler for ``/api`` route will be called when any of ``GET``, ``POST``, ``PUT``, ``DELETE`` methods is used. +If you want to route a given path with and without trailing slash, use ``append_slash=True`` parameter. + +In example below, handler for ``/api`` and ``/api/`` route will be called when any of ``GET``, ``POST``, ``PUT``, ``DELETE`` methods is used. .. literalinclude:: ../examples/httpserver_methods.py :caption: examples/httpserver_methods.py diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index c40b5b2..68a5dfd 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -13,7 +13,7 @@ server = Server(pool, debug=True) -@server.route("/cpu-information") +@server.route("/cpu-information", append_slash=True) def cpu_information_handler(request: Request): """ Return the current CPU temperature, frequency, and voltage as JSON. diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py index 3f1382e..92370ca 100644 --- a/examples/httpserver_methods.py +++ b/examples/httpserver_methods.py @@ -12,7 +12,7 @@ server = Server(pool, debug=True) -@server.route("/api", [GET, POST, PUT, DELETE]) +@server.route("/api", [GET, POST, PUT, DELETE], append_slash=True) def api(request: Request): """ Performs different operations depending on the HTTP method. From 191e91c928efff425e5ba969d73dd85d033181f3 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 2 May 2023 13:17:38 +0000 Subject: [PATCH 33/44] Changed format of debug logs, added Response.size, fix in send_chunk --- adafruit_httpserver/response.py | 32 ++++++++++++++++++++++++++++++-- adafruit_httpserver/server.py | 14 +------------- docs/examples.rst | 18 +++++++++++------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index a6a9a5e..2243534 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -90,6 +90,9 @@ def route_func(request): Common MIME types are defined in `adafruit_httpserver.mime_types`. """ + size: int = 0 + """Size of the response in bytes.""" + def __init__( # pylint: disable=too-many-arguments self, request: Request, @@ -192,6 +195,9 @@ def send( self._send_bytes(self.request.connection, encoded_response_message_body) self._response_already_sent = True + if self.request.server.debug: + _debug_response_sent(self) + @staticmethod def _check_file_path_is_valid(file_path: str) -> bool: """ @@ -270,6 +276,9 @@ def send_file( # pylint: disable=too-many-arguments self._send_bytes(self.request.connection, bytes_read) self._response_already_sent = True + if self.request.server.debug: + _debug_response_sent(self) + def send_chunk(self, chunk: str = "") -> None: """ Sends chunk of response. @@ -279,6 +288,7 @@ def send_chunk(self, chunk: str = "") -> None: :param str chunk: String data to be sent. """ + self._check_if_not_already_sent() self._check_chunked(True) if getattr(chunk, "encode", None): @@ -299,14 +309,19 @@ def __exit__(self, exception_type, exception_value, exception_traceback): if self.chunked: self.send_chunk("") + self._response_already_sent = True + + if self.chunked and self.request.server.debug: + _debug_response_sent(self) + return True - @staticmethod def _send_bytes( + self, conn: Union["SocketPool.Socket", "socket.socket"], buffer: Union[bytes, bytearray, memoryview], ): - bytes_sent = 0 + bytes_sent: int = 0 bytes_to_send = len(buffer) view = memoryview(buffer) while bytes_sent < bytes_to_send: @@ -318,3 +333,16 @@ def _send_bytes( if exc.errno == ECONNRESET: return raise + self.size += bytes_sent + + +def _debug_response_sent(response: "Response"): + """Prints a message when after a response is sent.""" + client_ip = response.request.client_address[0] + method = response.request.method + path = response.request.path + req_size = len(response.request.raw_request) + status = response.status + res_size = response.size + + print(f'{client_ip} -- "{method} {path}" {req_size} -- "{status}" {res_size}') diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 84f9511..ab84df0 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -309,9 +309,6 @@ def poll(self): if (request := self._receive_request(conn, client_address)) is None: return - if self.debug: - _debug_incoming_request(request) - # Find a handler for the route handler = self._routes.find_handler( _Route(request.path, request.method) @@ -397,19 +394,10 @@ def _debug_started_server(server: "Server"): def _debug_stopped_server(server: "Server"): # pylint: disable=unused-argument - """Prints a message when the server stops.""" + """Prints a message after the server stops.""" print("Stopped development server") -def _debug_incoming_request(request: "Request"): - """Prints a message when a request is received.""" - client_ip = request.client_address[0] - method = request.method - size = len(request.raw_request) - - print(f"{client_ip} -- {method} {request.path} {size}") - - def _debug_exception_in_handler(error: Exception): """Prints a message when an exception is raised in a handler.""" print_exception(error) diff --git a/docs/examples.rst b/docs/examples.rst index 672b839..d7c3ace 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -228,25 +228,29 @@ During development it is useful to see the logs from the server. You can enable debug mode by setting ``debug=True`` on ``Server`` instance or in constructor, it is disabled by default. -Debug mode prints messages on server startup, when a request is received and if exception +Debug mode prints messages on server startup, after sending a response to a request and if exception occurs during handling of the request in ``.serve_forever()``. This is how the logs might look like when debug mode is enabled:: Started development server on http://192.168.0.100:80 - 192.168.0.101 -- GET / 194 - 192.168.0.101 -- GET /example 194 - 192.168.0.102 -- POST /api 241 + 192.168.0.101 -- "GET /" 194 -- "200 OK" 154 + 192.168.0.101 -- "GET /example" 134 -- "404 Not Found" 172 + 192.168.0.102 -- "POST /api" 1241 -- "401 Unauthorized" 95 Traceback (most recent call last): ... File "code.py", line 55, in example_handler KeyError: non_existent_key - 192.168.0.103 -- GET /index.html 242 + 192.168.0.103 -- "GET /index.html" 242 -- "200 OK" 154 Stopped development server +This is the default format of the logs:: -If you need more information about the request or you want it in a different format you can modify -functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. + {client_ip} -- "{request_method} {path}" {request_size} -- "{response_status}" {response_size} + +If you need more information about the server or request, or you want it in a different format you can modify +functions at the bottom of ``adafruit_httpserver/server.py`` and ``adafruit_httpserver/response.py`` that +start with ``_debug_...``. NOTE: *This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.* From a424d27098b1f80183e6365a5213656871193b62 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 3 May 2023 13:53:30 +0000 Subject: [PATCH 34/44] Added ... and .... wildcards --- adafruit_httpserver/route.py | 22 ++++++++++++++++++---- adafruit_httpserver/server.py | 8 +++++++- docs/examples.rst | 13 ++++++++++--- examples/httpserver_url_parameters.py | 12 ++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 44dd842..d44eaea 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -31,7 +31,9 @@ def __init__( self.parameters_names = [ name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" ] - self.path = re.sub(r"<\w*>", r"([^/]*)", path) + ("/?" if append_slash else "") + 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} @staticmethod @@ -54,10 +56,12 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: Examples:: - route = _Route("/example", GET) + route = _Route("/example", GET, True) - other1 = _Route("/example", GET) - route.matches(other1) # 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, [] @@ -71,6 +75,16 @@ def match(self, other: "_Route") -> Tuple[bool, List[str]]: other2 = _Route("/other-example", GET) route.matches(other2) # False, [] + + ... + + 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, [] """ if not other.methods.issubset(self.methods): diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ab84df0..5559dfc 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -102,9 +102,15 @@ def route_func(request): ... # URL parameters can be specified - @server.route("/example/", GET) + @server.route("/example/", GET) e.g. /example/123 def route_func(request, my_parameter): ... + + # It is possible to use wildcard that can match any number of path segments + @server.route("/example/.../something", GET) # e.g. /example/123/something + @server.route("/example/..../something", GET) # e.g. /example/123/456/something + def route_func(request): + ... """ if path.endswith("/") and append_slash: raise ValueError("Cannot use append_slash=True when path ends with /") diff --git a/docs/examples.rst b/docs/examples.rst index d7c3ace..f329f7c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -154,8 +154,8 @@ To use it, you need to set the ``chunked=True`` when creating a ``Response`` obj :emphasize-lines: 21-26 :linenos: -URL parameters --------------- +URL parameters and wildcards +---------------------------- Alternatively to using query parameters, you can use URL parameters. They are a better choice when you want to perform different actions based on the URL. @@ -176,9 +176,16 @@ In the example below the second route has only one URL parameter, so the ``actio Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type. Also note that the names of the function parameters **have to match** with the ones used in route, but they **do not have to** be in the same order. +It is also possible to specify a wildcard route: + +- ``...`` - matches one path segment, e.g ``/api/...`` will match ``/api/123``, but **not** ``/api/123/456`` +- ``....`` - matches multiple path segments, e.g ``/api/....`` will match ``/api/123`` and ``/api/123/456`` + +In both cases, wildcards will not match empty path segment, so ``/api/.../users`` will match ``/api/v1/users``, but not ``/api//users`` or ``/api/users``. + .. literalinclude:: ../examples/httpserver_url_parameters.py :caption: examples/httpserver_url_parameters.py - :emphasize-lines: 30-34 + :emphasize-lines: 30-34,54-55 :linenos: Authentication diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index b5e0774..412d04c 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -51,4 +51,16 @@ def perform_action( response.send(f"Action ({action}) performed on device with ID: {device_id}") +@server.route("/device/.../status", append_slash=True) +@server.route("/device/....", append_slash=True) +def device_status(request: Request): + """ + Returns the status of all devices no matter what their ID is. + Unknown commands also return the status of all devices. + """ + + with Response(request, content_type="text/plain") as response: + response.send("Status of all devices: ...") + + server.serve_forever(str(wifi.radio.ipv4_address)) From 3359668c08ab269d114be78a3e77af7df5c2be79 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 3 May 2023 15:16:10 +0000 Subject: [PATCH 35/44] Imported missing exception in module init, updated authors --- adafruit_httpserver/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 28e65d5..b9d12d0 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -8,7 +8,7 @@ Socket based HTTP Server for CircuitPython -* Author(s): Dan Halbert +* Author(s): Dan Halbert, Michał Pokusa Implementation Notes -------------------- @@ -30,12 +30,14 @@ require_authentication, ) from .exceptions import ( + ServerStoppedError, AuthenticationError, - BackslashInPathError, - FileNotExistsError, InvalidPathError, ParentDirectoryReferenceError, + BackslashInPathError, ResponseAlreadySentError, + ServingFilesDisabledError, + FileNotExistsError, ) from .headers import Headers from .methods import ( From 55be729bfa816bdb52e5c3009190b83ba2e9d291 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 5 May 2023 22:03:18 +0000 Subject: [PATCH 36/44] Fix: Incorrectly changing from list to set using brackets --- adafruit_httpserver/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 5559dfc..f944929 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -115,7 +115,7 @@ def route_func(request): if path.endswith("/") and append_slash: raise ValueError("Cannot use append_slash=True when path ends with /") - methods = methods if isinstance(methods, set) else {methods} + methods = methods if isinstance(methods, set) else set(methods) def route_decorator(func: Callable) -> Callable: self._routes.add(_Route(path, methods, append_slash), func) From c299f9fdc97be0248330d07835a7c6395067db2d Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 6 May 2023 15:26:44 +0000 Subject: [PATCH 37/44] Corrected typing in Response.send --- adafruit_httpserver/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 2243534..8fb1856 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -171,7 +171,7 @@ def _check_chunked(self, expected_value: bool) -> None: def send( self, - body: str = "", + body: Union[str, bytes] = "", content_type: str = None, ) -> None: """ From e3529d6e37633fb1a301d0cbc63ddaef86464d46 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 7 May 2023 21:37:00 +0000 Subject: [PATCH 38/44] Moved debugging exception to .poll() --- adafruit_httpserver/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index f944929..a7aa589 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -151,9 +151,6 @@ def serve_forever(self, host: str, port: int = 80) -> None: except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development self.stop() return - except Exception as error: # pylint: disable=broad-except - if self.debug: - _debug_exception_in_handler(error) def start(self, host: str, port: int = 80) -> None: """ @@ -332,6 +329,10 @@ def poll(self): return raise + except Exception as error: # pylint: disable=broad-except + if self.debug: + _debug_exception_in_handler(error) + def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: """ Requires authentication for all routes and files in ``root_path``. From f95781a05016796eb9b868b90752830f4305dfb4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 8 May 2023 09:01:12 +0000 Subject: [PATCH 39/44] Rewrite of Response logic from context managers to returns, added some new types of response --- adafruit_httpserver/__init__.py | 9 +- adafruit_httpserver/exceptions.py | 6 - adafruit_httpserver/response.py | 461 ++++++++++++++++-------------- adafruit_httpserver/route.py | 7 +- adafruit_httpserver/server.py | 99 ++++--- 5 files changed, 328 insertions(+), 254 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index b9d12d0..cb152b2 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -35,7 +35,6 @@ InvalidPathError, ParentDirectoryReferenceError, BackslashInPathError, - ResponseAlreadySentError, ServingFilesDisabledError, FileNotExistsError, ) @@ -53,7 +52,13 @@ ) from .mime_types import MIMETypes from .request import Request -from .response import Response +from .response import ( + Response, + FileResponse, + ChunkedResponse, + JSONResponse, + Redirect, +) from .server import Server from .status import ( Status, diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py index 0dd14ec..52f000f 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -46,12 +46,6 @@ def __init__(self, path: str) -> None: super().__init__(f"Backslash in path: {path}") -class ResponseAlreadySentError(Exception): - """ - Another ``Response`` has already been sent. There can only be one per ``Request``. - """ - - class ServingFilesDisabledError(Exception): """ Raised when ``root_path`` is not set and there is no handler for ``request``. diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 8fb1856..0310f90 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -8,143 +8,83 @@ """ try: - from typing import Optional, Dict, Union, Tuple + from typing import Optional, Dict, Union, Tuple, Generator, Any from socket import socket from socketpool import SocketPool except ImportError: pass import os +import json from errno import EAGAIN, ECONNRESET from .exceptions import ( BackslashInPathError, FileNotExistsError, ParentDirectoryReferenceError, - ResponseAlreadySentError, ) from .mime_types import MIMETypes from .request import Request -from .status import Status, OK_200 +from .status import Status, OK_200, TEMPORARY_REDIRECT_307, PERMANENT_REDIRECT_308 from .headers import Headers -class Response: +class Response: # pylint: disable=too-few-public-methods """ Response to a given `Request`. Use in `Server.route` handler functions. - Example:: - - # Response with 'Content-Length' header - @server.route(path, method) - def route_func(request): - - response = Response(request) - response.send("Some content", content_type="text/plain") - - # or + Base class for all other response classes. - response = Response(request) - with response: - response.send(body='Some content', content_type="text/plain") - - # or - - with Response(request) as response: - response.send("Some content", content_type="text/plain") + Example:: - # Response with 'Transfer-Encoding: chunked' header @server.route(path, method) - def route_func(request): - - response = Response(request, content_type="text/plain", chunked=True) - with response: - response.send_chunk("Some content") - response.send_chunk("Some more content") - - # or - - with Response(request, content_type="text/plain", chunked=True) as response: - response.send_chunk("Some content") - response.send_chunk("Some more content") - """ - - request: Request - """The request that this is a response to.""" - - http_version: str - - status: Status - """Status code of the response. Defaults to ``200 OK``.""" - - headers: Headers - """Headers to be sent in the response.""" - - content_type: str - """ - Defaults to ``text/plain`` if not set. - - Can be explicitly provided in the constructor, in ``send()`` or - implicitly determined from filename in ``send_file()``. + def route_func(request: Request): - Common MIME types are defined in `adafruit_httpserver.mime_types`. + return Response(request, body='Some content', content_type="text/plain") """ - size: int = 0 - """Size of the response in bytes.""" - def __init__( # pylint: disable=too-many-arguments self, request: Request, + body: Union[str, bytes] = "", + *, status: Union[Status, Tuple[int, str]] = OK_200, headers: Union[Headers, Dict[str, str]] = None, content_type: str = None, - http_version: str = "HTTP/1.1", - chunked: bool = False, ) -> None: """ - Creates an HTTP response. - - Sets `status`, ``headers`` and `http_version` - and optionally default ``content_type``. - - To send the response, call ``send`` or ``send_file``. - For chunked response use - ``with Response(request, content_type=..., chunked=True) as r:`` and `send_chunk`. + :param Request request: Request that this is a response to. + :param str body: Body of response. Defaults to empty string. + :param Status status: Status code and text. Defaults to 200 OK. + :param Headers headers: Headers to include in response. Defaults to empty dict. + :param str content_type: Content type of response. Defaults to None. """ - self.request = request - self.status = status if isinstance(status, Status) else Status(*status) - self.headers = ( + + self._request = request + self._body = body + self._status = status if isinstance(status, Status) else Status(*status) + self._headers = ( headers.copy() if isinstance(headers, Headers) else Headers(headers) ) - self.content_type = content_type - self.http_version = http_version - self.chunked = chunked - self._response_already_sent = False + self._content_type = content_type + self._size = 0 def _send_headers( self, content_length: Optional[int] = None, content_type: str = None, ) -> None: - """ - Sends headers. - Implicitly called by ``send`` and ``send_file`` and in - ``with Response(request, chunked=True) as response:`` context manager. - """ - headers = self.headers.copy() + headers = self._headers.copy() response_message_header = ( - f"{self.http_version} {self.status.code} {self.status.text}\r\n" + f"HTTP/1.1 {self._status.code} {self._status.text}\r\n" ) headers.setdefault( - "Content-Type", content_type or self.content_type or MIMETypes.DEFAULT + "Content-Type", content_type or self._content_type or MIMETypes.DEFAULT ) headers.setdefault("Connection", "close") - if self.chunked: - headers.setdefault("Transfer-Encoding", "chunked") - else: + if content_length is not None: headers.setdefault("Content-Length", content_length) for header, value in headers.items(): @@ -152,56 +92,113 @@ def _send_headers( response_message_header += "\r\n" self._send_bytes( - self.request.connection, response_message_header.encode("utf-8") + self._request.connection, response_message_header.encode("utf-8") ) - def _check_if_not_already_sent(self) -> None: - """Prevents calling ``send`` or ``send_file`` more than once.""" - if self._response_already_sent: - raise ResponseAlreadySentError - - def _check_chunked(self, expected_value: bool) -> None: - """Prevents calling incompatible methods on chunked/non-chunked response.""" - if self.chunked != expected_value: - raise RuntimeError( - "Trying to send non-chunked data in chunked response." - if self.chunked - else "Trying to send chunked data in non-chunked response." - ) + def _send(self) -> None: + encoded_body = ( + self._body.encode("utf-8") if isinstance(self._body, str) else self._body + ) - def send( + self._send_headers(len(encoded_body), self._content_type) + self._send_bytes(self._request.connection, encoded_body) + + def _send_bytes( self, - body: Union[str, bytes] = "", + conn: Union["SocketPool.Socket", "socket.socket"], + buffer: Union[bytes, bytearray, memoryview], + ): + bytes_sent: int = 0 + bytes_to_send = len(buffer) + view = memoryview(buffer) + while bytes_sent < bytes_to_send: + try: + bytes_sent += conn.send(view[bytes_sent:]) + except OSError as exc: + if exc.errno == EAGAIN: + continue + if exc.errno == ECONNRESET: + return + raise + self._size += bytes_sent + + +class FileResponse(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for sending files. + + Instead of ``body`` it takes ``filename`` and ``root_path`` arguments. + It is also possible to send only headers with ``head_only`` argument or modify ``buffer_size``. + + If browsers should download the file instead of displaying it, use ``as_attachment`` and + ``download_filename`` arguments. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return FileResponse(request, filename='index.html', root_path='/www') + """ + + def __init__( # pylint: disable=too-many-arguments + self, + request: Request, + filename: str = "index.html", + root_path: str = None, + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, content_type: str = None, + as_attachment: bool = False, + download_filename: str = None, + buffer_size: int = 1024, + head_only: bool = False, + safe: bool = True, ) -> None: """ - Sends response with content built from ``body``. - Implicitly calls ``_send_headers`` before sending the body. - - Should be called **only once** per response. + :param Request request: Request that this is a response to. + :param str filename: Name of the file to send. + :param str root_path: Path to the root directory from which to serve files. Defaults to + server's ``root_path``. + :param Status status: Status code and text. Defaults to 200 OK. + :param Headers headers: Headers to include in response. + :param str content_type: Content type of response. + :param bool as_attachment: If True, the file will be sent as an attachment. + :param str download_filename: Name of the file to send as an attachment. + :param int buffer_size: Size of the buffer used to send the file. Defaults to 1024. + :param bool head_only: If True, only headers will be sent. Defaults to False. + :param bool safe: If True, checks if ``filename`` is valid. Defaults to True. """ - self._check_if_not_already_sent() - self._check_chunked(False) - - if getattr(body, "encode", None): - encoded_response_message_body = body.encode("utf-8") - else: - encoded_response_message_body = body + if safe: + self._verify_file_path_is_valid(filename) - self._send_headers( - content_type=content_type or self.content_type, - content_length=len(encoded_response_message_body), + super().__init__( + request=request, + headers=headers, + content_type=content_type, + status=status, ) - self._send_bytes(self.request.connection, encoded_response_message_body) - self._response_already_sent = True - - if self.request.server.debug: - _debug_response_sent(self) + self._filename = filename + "index.html" if filename.endswith("/") else filename + self._root_path = root_path or self._request.server.root_path + self._full_file_path = self._combine_path(self._root_path, self._filename) + self._content_type = content_type or MIMETypes.get_for_filename(self._filename) + self._file_length = self._get_file_length(self._full_file_path) + + self._buffer_size = buffer_size + self._head_only = head_only + self._safe = safe + + if as_attachment: + self._headers.setdefault( + "Content-Disposition", + f"attachment; filename={download_filename or self._filename.split('/')[-1]}", + ) @staticmethod - def _check_file_path_is_valid(file_path: str) -> bool: + def _verify_file_path_is_valid(file_path: str): """ - Checks if ``file_path`` does not contain backslashes or parent directory references. + Verifies that ``file_path`` does not contain backslashes or parent directory references. If not raises error corresponding to the problem. """ @@ -235,114 +232,162 @@ def _get_file_length(file_path: str) -> int: Raises ``FileNotExistsError`` if file does not exist. """ try: - return os.stat(file_path)[6] - except OSError: + stat = os.stat(file_path) + st_mode, st_size = stat[0], stat[6] + assert (st_mode & 0o170000) == 0o100000 # Check if it is a regular file + return st_size + except (OSError, AssertionError): raise FileNotExistsError(file_path) # pylint: disable=raise-missing-from - def send_file( # pylint: disable=too-many-arguments + def _send(self) -> None: + self._send_headers(self._file_length, self._content_type) + + if not self._head_only: + 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) + + +class ChunkedResponse(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for sending data using chunked transfer encoding. + + Instead of requiring the whole content to be passed to the constructor, it expects + a **generator** that yields chunks of data. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + def body(): + yield "Some ch" + yield "unked co" + yield "ntent" + + return ChunkedResponse(request, body, content_type="text/plain") + """ + + def __init__( # pylint: disable=too-many-arguments self, - filename: str = "index.html", - root_path: str = None, - buffer_size: int = 1024, - head_only: bool = False, - safe: bool = True, + request: Request, + body: Generator[Union[str, bytes], Any, Any], + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, + content_type: str = None, ) -> None: """ - Send response with content of ``filename`` located in ``root_path``. - Implicitly calls ``_send_headers`` before sending the file content. - File is send split into ``buffer_size`` parts. - - Should be called **only once** per response. + :param Request request: Request object + :param Generator body: Generator that yields chunks of data. + :param Status status: Status object or tuple with code and message. + :param Headers headers: Headers to be sent with the response. + :param str content_type: Content type of the response. """ - self._check_if_not_already_sent() - self._check_chunked(False) - if safe: - self._check_file_path_is_valid(filename) + super().__init__( + request=request, + headers=headers, + status=status, + content_type=content_type, + ) + self._headers.setdefault("Transfer-Encoding", "chunked") + self._body = body - root_path = root_path or self.request.server.root_path - full_file_path = self._combine_path(root_path, filename) + def _send_chunk(self, chunk: Union[str, bytes] = "") -> None: + encoded_chunk = chunk.encode("utf-8") if isinstance(chunk, str) else chunk - file_length = self._get_file_length(full_file_path) + self._send_bytes(self._request.connection, b"%x\r\n" % len(encoded_chunk)) + self._send_bytes(self._request.connection, encoded_chunk) + self._send_bytes(self._request.connection, b"\r\n") - self._send_headers( - content_type=MIMETypes.get_for_filename(filename), - content_length=file_length, - ) + def _send(self) -> None: + self._send_headers() - if not head_only: - with open(full_file_path, "rb") as file: - while bytes_read := file.read(buffer_size): - self._send_bytes(self.request.connection, bytes_read) - self._response_already_sent = True + for chunk in self._body(): + self._send_chunk(chunk) - if self.request.server.debug: - _debug_response_sent(self) + # Empty chunk to indicate end of response + self._send_chunk() - def send_chunk(self, chunk: str = "") -> None: - """ - Sends chunk of response. - Should be used **only** inside - ``with Response(request, chunked=True) as response:`` context manager. +class JSONResponse(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for sending JSON data. - :param str chunk: String data to be sent. - """ - self._check_if_not_already_sent() - self._check_chunked(True) + Instead of requiring ``body`` to be passed to the constructor, it expects ``data`` to be passed + instead. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return JSONResponse(request, {"key": "value"}) + """ - if getattr(chunk, "encode", None): - chunk = chunk.encode("utf-8") + def __init__( + self, + request: Request, + data: Dict[Any, Any], + *, + headers: Union[Headers, Dict[str, str]] = None, + status: Union[Status, Tuple[int, str]] = OK_200, + ) -> None: + """ + :param Request request: Request that this is a response to. + :param dict data: Data to be sent as JSON. + :param Headers headers: Headers to include in response. + :param Status status: Status code and text. Defaults to 200 OK. + """ + super().__init__( + request=request, + headers=headers, + status=status, + ) + self._data = data - self._send_bytes(self.request.connection, b"%x\r\n" % len(chunk)) - self._send_bytes(self.request.connection, chunk) - self._send_bytes(self.request.connection, b"\r\n") + def _send(self) -> None: + encoded_data = json.dumps(self._data).encode("utf-8") - def __enter__(self): - if self.chunked: - self._send_headers() - return self + self._send_headers(len(encoded_data), "application/json") + self._send_bytes(self._request.connection, encoded_data) - def __exit__(self, exception_type, exception_value, exception_traceback): - if exception_type is not None: - return False - if self.chunked: - self.send_chunk("") - self._response_already_sent = True +class Redirect(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for redirecting to another URL. - if self.chunked and self.request.server.debug: - _debug_response_sent(self) + Instead of requiring the body to be passed to the constructor, it expects a URL to redirect to. - return True + Example:: - def _send_bytes( - self, - conn: Union["SocketPool.Socket", "socket.socket"], - buffer: Union[bytes, bytearray, memoryview], - ): - bytes_sent: int = 0 - bytes_to_send = len(buffer) - view = memoryview(buffer) - while bytes_sent < bytes_to_send: - try: - bytes_sent += conn.send(view[bytes_sent:]) - except OSError as exc: - if exc.errno == EAGAIN: - continue - if exc.errno == ECONNRESET: - return - raise - self.size += bytes_sent + @server.route(path, method) + def route_func(request: Request): + return Redirect(request, "https://www.example.com") + """ -def _debug_response_sent(response: "Response"): - """Prints a message when after a response is sent.""" - client_ip = response.request.client_address[0] - method = response.request.method - path = response.request.path - req_size = len(response.request.raw_request) - status = response.status - res_size = response.size + def __init__( + self, + request: Request, + url: str, + *, + permanent: bool = False, + headers: Union[Headers, Dict[str, str]] = None, + ) -> None: + """ + :param Request request: Request that this is a response to. + :param str url: URL to redirect to. + :param bool permanent: Whether to use a permanent redirect (308) or a temporary one (307). + :param Headers headers: Headers to include in response. + """ + super().__init__( + request, + status=PERMANENT_REDIRECT_308 if permanent else TEMPORARY_REDIRECT_307, + headers=headers, + ) + self._headers.update({"Location": url}) - print(f'{client_ip} -- "{method} {path}" {req_size} -- "{status}" {res_size}') + def _send(self) -> None: + self._send_headers() diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index d44eaea..96750f4 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -8,7 +8,10 @@ """ try: - from typing import Callable, List, Set, Union, Tuple + from typing import Callable, List, Set, Union, Tuple, TYPE_CHECKING + + if TYPE_CHECKING: + from .response import Response except ImportError: pass @@ -116,7 +119,7 @@ def add(self, route: _Route, handler: Callable): self._routes.append(route) self._handlers.append(handler) - def find_handler(self, route: _Route) -> Union[Callable, None]: + def find_handler(self, route: _Route) -> Union[Callable["...", "Response"], None]: """ Finds a handler for a given route. diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index a7aa589..89fca4d 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -27,7 +27,7 @@ ) from .methods import GET, HEAD from .request import Request -from .response import Response +from .response import Response, FileResponse from .route import _Routes, _Route from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 @@ -151,6 +151,8 @@ def serve_forever(self, host: str, port: int = 80) -> None: except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development self.stop() return + except Exception: # pylint: disable=broad-except + pass # Ignore exceptions in handler function def start(self, host: str, port: int = 80) -> None: """ @@ -251,16 +253,9 @@ def _receive_body_bytes( raise ex return received_body_bytes[:content_length] - def _serve_file_from_filesystem(self, request: Request): - filename = "index.html" if request.path == "/" else request.path - root_path = self.root_path - buffer_size = self.request_buffer_size - head_only = request.method == HEAD - - with Response(request) as response: - response.send_file(filename, root_path, buffer_size, head_only) - - def _handle_request(self, request: Request, handler: Union[Callable, None]): + def _handle_request( + self, request: Request, handler: Union[Callable, None] + ) -> Union[Response, None]: try: # Check server authentications if necessary if self._auths: @@ -268,32 +263,43 @@ def _handle_request(self, request: Request, handler: Union[Callable, None]): # Handler for route exists and is callable if handler is not None and callable(handler): - handler(request) + return handler(request) - # Handler is not found... - - # ...no root_path, access to filesystem disabled, return 404. - elif self.root_path is None: + # No root_path, access to filesystem disabled, return 404. + if self.root_path is None: raise ServingFilesDisabledError - # ..root_path is set, access to filesystem enabled... - - # ...request.method is GET or HEAD, try to serve a file from the filesystem. - elif request.method in [GET, HEAD]: - self._serve_file_from_filesystem(request) + # Method is GET or HEAD, try to serve a file from the filesystem. + if request.method in [GET, HEAD]: + return FileResponse( + request, + filename=request.path, + root_path=self.root_path, + head_only=request.method == HEAD, + ) - else: - Response(request, status=BAD_REQUEST_400).send() + return Response(request, status=BAD_REQUEST_400) except AuthenticationError: - headers = {"WWW-Authenticate": 'Basic charset="UTF-8"'} - Response(request, status=UNAUTHORIZED_401, headers=headers).send() + return Response( + request, + status=UNAUTHORIZED_401, + headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, + ) except InvalidPathError as error: - Response(request, status=FORBIDDEN_403).send(str(error)) + return Response( + request, + str(error) if self.debug else "Invalid path", + status=FORBIDDEN_403, + ) except (FileNotExistsError, ServingFilesDisabledError) as error: - Response(request, status=NOT_FOUND_404).send(str(error)) + return Response( + request, + str(error) if self.debug else "File not found", + status=NOT_FOUND_404, + ) def poll(self): """ @@ -318,21 +324,29 @@ def poll(self): ) # Handle the request - self._handle_request(request, handler) + response = self._handle_request(request, handler) - except OSError as error: - # There is no data available right now, try again later. - if error.errno == EAGAIN: - return - # Connection reset by peer, try again later. - if error.errno == ECONNRESET: - return - raise + # Send the response + if response is not None: + response._send() # pylint: disable=protected-access + + if self.debug: + _debug_response_sent(response) except Exception as error: # pylint: disable=broad-except + if isinstance(error, OSError): + # There is no data available right now, try again later. + if error.errno == EAGAIN: + return + # Connection reset by peer, try again later. + if error.errno == ECONNRESET: + return + if self.debug: _debug_exception_in_handler(error) + raise error # Raise the exception again to be handled by the user. + def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: """ Requires authentication for all routes and files in ``root_path``. @@ -400,6 +414,19 @@ def _debug_started_server(server: "Server"): print(f"Started development server on http://{host}:{port}") +def _debug_response_sent(response: "Response"): + """Prints a message when after a response is sent.""" + # pylint: disable=protected-access + client_ip = response._request.client_address[0] + method = response._request.method + path = response._request.path + req_size = len(response._request.raw_request) + status = response._status + res_size = response._size + + print(f'{client_ip} -- "{method} {path}" {req_size} -- "{status}" {res_size}') + + def _debug_stopped_server(server: "Server"): # pylint: disable=unused-argument """Prints a message after the server stops.""" print("Stopped development server") From 87b0e278fdabba2402581b485b62b46221102edf Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 8 May 2023 09:25:38 +0000 Subject: [PATCH 40/44] Updated docs and examples to new Response API, added example of Redirect --- docs/api.rst | 3 - docs/examples.rst | 64 +++++++++++-------- .../httpserver_authentication_handlers.py | 21 +++--- examples/httpserver_authentication_server.py | 3 +- examples/httpserver_chunked.py | 16 +++-- examples/httpserver_cpu_information.py | 6 +- examples/httpserver_handler_serves_file.py | 5 +- examples/httpserver_mdns.py | 6 +- examples/httpserver_methods.py | 53 ++++++++++++--- examples/httpserver_multiple_servers.py | 15 ++--- examples/httpserver_neopixel.py | 12 ++-- examples/httpserver_redirects.py | 41 ++++++++++++ examples/httpserver_simpletest_auto.py | 4 +- examples/httpserver_simpletest_manual.py | 4 +- examples/httpserver_start_and_poll.py | 5 +- examples/httpserver_url_parameters.py | 12 ++-- 16 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 examples/httpserver_redirects.py diff --git a/docs/api.rst b/docs/api.rst index ac5170b..a8fad68 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -22,9 +22,6 @@ .. automodule:: adafruit_httpserver.status :members: -.. automodule:: adafruit_httpserver.methods - :members: - .. automodule:: adafruit_httpserver.mime_types :members: diff --git a/docs/examples.rst b/docs/examples.rst index f329f7c..683c094 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -49,11 +49,11 @@ In order to save memory, we are unregistering unused MIME types and registering :linenos: You can also serve a specific file from the handler. -By default ``Response.send_file()`` looks for the file in the server's ``root_path`` directory, but you can change it. +By default ``FileResponse`` looks for the file in the server's ``root_path`` directory, but you can change it. .. literalinclude:: ../examples/httpserver_handler_serves_file.py :caption: examples/httpserver_handler_serves_file.py - :emphasize-lines: 22-23 + :emphasize-lines: 22 :linenos: .. literalinclude:: ../examples/home.html @@ -74,7 +74,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: 25,34 + :emphasize-lines: 24,33 :linenos: Server with MDNS @@ -91,6 +91,17 @@ In this example, the server is accessible via ``http://custom-mdns-hostname/`` a :emphasize-lines: 12-14 :linenos: +Get CPU information +------------------- + +You can return data from sensors or any computed value as JSON. +That makes it easy to use the data in other applications. + +.. literalinclude:: ../examples/httpserver_cpu_information.py + :caption: examples/httpserver_cpu_information.py + :emphasize-lines: 9,27 + :linenos: + Handling different methods --------------------------------------- @@ -107,7 +118,7 @@ In example below, handler for ``/api`` and ``/api/`` route will be called when a .. literalinclude:: ../examples/httpserver_methods.py :caption: examples/httpserver_methods.py - :emphasize-lines: 8,15 + :emphasize-lines: 8,19,26,30,49 :linenos: Change NeoPixel color @@ -129,29 +140,19 @@ Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py - :emphasize-lines: 25-27,39-40,52-53,61,69 - :linenos: - -Get CPU information -------------------- - -You can return data from sensors or any computed value as JSON. -That makes it easy to use the data in other applications. - -.. literalinclude:: ../examples/httpserver_cpu_information.py - :caption: examples/httpserver_cpu_information.py - :emphasize-lines: 28-29 + :emphasize-lines: 25-27,39,51,60,66 :linenos: Chunked response ---------------- -Library supports chunked responses. This is useful for streaming data. -To use it, you need to set the ``chunked=True`` when creating a ``Response`` object. +Library supports chunked responses. This is useful for streaming large amounts of data. +In order to use it, you need pass a generator that yields chunks of data to a ``ChunkedResponse`` +constructor. .. literalinclude:: ../examples/httpserver_chunked.py :caption: examples/httpserver_chunked.py - :emphasize-lines: 21-26 + :emphasize-lines: 8,21-26,28 :linenos: URL parameters and wildcards @@ -163,7 +164,7 @@ Query/GET parameters are better suited for modifying the behaviour of the handle Of course it is only a suggestion, you can use them interchangeably and/or both at the same time. -In order to use URL parameters, you need to wrap them inside ``<>`` in ``Server.route``, e.g. ````. +In order to use URL parameters, you need to wrap them inside with angle brackets in ``Server.route``, e.g. ````. All URL parameters values are **passed as keyword arguments** to the handler function. @@ -185,7 +186,7 @@ In both cases, wildcards will not match empty path segment, so ``/api/.../users` .. literalinclude:: ../examples/httpserver_url_parameters.py :caption: examples/httpserver_url_parameters.py - :emphasize-lines: 30-34,54-55 + :emphasize-lines: 30-34,53-54 :linenos: Authentication @@ -206,7 +207,21 @@ In both cases you can check if ``request`` is authenticated by calling ``check_a .. literalinclude:: ../examples/httpserver_authentication_handlers.py :caption: examples/httpserver_authentication_handlers.py - :emphasize-lines: 9-15,21-25,33,44,57 + :emphasize-lines: 9-15,21-25,33,47,59 + :linenos: + +Redirects +--------- + +Sometimes you might want to redirect the user to a different URL, either on the same server or on a different one. + +You can do that by returning ``Redirect`` from your handler function. + +You can specify wheter the redirect is permanent or temporary by passing ``permanent=...`` to ``Redirect``. + +.. literalinclude:: ../examples/httpserver_redirects.py + :caption: examples/httpserver_redirects.py + :emphasize-lines: 14-18,26,38 :linenos: Multiple servers @@ -223,7 +238,7 @@ You can share same handler functions between servers or use different ones for e .. literalinclude:: ../examples/httpserver_multiple_servers.py :caption: examples/httpserver_multiple_servers.py - :emphasize-lines: 13-14,17,26,35-36,48-49,54-55 + :emphasize-lines: 13-14,17,25,33-34,45-46,51-52 :linenos: Debug mode @@ -256,8 +271,7 @@ This is the default format of the logs:: {client_ip} -- "{request_method} {path}" {request_size} -- "{response_status}" {response_size} If you need more information about the server or request, or you want it in a different format you can modify -functions at the bottom of ``adafruit_httpserver/server.py`` and ``adafruit_httpserver/response.py`` that -start with ``_debug_...``. +functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. NOTE: *This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.* diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py index d790af7..bfdb5d1 100644 --- a/examples/httpserver_authentication_handlers.py +++ b/examples/httpserver_authentication_handlers.py @@ -32,8 +32,11 @@ def check_if_authenticated(request: Request): """ is_authenticated = check_authentication(request, auths) - with Response(request, content_type="text/plain") as response: - response.send("Authenticated" if is_authenticated else "Not authenticated") + return Response( + request, + body="Authenticated" if is_authenticated else "Not authenticated", + content_type="text/plain", + ) @server.route("/require-or-401") @@ -43,8 +46,7 @@ def require_authentication_or_401(request: Request): """ require_authentication(request, auths) - with Response(request, content_type="text/plain") as response: - response.send("Authenticated") + return Response(request, body="Authenticated", content_type="text/plain") @server.route("/require-or-handle") @@ -56,12 +58,15 @@ def require_authentication_or_manually_handle(request: Request): try: require_authentication(request, auths) - with Response(request, content_type="text/plain") as response: - response.send("Authenticated") + return Response(request, body="Authenticated", content_type="text/plain") except AuthenticationError: - with Response(request, status=UNATUHORIZED_401) as response: - response.send("Not authenticated - Manually handled") + return Response( + request, + body="Not authenticated - Manually handled", + content_type="text/plain", + status=UNATUHORIZED_401, + ) server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_authentication_server.py b/examples/httpserver_authentication_server.py index 4f8c494..e957a41 100644 --- a/examples/httpserver_authentication_server.py +++ b/examples/httpserver_authentication_server.py @@ -25,8 +25,7 @@ def implicit_require_authentication(request: Request): Implicitly require authentication because of the server.require_authentication() call. """ - with Response(request, content_type="text/plain") as response: - response.send("Authenticated") + return Response(request, body="Authenticated", content_type="text/plain") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_chunked.py b/examples/httpserver_chunked.py index 3e990a7..e357fd3 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -5,7 +5,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response +from adafruit_httpserver import Server, Request, ChunkedResponse pool = socketpool.SocketPool(wifi.radio) @@ -18,12 +18,14 @@ def chunked(request: Request): Return the response with ``Transfer-Encoding: chunked``. """ - with Response(request, chunked=True) as response: - response.send_chunk("Adaf") - response.send_chunk("ruit") - response.send_chunk(" Indus") - response.send_chunk("tr") - response.send_chunk("ies") + def body(): + yield "Adaf" + yield b"ruit" # Data chunk can be bytes or str. + yield " Indus" + yield b"tr" + yield "ies" + + return ChunkedResponse(request, body) server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index 68a5dfd..96ed985 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -2,12 +2,11 @@ # # SPDX-License-Identifier: Unlicense -import json import microcontroller import socketpool import wifi -from adafruit_httpserver import Server, Request, Response +from adafruit_httpserver import Server, Request, JSONResponse pool = socketpool.SocketPool(wifi.radio) server = Server(pool, debug=True) @@ -25,8 +24,7 @@ def cpu_information_handler(request: Request): "voltage": microcontroller.cpu.voltage, } - with Response(request, content_type="application/json") as response: - response.send(json.dumps(data)) + return JSONResponse(request, data) server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_handler_serves_file.py b/examples/httpserver_handler_serves_file.py index 4978fa6..8897eaa 100644 --- a/examples/httpserver_handler_serves_file.py +++ b/examples/httpserver_handler_serves_file.py @@ -6,7 +6,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response +from adafruit_httpserver import Server, Request, FileResponse pool = socketpool.SocketPool(wifi.radio) @@ -19,8 +19,7 @@ def home(request: Request): Serves the file /www/home.html. """ - with Response(request, content_type="text/html") as response: - response.send_file("home.html", root_path="/www") + return FileResponse(request, "home.html", "/www") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index 3f3a260..377f957 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -6,7 +6,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response +from adafruit_httpserver import Server, Request, FileResponse mdns_server = mdns.Server(wifi.radio) @@ -22,8 +22,8 @@ def base(request: Request): """ Serve the default index.html file. """ - with Response(request, content_type="text/html") as response: - response.send_file("index.html") + + return FileResponse(request, "index.html", "/www") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py index 92370ca..cb99fc9 100644 --- a/examples/httpserver_methods.py +++ b/examples/httpserver_methods.py @@ -5,12 +5,16 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response, GET, POST, PUT, DELETE +from adafruit_httpserver import Server, Request, JSONResponse, GET, POST, PUT, DELETE pool = socketpool.SocketPool(wifi.radio) server = Server(pool, debug=True) +objects = [ + {"id": 1, "name": "Object 1"}, +] + @server.route("/api", [GET, POST, PUT, DELETE], append_slash=True) def api(request: Request): @@ -18,20 +22,49 @@ def api(request: Request): Performs different operations depending on the HTTP method. """ + # Get objects if request.method == GET: - # Get objects - with Response(request) as response: - response.send("Objects: ...") + return JSONResponse(request, objects) + # Upload or update objects if request.method in [POST, PUT]: - # Upload or update objects - with Response(request) as response: - response.send("Object uploaded/updated") + uploaded_object = request.json() + + # Find object with same ID + for i, obj in enumerate(objects): + if obj["id"] == uploaded_object["id"]: + objects[i] = uploaded_object + + return JSONResponse( + request, {"message": "Object updated", "object": uploaded_object} + ) + + # If not found, add it + objects.append(uploaded_object) + return JSONResponse( + request, {"message": "Object added", "object": uploaded_object} + ) + # Delete objects if request.method == DELETE: - # Delete objects - with Response(request) as response: - response.send("Object deleted") + deleted_object = request.json() + + # Find object with same ID + for i, obj in enumerate(objects): + if obj["id"] == deleted_object["id"]: + del objects[i] + + return JSONResponse( + request, {"message": "Object deleted", "object": deleted_object} + ) + + # If not found, return error + return JSONResponse( + request, {"message": "Object not found", "object": deleted_object} + ) + + # If we get here, something went wrong + return JSONResponse(request, {"message": "Something went wrong"}) server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py index ebf8426..1ac3ed4 100644 --- a/examples/httpserver_multiple_servers.py +++ b/examples/httpserver_multiple_servers.py @@ -17,29 +17,26 @@ @bedroom_server.route("/bedroom") def bedroom(request: Request): """ - This route is registered only with ``bedroom_server``. + This route is registered only on ``bedroom_server``. """ - with Response(request) as response: - response.send("Hello from the bedroom!") + return Response(request, "Hello from the bedroom!") @office_server.route("/office") def office(request: Request): """ - This route is registered only with ``office_server``. + This route is registered only on ``office_server``. """ - with Response(request) as response: - response.send("Hello from the office!") + return Response(request, "Hello from the office!") @bedroom_server.route("/home") @office_server.route("/home") def home(request: Request): """ - This route is registered with both servers. + This route is registered on both servers. """ - with Response(request) as response: - response.send("Hello from home!") + return Response(request, "Hello from home!") id_address = str(wifi.radio.ipv4_address) diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 4694f31..7e7dab3 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -28,8 +28,7 @@ def change_neopixel_color_handler_query_params(request: Request): pixel.fill((int(r), int(g), int(b))) - with Response(request, content_type="text/plain") as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") @server.route("/change-neopixel-color", POST) @@ -41,8 +40,7 @@ def change_neopixel_color_handler_post_body(request: Request): pixel.fill((int(r), int(g), int(b))) - with Response(request, content_type="text/plain") as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") @server.route("/change-neopixel-color/json", POST) @@ -54,8 +52,7 @@ def change_neopixel_color_handler_post_json(request: Request): pixel.fill((r, g, b)) - with Response(request, content_type="text/plain") as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") @server.route("/change-neopixel-color///", GET) @@ -68,8 +65,7 @@ def change_neopixel_color_handler_url_params( pixel.fill((int(r), int(g), int(b))) - with Response(request, content_type="text/plain") as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_redirects.py b/examples/httpserver_redirects.py new file mode 100644 index 0000000..92fea4b --- /dev/null +++ b/examples/httpserver_redirects.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, Redirect, NOT_FOUND_404 + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +REDIRECTS = { + "google": "https://www.google.com", + "adafruit": "https://www.adafruit.com", + "circuitpython": "https://circuitpython.org", +} + + +@server.route("/blinka") +def redirect_blinka(request: Request): + """ + Always redirect to a Blinka page as permanent redirect. + """ + return Redirect(request, "https://circuitpython.org/blinka", permanent=True) + + +@server.route("/") +def redirect_other(request: Request, slug: str = None): + """ + Redirect to a URL based on the slug. + """ + + if slug is None or not slug in REDIRECTS: + return Response(request, text="Unknown redirect", status=NOT_FOUND_404) + + return Redirect(request, REDIRECTS.get(slug)) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_auto.py b/examples/httpserver_simpletest_auto.py index db006ed..a1f8192 100644 --- a/examples/httpserver_simpletest_auto.py +++ b/examples/httpserver_simpletest_auto.py @@ -17,9 +17,7 @@ def base(request: Request): """ Serve a default static plain text message. """ - with Response(request, content_type="text/plain") as response: - message = "Hello from the CircuitPython HTTP Server!" - response.send(message) + return Response(request, "Hello from the CircuitPython HTTP Server!") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_manual.py b/examples/httpserver_simpletest_manual.py index e8b23b0..df28c6f 100644 --- a/examples/httpserver_simpletest_manual.py +++ b/examples/httpserver_simpletest_manual.py @@ -25,9 +25,7 @@ def base(request: Request): """ Serve a default static plain text message. """ - with Response(request, content_type="text/plain") as response: - message = "Hello from the CircuitPython HTTP Server!" - response.send(message) + return Response(request, "Hello from the CircuitPython HTTP Server!") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_start_and_poll.py b/examples/httpserver_start_and_poll.py index e1b5a56..340316a 100644 --- a/examples/httpserver_start_and_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -5,7 +5,7 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, Response +from adafruit_httpserver import Server, Request, FileResponse pool = socketpool.SocketPool(wifi.radio) @@ -17,8 +17,7 @@ def base(request: Request): """ Serve the default index.html file. """ - with Response(request, content_type="text/html") as response: - response.send_file("index.html") + return FileResponse(request, "index.html") # Start the server. diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index 412d04c..9905bc4 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -43,12 +43,11 @@ def perform_action( elif action in ["turn_off", "emergency_power_off"]: device.turn_off() else: - with Response(request, content_type="text/plain") as response: - response.send(f"Unknown action ({action})") - return + return Response(request, f"Unknown action ({action})") - with Response(request, content_type="text/plain") as response: - response.send(f"Action ({action}) performed on device with ID: {device_id}") + return Response( + request, f"Action ({action}) performed on device with ID: {device_id}" + ) @server.route("/device/.../status", append_slash=True) @@ -59,8 +58,7 @@ def device_status(request: Request): Unknown commands also return the status of all devices. """ - with Response(request, content_type="text/plain") as response: - response.send("Status of all devices: ...") + return Response(request, "Status of all devices: ...") server.serve_forever(str(wifi.radio.ipv4_address)) From 90ce5368b43dfd50506ede0f4640b28e1c48c050 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 8 May 2023 22:41:33 +0000 Subject: [PATCH 41/44] Minor refactor of sending response, modified guard --- adafruit_httpserver/server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 89fca4d..def6094 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -326,12 +326,14 @@ def poll(self): # Handle the request response = self._handle_request(request, handler) + if response is None: + return + # Send the response - if response is not None: - response._send() # pylint: disable=protected-access + response._send() # pylint: disable=protected-access - if self.debug: - _debug_response_sent(response) + if self.debug: + _debug_response_sent(response) except Exception as error: # pylint: disable=broad-except if isinstance(error, OSError): From 85b452b95b9aa7141206f276105d99914c51477e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 22 May 2023 14:52:01 +0000 Subject: [PATCH 42/44] Fix: Incorrect parsing of default route methods --- adafruit_httpserver/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index def6094..b0d1515 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -115,7 +115,7 @@ def route_func(request): if path.endswith("/") and append_slash: raise ValueError("Cannot use append_slash=True when path ends with /") - methods = methods if isinstance(methods, set) else set(methods) + 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) From 4ad7995386cc5d8b051a455580d10660909ffc84 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 22 May 2023 15:22:48 +0000 Subject: [PATCH 43/44] Added warning about exposing files and some docstrings --- adafruit_httpserver/server.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index b0d1515..1666df5 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -36,7 +36,10 @@ class Server: """A basic socket-based HTTP server.""" host: str = None + """Host name or IP address the server is listening on.""" + port: int = None + """Port the server is listening on.""" def __init__( self, socket_source: Protocol, root_path: str = None, *, debug: bool = False @@ -55,6 +58,8 @@ def __init__( self._socket_source = socket_source self._sock = None self.root_path = root_path + if root_path in ["", "/"] and debug: + _debug_warning_exposed_files(root_path) self.stopped = False self.debug = debug @@ -409,6 +414,15 @@ def socket_timeout(self, value: int) -> None: raise ValueError("Server.socket_timeout must be a positive numeric value.") +def _debug_warning_exposed_files(root_path: str): + """Warns about exposing all files on the device.""" + print( + f"WARNING: Setting root_path to '{root_path}' will expose all files on your device through" + " the webserver, including potentially sensitive files like settings.toml or secrets.py. " + "Consider making a sub-directory on your device and using that for your root_path instead." + ) + + def _debug_started_server(server: "Server"): """Prints a message when the server starts.""" host, port = server.host, server.port From f59d10c85205b54aa18025cdc3f76e99ac9cadcf Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 22 May 2023 15:55:35 +0000 Subject: [PATCH 44/44] Fix: Removed text keyword from Response --- examples/httpserver_redirects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/httpserver_redirects.py b/examples/httpserver_redirects.py index 92fea4b..3278f25 100644 --- a/examples/httpserver_redirects.py +++ b/examples/httpserver_redirects.py @@ -33,7 +33,7 @@ def redirect_other(request: Request, slug: str = None): """ if slug is None or not slug in REDIRECTS: - return Response(request, text="Unknown redirect", status=NOT_FOUND_404) + return Response(request, "Unknown redirect", status=NOT_FOUND_404) return Redirect(request, REDIRECTS.get(slug))