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 fb2966f..cb152b2 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -5,10 +5,10 @@ `adafruit_httpserver` ================================================================================ -Simple HTTP Server for CircuitPython +Socket based HTTP Server for CircuitPython -* Author(s): Dan Halbert +* Author(s): Dan Halbert, Michał Pokusa Implementation Notes -------------------- @@ -21,3 +21,61 @@ __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 ( + ServerStoppedError, + AuthenticationError, + InvalidPathError, + ParentDirectoryReferenceError, + BackslashInPathError, + ServingFilesDisabledError, + FileNotExistsError, +) +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, + FileResponse, + ChunkedResponse, + JSONResponse, + Redirect, +) +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, +) diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py new file mode 100644 index 0000000..211ab63 --- /dev/null +++ b/adafruit_httpserver/authentication.py @@ -0,0 +1,64 @@ +# 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 Request + + +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: Request, 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: Request, 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..52f000f 100644 --- a/adafruit_httpserver/exceptions.py +++ b/adafruit_httpserver/exceptions.py @@ -8,6 +8,18 @@ """ +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. + """ + + class InvalidPathError(Exception): """ Parent class for all path related errors. @@ -34,9 +46,9 @@ def __init__(self, path: str) -> None: super().__init__(f"Backslash in path: {path}") -class ResponseAlreadySentError(Exception): +class ServingFilesDisabledError(Exception): """ - Another ``HTTPResponse`` has already been sent. There can only be one per ``HTTPRequest``. + Raised when ``root_path`` is not set and there is no handler for ``request``. """ diff --git a/adafruit_httpserver/headers.py b/adafruit_httpserver/headers.py index c434794..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 @@ -80,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..450b770 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -2,38 +2,26 @@ # # 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 = "GET" - """GET method.""" +POST = "POST" - POST = "POST" - """POST method.""" +PUT = "PUT" - PUT = "PUT" - """PUT method""" +DELETE = "DELETE" - DELETE = "DELETE" - """DELETE method""" +PATCH = "PATCH" - PATCH = "PATCH" - """PATCH method""" +HEAD = "HEAD" - HEAD = "HEAD" - """HEAD method""" +OPTIONS = "OPTIONS" - OPTIONS = "OPTIONS" - """OPTIONS method""" +TRACE = "TRACE" - TRACE = "TRACE" - """TRACE method""" - - CONNECT = "CONNECT" - """CONNECT method""" +CONNECT = "CONNECT" 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..2bff2bf --- /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" + """ + Default MIME type for unknown files. + Can be changed using ``MIMETypes.configure(default_to=...)``. + """ + + 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**. + """ + + 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. + + :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. + + 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( + 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. + 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 + + try: + extension = filename.rsplit(".", 1)[-1].lower() + return cls.REGISTERED.get(f".{extension}", default) + except IndexError: + return default diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 908b462..e911570 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -2,30 +2,40 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.request.HTTPRequest` +`adafruit_httpserver.request` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ 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 -from .headers import HTTPHeaders +import json + +from .headers import Headers -class HTTPRequest: +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. + """ + + server: "Server" + """ + Server object that received the request. """ 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 +52,7 @@ class HTTPRequest: """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] """ @@ -50,32 +60,34 @@ 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"} """ http_version: str - """HTTP version, e.g. "HTTP/1.1".""" + """HTTP version, e.g. ``"HTTP/1.1"``.""" - headers: HTTPHeaders + headers: Headers """ Headers from the 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. """ 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 @@ -83,7 +95,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 +111,29 @@ 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 + + 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 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]: @@ -139,11 +159,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..0310f90 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -2,156 +2,89 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.response.HTTPResponse` +`adafruit_httpserver.response` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Optional, Dict, Union, Tuple, Callable + 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_type import MIMEType -from .request import HTTPRequest -from .status import HTTPStatus, CommonHTTPStatus -from .headers import HTTPHeaders +from .mime_types import MIMETypes +from .request import Request +from .status import Status, OK_200, TEMPORARY_REDIRECT_307, PERMANENT_REDIRECT_308 +from .headers import Headers -def _prevent_multiple_send_calls(function: Callable): +class Response: # pylint: disable=too-few-public-methods """ - Decorator that prevents calling ``send`` or ``send_file`` more than once. - """ - - def wrapper(self: "HTTPResponse", *args, **kwargs): - if self._response_already_sent: # pylint: disable=protected-access - raise ResponseAlreadySentError - - result = function(self, *args, **kwargs) - return result - - return wrapper - + Response to a given `Request`. Use in `Server.route` handler functions. -class HTTPResponse: - """ - Response to a given `HTTPRequest`. Use in `HTTPServer.route` decorator functions. + Base class for all other response classes. Example:: - # Response with 'Content-Length' header @server.route(path, method) - def route_func(request): - - response = HTTPResponse(request) - response.send("Some content", content_type="text/plain") - - # or - - response = HTTPResponse(request) - with response: - response.send(body='Some content', content_type="text/plain") - - # or + def route_func(request: Request): - with HTTPResponse(request) as response: - response.send("Some content", content_type="text/plain") - - # Response with 'Transfer-Encoding: chunked' header - @server.route(path, method) - def route_func(request): - - response = HTTPResponse(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: - response.send_chunk("Some content") - response.send_chunk("Some more content") - """ - - request: HTTPRequest - """The request that this is a response to.""" - - http_version: str - status: HTTPStatus - headers: HTTPHeaders - 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()``. - - Common MIME types are defined in `adafruit_httpserver.mime_type.MIMEType`. + return Response(request, body='Some content', content_type="text/plain") """ 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, + 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 HTTPRequest(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, HTTPStatus) else HTTPStatus(*status) - self.headers = ( - headers.copy() if isinstance(headers, HTTPHeaders) else HTTPHeaders(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 HTTPResponse(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 MIMEType.TYPE_TXT + "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(): @@ -159,38 +92,114 @@ 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 _send(self) -> None: + encoded_body = ( + self._body.encode("utf-8") if isinstance(self._body, str) else self._body ) - @_prevent_multiple_send_calls - def send( + self._send_headers(len(encoded_body), self._content_type) + self._send_bytes(self._request.connection, encoded_body) + + def _send_bytes( self, - body: str = "", + 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. """ + if safe: + self._verify_file_path_is_valid(filename) - if getattr(body, "encode", None): - encoded_response_message_body = body.encode("utf-8") - else: - encoded_response_message_body = body - - 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 + 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`` is valid. + Verifies that ``file_path`` does not contain backslashes or parent directory references. + If not raises error corresponding to the problem. """ @@ -203,6 +212,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: """ @@ -210,93 +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 - @_prevent_multiple_send_calls - 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 = "./", - 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. """ - 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 - if not root_path.endswith("/"): - root_path += "/" - if filename.startswith("/"): - filename = filename[1:] + def _send_chunk(self, chunk: Union[str, bytes] = "") -> None: + encoded_chunk = chunk.encode("utf-8") if isinstance(chunk, str) else chunk - full_file_path = root_path + filename + 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") - file_length = self._get_file_length(full_file_path) + def _send(self) -> None: + self._send_headers() - self._send_headers( - content_type=MIMEType.from_file_name(filename), - content_length=file_length, - ) + for chunk in self._body(): + self._send_chunk(chunk) - 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 + # 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 HTTPResponse(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. + + 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): - :param str chunk: String data to be sent. + return JSONResponse(request, {"key": "value"}) + """ + + 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. """ - if getattr(chunk, "encode", None): - chunk = chunk.encode("utf-8") + 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("") - return True +class Redirect(Response): # pylint: disable=too-few-public-methods + """ + Specialized version of `Response` class for redirecting to another URL. - @staticmethod - def _send_bytes( - conn: Union["SocketPool.Socket", "socket.socket"], - buffer: Union[bytes, bytearray, memoryview], - ): - bytes_sent = 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 + Instead of requiring the body to be passed to the constructor, it expects a URL to redirect to. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return Redirect(request, "https://www.example.com") + """ + + 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}) + + def _send(self) -> None: + self._send_headers() diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index ff8aa44..96750f4 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -2,34 +2,52 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.route._HTTPRoute` +`adafruit_httpserver.route` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Callable, List, Union, Tuple + from typing import Callable, List, Set, Union, Tuple, TYPE_CHECKING + + if TYPE_CHECKING: + from .response import Response except ImportError: pass import re -from .methods import HTTPMethod +from .methods import GET + + +class _Route: + """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" + def __init__( + self, + path: str = "", + methods: Union[str, Set[str]] = GET, + append_slash: bool = False, + ) -> None: + self._validate_path(path) -class _HTTPRoute: - """Route definition for different paths, see `adafruit_httpserver.server.HTTPServer.route`.""" + self.parameters_names = [ + name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" + ] + 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} - def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: - contains_parameters = re.search(r"<\w*>", path) is not None + @staticmethod + def _validate_path(path: str) -> None: + if not path.startswith("/"): + raise ValueError("Path must start with a slash.") - self.path = ( - path if not contains_parameters else re.sub(r"<\w*>", r"([^/]*)", path) - ) - self.method = method - self._contains_parameters = contains_parameters + if "<>" in path: + raise ValueError("All URL parameters must be named.") - 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. @@ -41,55 +59,67 @@ def match(self, other: "_HTTPRoute") -> Tuple[bool, List[str]]: Examples:: - route = _HTTPRoute("/example", HTTPMethod.GET) + route = _Route("/example", GET, True) - other1 = _HTTPRoute("/example", HTTPMethod.GET) - route.matches(other1) # True, [] + other1a = _Route("/example", GET) + other1b = _Route("/example/", GET) + route.matches(other1a) # True, [] + route.matches(other1b) # 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, [] + + ... + + 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 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(self.path, other.path) + regex_match = re.match(f"^{self.path}$", other.path) if regex_match is None: return False, [] return True, regex_match.groups() def __repr__(self) -> str: - return f"_HTTPRoute(path={repr(self.path)}, method={repr(self.method)})" + path = repr(self.path) + methods = repr(self.methods) + + return f"_Route(path={path}, methods={methods})" -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["...", "Response"], None]: """ Finds a handler for a given route. @@ -98,19 +128,16 @@ 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 my_parameter == "123" # True """ - if not self._routes: - return None - 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,10 +148,12 @@ 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 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 a45eb1a..1666df5 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -2,97 +2,225 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.server.HTTPServer` +`adafruit_httpserver.server` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Callable, Protocol, Union + from typing import Callable, Protocol, Union, List, Set, Tuple from socket import socket from socketpool import SocketPool except ImportError: pass from errno import EAGAIN, ECONNRESET, ETIMEDOUT +from traceback import print_exception + +from .authentication import Basic, Bearer, require_authentication +from .exceptions import ( + ServerStoppedError, + AuthenticationError, + FileNotExistsError, + InvalidPathError, + ServingFilesDisabledError, +) +from .methods import GET, HEAD +from .request import Request +from .response import Response, FileResponse +from .route import _Routes, _Route +from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 + + +class Server: + """A basic socket-based HTTP server.""" -from .exceptions import FileNotExistsError, InvalidPathError -from .methods import HTTPMethod -from .request import HTTPRequest -from .response import HTTPResponse -from .route import _HTTPRoutes, _HTTPRoute -from .status import CommonHTTPStatus - + host: str = None + """Host name or IP address the server is listening on.""" -class HTTPServer: - """A basic socket-based HTTP server.""" + port: int = None + """Port the server is listening on.""" - def __init__(self, socket_source: Protocol, root_path: str) -> 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) self._timeout = 1 - self.routes = _HTTPRoutes() + self._routes = _Routes() 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 - def route(self, path: str, method: HTTPMethod = HTTPMethod.GET) -> Callable: + self.debug = debug + + 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 HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc. + :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:: - @server.route("/example", HTTPMethod.GET) + # Default method is GET + @server.route("/example") + def route_func(request): + ... + + # It is necessary to specify other methods like POST, PUT, etc. + @server.route("/example", POST) + 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): ... - @server.route("/example/", HTTPMethod.GET) + # URL parameters can be specified + @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 /") + + methods = set(methods) if isinstance(methods, (set, list)) else set([methods]) def route_decorator(func: Callable) -> Callable: - self.routes.add(_HTTPRoute(path, method), func) + self._routes.add(_Route(path, methods, append_slash), func) return func 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. + """ + 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 True: + while not self.stopped: try: self.poll() - except OSError: - continue + 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: """ 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._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 ) self._sock.bind((host, port)) self._sock.listen(10) - self._sock.setblocking(False) # non-blocking socket + 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. + 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() + + if self.debug: + _debug_stopped_server(self) + + 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(self, 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"] @@ -130,80 +258,113 @@ def _receive_body_bytes( raise ex return received_body_bytes[:content_length] + def _handle_request( + self, request: Request, handler: Union[Callable, None] + ) -> Union[Response, 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): + return handler(request) + + # No root_path, access to filesystem disabled, return 404. + if self.root_path is None: + raise ServingFilesDisabledError + + # 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, + ) + + return Response(request, status=BAD_REQUEST_400) + + except AuthenticationError: + return Response( + request, + status=UNAUTHORIZED_401, + headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, + ) + + except InvalidPathError as error: + return Response( + request, + str(error) if self.debug else "Invalid path", + status=FORBIDDEN_403, + ) + + except (FileNotExistsError, ServingFilesDisabledError) as error: + return Response( + request, + str(error) if self.debug else "File not found", + status=NOT_FOUND_404, + ) + 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. """ + if self.stopped: + raise ServerStoppedError + try: conn, client_address = self._sock.accept() 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 = HTTPRequest(conn, client_address, header_bytes) + # Find a handler for the route + handler = self._routes.find_handler( + _Route(request.path, request.method) + ) - content_length = int(request.headers.get("Content-Length", 0)) - received_body_bytes = request.body + # Handle the request + response = self._handle_request(request, handler) - # Receiving remaining body bytes - request.body = self._receive_body_bytes( - conn, received_body_bytes, content_length - ) + if response is None: + return - # Find a handler for the route - handler = self.routes.find_handler( - _HTTPRoute(request.path, request.method) - ) + # Send the response + response._send() # pylint: disable=protected-access - try: - # 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 ( - HTTPMethod.GET, - HTTPMethod.HEAD, - ): - filename = "index.html" if request.path == "/" else request.path - HTTPResponse(request).send_file( - filename=filename, - root_path=self.root_path, - buffer_size=self.request_buffer_size, - head_only=(request.method == HTTPMethod.HEAD), - ) - else: - HTTPResponse( - request, status=CommonHTTPStatus.BAD_REQUEST_400 - ).send() - - except InvalidPathError as error: - HTTPResponse(request, status=CommonHTTPStatus.FORBIDDEN_403).send( - str(error) - ) - - except FileNotExistsError as error: - HTTPResponse(request, status=CommonHTTPStatus.NOT_FOUND_404).send( - str(error) - ) + if self.debug: + _debug_response_sent(response) - except OSError as error: - # Handle EAGAIN and ECONNRESET - if error.errno == EAGAIN: + except Exception as error: # pylint: disable=broad-except + if isinstance(error, OSError): # There is no data available right now, try again later. - return - if error.errno == ECONNRESET: + if error.errno == EAGAIN: + return # Connection reset by peer, try again later. - return - raise + 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``. + Any non-authenticated request will be rejected with a 401 status code. + + Example:: + + server = Server(pool, "/static") + server.require_authentication([Basic("user", "pass")]) + """ + self._auths = auths @property def request_buffer_size(self) -> int: @@ -216,7 +377,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)) @@ -238,7 +399,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)) @@ -250,6 +411,43 @@ 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.") + + +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 + + 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") + + +def _debug_exception_in_handler(error: Exception): + """Prints a message when an exception is raised in a handler.""" + print_exception(error) diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 8a7b198..57ae47a 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,29 +22,43 @@ 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 -class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods - """Common HTTP status codes.""" +OK_200 = Status(200, "OK") + +CREATED_201 = Status(201, "Created") + +ACCEPTED_202 = Status(202, "Accepted") + +NO_CONTENT_204 = Status(204, "No Content") + +PARTIAL_CONTENT_206 = Status(206, "Partial Content") + +TEMPORARY_REDIRECT_307 = Status(307, "Temporary Redirect") + +PERMANENT_REDIRECT_308 = Status(308, "Permanent Redirect") + +BAD_REQUEST_400 = Status(400, "Bad Request") + +UNAUTHORIZED_401 = Status(401, "Unauthorized") + +FORBIDDEN_403 = Status(403, "Forbidden") + +NOT_FOUND_404 = Status(404, "Not Found") - OK_200 = HTTPStatus(200, "OK") - """200 OK""" +METHOD_NOT_ALLOWED_405 = Status(405, "Method Not Allowed") - BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") - """400 Bad Request""" +TOO_MANY_REQUESTS_429 = Status(429, "Too Many Requests") - FORBIDDEN_403 = HTTPStatus(403, "Forbidden") - """403 Forbidden""" +INTERNAL_SERVER_ERROR_500 = Status(500, "Internal Server Error") - NOT_FOUND_404 = HTTPStatus(404, "Not Found") - """404 Not Found""" +NOT_IMPLEMENTED_501 = Status(501, "Not Implemented") - INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") - """500 Internal Server Error""" +SERVICE_UNAVAILABLE_503 = Status(503, "Service Unavailable") diff --git a/docs/api.rst b/docs/api.rst index 64bb534..a8fad68 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -22,10 +22,7 @@ .. automodule:: adafruit_httpserver.status :members: -.. 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..683c094 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,12 +1,70 @@ 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. + +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: -Serving a simple static text message. +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. -.. literalinclude:: ../examples/httpserver_simpletest.py - :caption: examples/httpserver_simpletest.py +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 + :lines: 5- + :linenos: + +Note that we still need to import ``socketpool`` and ``wifi`` modules. + +.. 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 + :emphasize-lines: 12-18,23-26 + :linenos: + +You can also serve a specific file from the handler. +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 + :linenos: + +.. literalinclude:: ../examples/home.html + :language: html + :caption: www/home.html + :lines: 5- + :linenos: + +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. @@ -14,27 +72,67 @@ 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 + :emphasize-lines: 24,33 :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/``. .. literalinclude:: ../examples/httpserver_mdns.py :caption: examples/httpserver_mdns.py + :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 +--------------------------------------- + +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. + +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 + :emphasize-lines: 8,19,26,30,49 :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. +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. @@ -42,36 +140,33 @@ Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py - :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: 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 ``HTTPResponse`` 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: 8,21-26,28 :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. +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 ``HTTPServer.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 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 +175,103 @@ 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. +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: -Although it is possible, it makes more sense be consistent with the names of the parameters in the route and in the handler function. +- ``...`` - 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,53-54 + :linenos: + +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. + +.. 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. +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 + :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 +---------------- + +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. +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,25,33-34,45-46,51-52 + :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, 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 -- "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 -- "200 OK" 154 + Stopped development server + +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`` 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/home.html b/examples/home.html new file mode 100644 index 0000000..2a17081 --- /dev/null +++ b/examples/home.html @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + + + + + + + Adafruit HTTPServer + + +

Hello from the CircuitPython HTTP Server!

+ + diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py new file mode 100644 index 0000000..bfdb5d1 --- /dev/null +++ b/examples/httpserver_authentication_handlers.py @@ -0,0 +1,72 @@ +# 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, debug=True) + +# 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) + + return Response( + request, + body="Authenticated" if is_authenticated else "Not authenticated", + content_type="text/plain", + ) + + +@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) + + return Response(request, body="Authenticated", content_type="text/plain") + + +@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) + + return Response(request, body="Authenticated", content_type="text/plain") + + except AuthenticationError: + 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 new file mode 100644 index 0000000..e957a41 --- /dev/null +++ b/examples/httpserver_authentication_server.py @@ -0,0 +1,31 @@ +# 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", debug=True) +server.require_authentication(auths) + + +@server.route("/implicit-require") +def implicit_require_authentication(request: Request): + """ + Implicitly require authentication because of the server.require_authentication() call. + """ + + 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 ed67fc6..e357fd3 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -2,40 +2,30 @@ # # 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 - +from adafruit_httpserver import Server, Request, ChunkedResponse -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") - -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, debug=True) @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: - 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) -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 41d7a05..96ed985 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -2,32 +2,18 @@ # # 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, JSONResponse pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") +server = Server(pool, debug=True) -@server.route("/cpu-information") -def cpu_information_handler(request: HTTPRequest): +@server.route("/cpu-information", append_slash=True) +def cpu_information_handler(request: Request): """ Return the current CPU temperature, frequency, and voltage as JSON. """ @@ -38,9 +24,7 @@ def cpu_information_handler(request: HTTPRequest): "voltage": microcontroller.cpu.voltage, } - with HTTPResponse(request, content_type=MIMEType.TYPE_JSON) as response: - response.send(json.dumps(data)) + return JSONResponse(request, 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 new file mode 100644 index 0000000..8897eaa --- /dev/null +++ b/examples/httpserver_handler_serves_file.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, FileResponse + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/home") +def home(request: Request): + """ + Serves the file /www/home.html. + """ + + 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 bebdc2a..377f957 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -2,41 +2,28 @@ # # 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 - +from adafruit_httpserver import Server, Request, FileResponse -ssid = os.getenv("WIFI_SSID") -password = os.getenv("WIFI_PASSWORD") - -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", debug=True) @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: - response.send_file("index.html") + + return FileResponse(request, "index.html", "/www") -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 new file mode 100644 index 0000000..cb99fc9 --- /dev/null +++ b/examples/httpserver_methods.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +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): + """ + Performs different operations depending on the HTTP method. + """ + + # Get objects + if request.method == GET: + return JSONResponse(request, objects) + + # Upload or update objects + if request.method in [POST, PUT]: + 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: + 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 new file mode 100644 index 0000000..1ac3ed4 --- /dev/null +++ b/examples/httpserver_multiple_servers.py @@ -0,0 +1,55 @@ +# 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", debug=True) +office_server = Server(pool, "/office", debug=True) + + +@bedroom_server.route("/bedroom") +def bedroom(request: Request): + """ + This route is registered only on ``bedroom_server``. + """ + return Response(request, "Hello from the bedroom!") + + +@office_server.route("/office") +def office(request: Request): + """ + This route is registered only on ``office_server``. + """ + return Response(request, "Hello from the office!") + + +@bedroom_server.route("/home") +@office_server.route("/home") +def home(request: Request): + """ + This route is registered on both servers. + """ + return Response(request, "Hello from home!") + + +id_address = str(wifi.radio.ipv4_address) + +# Start the servers. +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 diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index baff3de..7e7dab3 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -2,59 +2,70 @@ # # 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, GET, POST -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", debug=True) pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) -@server.route("/change-neopixel-color") -def change_neopixel_color_handler_query_params(request: HTTPRequest): - """ - 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") +@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.""" + + # 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))) + + return Response(request, 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.""" - pixel.fill((int(r or 0), int(g or 0), int(b or 0))) + data = request.body # e.g b"255,0,0" + r, g, b = data.decode().split(",") # ["255", "0", "0"] - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + pixel.fill((int(r), int(g), int(b))) + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color///") + +@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)) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + +@server.route("/change-neopixel-color///", GET) def change_neopixel_color_handler_url_params( - request: HTTPRequest, r: str, g: str, b: str + request: Request, r: str = "0", g: str = "0", b: str = "0" ): - """ - 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))) + """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 HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") + return Response(request, 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_redirects.py b/examples/httpserver_redirects.py new file mode 100644 index 0000000..3278f25 --- /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, "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.py b/examples/httpserver_simpletest.py deleted file mode 100644 index c04ce2d..0000000 --- a/examples/httpserver_simpletest.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries -# -# 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") - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) - -pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool, "/static") - - -@server.route("/") -def base(request: HTTPRequest): - """ - Serve a default static plain text message. - """ - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - message = "Hello from the CircuitPython HTTPServer!" - 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_auto.py b/examples/httpserver_simpletest_auto.py new file mode 100644 index 0000000..a1f8192 --- /dev/null +++ b/examples/httpserver_simpletest_auto.py @@ -0,0 +1,23 @@ +# 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", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text 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 new file mode 100644 index 0000000..df28c6f --- /dev/null +++ b/examples/httpserver_simpletest_manual.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import os + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response + +ssid = os.getenv("WIFI_SSID") +password = os.getenv("WIFI_PASSWORD") + +print("Connecting to", ssid) +wifi.radio.connect(ssid, password) +print("Connected to", ssid) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simple_poll.py b/examples/httpserver_start_and_poll.py similarity index 50% rename from examples/httpserver_simple_poll.py rename to examples/httpserver_start_and_poll.py index 1ed5027..340316a 100644 --- a/examples/httpserver_simple_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -2,39 +2,24 @@ # # 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, FileResponse -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", debug=True) @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: - response.send_file("index.html") + return FileResponse(request, "index.html") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") - # Start the server. server.start(str(wifi.radio.ipv4_address)) @@ -46,6 +31,8 @@ def base(request: HTTPRequest): # 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 diff --git a/examples/httpserver_static_files_serving.py b/examples/httpserver_static_files_serving.py new file mode 100644 index 0000000..ba45f2d --- /dev/null +++ b/examples/httpserver_static_files_serving.py @@ -0,0 +1,28 @@ +# 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", 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. + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index 22f9f3b..9905bc4 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, debug=True) 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,22 @@ 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: - response.send(f"Unknown action ({action})") - return + return Response(request, f"Unknown action ({action})") - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) 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("/something//") -def different_name_parameters( - request: HTTPRequest, - handler_param_1: str, # pylint: disable=unused-argument - handler_param_2: str = None, # pylint: disable=unused-argument -): +@server.route("/device/.../status", append_slash=True) +@server.route("/device/....", append_slash=True) +def device_status(request: Request): """ - Presents that the parameters can be named anything. - - ``route_param_1`` -> ``handler_param_1`` - ``route_param_2`` -> ``handler_param_2`` + Returns the status of all devices no matter what their ID is. + Unknown commands also return the status of all devices. """ - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - response.send("200 OK") + return Response(request, "Status of all devices: ...") -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..ab4da20 --- /dev/null +++ b/examples/settings.toml @@ -0,0 +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"