From 6892a9353549d9822aee609ae75ea48f32952838 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 12 Nov 2022 19:45:51 +0000 Subject: [PATCH 01/25] Splitted adafruit_httpserver into separate files --- adafruit_httpserver.py | 391 ------------------------------- adafruit_httpserver/__init__.py | 23 ++ adafruit_httpserver/mime_type.py | 87 +++++++ adafruit_httpserver/request.py | 26 ++ adafruit_httpserver/response.py | 97 ++++++++ adafruit_httpserver/server.py | 138 +++++++++++ adafruit_httpserver/status.py | 25 ++ 7 files changed, 396 insertions(+), 391 deletions(-) delete mode 100644 adafruit_httpserver.py create mode 100644 adafruit_httpserver/__init__.py create mode 100644 adafruit_httpserver/mime_type.py create mode 100644 adafruit_httpserver/request.py create mode 100644 adafruit_httpserver/response.py create mode 100644 adafruit_httpserver/server.py create mode 100644 adafruit_httpserver/status.py diff --git a/adafruit_httpserver.py b/adafruit_httpserver.py deleted file mode 100644 index ff2f97d..0000000 --- a/adafruit_httpserver.py +++ /dev/null @@ -1,391 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries -# -# SPDX-License-Identifier: MIT -""" -`adafruit_httpserver` -================================================================================ - -Simple HTTP Server for CircuitPython - - -* Author(s): Dan Halbert - -Implementation Notes --------------------- - -**Software and Dependencies:** - -* Adafruit CircuitPython firmware for the supported boards: - https://github.com/adafruit/circuitpython/releases -""" - -try: - from typing import Any, Callable, Optional -except ImportError: - pass - -from errno import EAGAIN, ECONNRESET -import os - -__version__ = "0.0.0+auto.0" -__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" - - -class HTTPStatus: # pylint: disable=too-few-public-methods - """HTTP status codes.""" - - def __init__(self, value, phrase): - """Define a status code. - - :param int value: Numeric value: 200, 404, etc. - :param str phrase: Short phrase: "OK", "Not Found', etc. - """ - self.value = value - self.phrase = phrase - - def __repr__(self): - return f'HTTPStatus({self.value}, "{self.phrase}")' - - def __str__(self): - return f"{self.value} {self.phrase}" - - -HTTPStatus.NOT_FOUND = HTTPStatus(404, "Not Found") -"""404 Not Found""" -HTTPStatus.OK = HTTPStatus(200, "OK") # pylint: disable=invalid-name -"""200 OK""" -HTTPStatus.INTERNAL_SERVER_ERROR = HTTPStatus(500, "Internal Server Error") -"""500 Internal Server Error""" - - -class _HTTPRequest: - def __init__( - self, path: str = "", method: str = "", raw_request: bytes = None - ) -> None: - self.raw_request = raw_request - if raw_request is None: - self.path = path - self.method = method - else: - # Parse request data from raw request - request_text = raw_request.decode("utf8") - first_line = request_text[: request_text.find("\n")] - try: - (self.method, self.path, _httpversion) = first_line.split() - except ValueError as exc: - raise ValueError("Unparseable raw_request: ", raw_request) from exc - - def __hash__(self) -> int: - return hash(self.method) ^ hash(self.path) - - def __eq__(self, other: "_HTTPRequest") -> bool: - return self.method == other.method and self.path == other.path - - def __repr__(self) -> str: - return f"_HTTPRequest(path={repr(self.path)}, method={repr(self.method)})" - - -class MIMEType: - """Common MIME types. - From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - """ - - TEXT_PLAIN = "text/plain" - - _MIME_TYPES = { - "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", - "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", - "gz": "application/gzip", - "gif": "image/gif", - "html": "text/html", - "htm": "text/html", - "ico": "image/vnd.microsoft.icon", - "ics": "text/calendar", - "jar": "application/java-archive", - "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", - "cda": "application/x-cdf", - "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", - "png": "image/png", - "pdf": "application/pdf", - "php": "application/x-httpd-php", - "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", - "tiff": "image/tiff", - "tif": "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", - "7z": "application/x-7z-compressed", - } - - @staticmethod - def mime_type(filename): - """Return the mime type for the given filename. If not known, return "text/plain".""" - return MIMEType._MIME_TYPES.get(filename.split(".")[-1], MIMEType.TEXT_PLAIN) - - -class HTTPResponse: - """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" - - _HEADERS_FORMAT = ( - "HTTP/1.1 {}\r\n" - "Content-Type: {}\r\n" - "Content-Length: {}\r\n" - "Connection: close\r\n" - "\r\n" - ) - - def __init__( - self, - *, - status: tuple = HTTPStatus.OK, - content_type: str = MIMEType.TEXT_PLAIN, - body: str = "", - filename: Optional[str] = None, - root: str = "", - ) -> None: - """Create an HTTP response. - - :param tuple status: The HTTP status code to return, as a tuple of (int, "message"). - Common statuses are available in `HTTPStatus`. - :param str content_type: The MIME type of the data being returned. - Common MIME types are available in `MIMEType`. - :param Union[str|bytes] body: - The data to return in the response body, if ``filename`` is not ``None``. - :param str filename: If not ``None``, - return the contents of the specified file, and ignore ``body``. - :param str root: root directory for filename, without a trailing slash - """ - self.status = status - self.content_type = content_type - self.body = body.encode() if isinstance(body, str) else body - self.filename = filename - - self.root = root - - def send(self, conn: Any) -> None: - # TODO: Use Union[SocketPool.Socket | socket.socket] for the type annotation in some way. - """Send the constructed response over the given socket.""" - if self.filename: - try: - file_length = os.stat(self.root + self.filename)[6] - self._send_file_response(conn, self.filename, self.root, file_length) - except OSError: - self._send_response( - conn, - HTTPStatus.NOT_FOUND, - MIMEType.TEXT_PLAIN, - f"{HTTPStatus.NOT_FOUND} {self.filename}\r\n", - ) - else: - self._send_response(conn, self.status, self.content_type, self.body) - - def _send_response(self, conn, status, content_type, body): - self._send_bytes( - conn, self._HEADERS_FORMAT.format(status, content_type, len(body)) - ) - self._send_bytes(conn, body) - - def _send_file_response(self, conn, filename, root, file_length): - self._send_bytes( - conn, - self._HEADERS_FORMAT.format( - self.status, MIMEType.mime_type(filename), file_length - ), - ) - with open(root + filename, "rb") as file: - while bytes_read := file.read(2048): - self._send_bytes(conn, bytes_read) - - @staticmethod - def _send_bytes(conn, buf): - bytes_sent = 0 - bytes_to_send = len(buf) - view = memoryview(buf) - 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 - - -class HTTPServer: - """A basic socket-based HTTP server.""" - - def __init__(self, socket_source: Any) -> None: - # TODO: Use a Protocol for the type annotation. - # The Protocol could be refactored from adafruit_requests. - """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. - """ - self._buffer = bytearray(1024) - self.routes = {} - self._socket_source = socket_source - self._sock = None - self.root_path = "/" - - def route(self, path: str, method: str = "GET"): - """Decorator used to add a route. - - :param str path: filename path - :param str method: HTTP method: "GET", "POST", etc. - - Example:: - - @server.route(path, method) - def route_func(request): - raw_text = request.raw_request.decode("utf8") - print("Received a request of length", len(raw_text), "bytes") - return HTTPResponse(body="hello world") - - """ - - def route_decorator(func: Callable) -> Callable: - self.routes[_HTTPRequest(path, method)] = func - return func - - return route_decorator - - def serve_forever(self, host: str, port: int = 80, root: str = "") -> None: - """Wait for HTTP requests at the given host and port. Does not return. - - :param str host: host name or IP address - :param int port: port - :param str root: root directory to serve files from - """ - self.start(host, port, root) - - while True: - try: - self.poll() - except OSError: - continue - - def start(self, host: str, port: int = 80, root: str = "") -> None: - """ - Start the HTTP server at the given host and port. Requires calling - poll() in a while loop to handle incoming requests. - - :param str host: host name or IP address - :param int port: port - :param str root: root directory to serve files from - """ - self.root_path = root - - 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 - - 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. - """ - try: - conn, _ = self._sock.accept() - with conn: - conn.setblocking(True) - length, _ = conn.recvfrom_into(self._buffer) - - request = _HTTPRequest(raw_request=self._buffer[:length]) - - # If a route exists for this request, call it. Otherwise try to serve a file. - route = self.routes.get(request, None) - if route: - response = route(request) - elif request.method == "GET": - response = HTTPResponse(filename=request.path, root=self.root_path) - else: - response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) - - response.send(conn) - except OSError as ex: - # handle EAGAIN and ECONNRESET - if ex.errno == EAGAIN: - # there is no data available right now, try again later. - return - if ex.errno == ECONNRESET: - # connection reset by peer, try again later. - return - raise - - @property - def request_buffer_size(self) -> int: - """ - The maximum size of the incoming request buffer. If the default size isn't - adequate to handle your incoming data you can set this after creating the - server instance. - - Default size is 1024 bytes. - - Example:: - - server = HTTPServer(pool) - server.request_buffer_size = 2048 - - server.serve_forever(str(wifi.radio.ipv4_address)) - """ - return len(self._buffer) - - @request_buffer_size.setter - def request_buffer_size(self, value: int) -> None: - self._buffer = bytearray(value) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py new file mode 100644 index 0000000..fb2966f --- /dev/null +++ b/adafruit_httpserver/__init__.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver` +================================================================================ + +Simple HTTP Server for CircuitPython + + +* Author(s): Dan Halbert + +Implementation Notes +-------------------- + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases +""" + +__version__ = "0.0.0+auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py new file mode 100644 index 0000000..f2db201 --- /dev/null +++ b/adafruit_httpserver/mime_type.py @@ -0,0 +1,87 @@ +class MIMEType: + """Common MIME types. + From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + """ + + TEXT_PLAIN = "text/plain" + + _MIME_TYPES = { + "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", + "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", + "gz": "application/gzip", + "gif": "image/gif", + "html": "text/html", + "htm": "text/html", + "ico": "image/vnd.microsoft.icon", + "ics": "text/calendar", + "jar": "application/java-archive", + "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", + "cda": "application/x-cdf", + "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", + "png": "image/png", + "pdf": "application/pdf", + "php": "application/x-httpd-php", + "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", + "tiff": "image/tiff", + "tif": "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", + "7z": "application/x-7z-compressed", + } + + @staticmethod + def mime_type(filename): + """Return the mime type for the given filename. If not known, return "text/plain".""" + return MIMEType._MIME_TYPES.get(filename.split(".")[-1], MIMEType.TEXT_PLAIN) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py new file mode 100644 index 0000000..b554fa5 --- /dev/null +++ b/adafruit_httpserver/request.py @@ -0,0 +1,26 @@ + +class _HTTPRequest: + def __init__( + self, path: str = "", method: str = "", raw_request: bytes = None + ) -> None: + self.raw_request = raw_request + if raw_request is None: + self.path = path + self.method = method + else: + # Parse request data from raw request + request_text = raw_request.decode("utf8") + first_line = request_text[: request_text.find("\n")] + try: + (self.method, self.path, _httpversion) = first_line.split() + except ValueError as exc: + raise ValueError("Unparseable raw_request: ", raw_request) from exc + + def __hash__(self) -> int: + return hash(self.method) ^ hash(self.path) + + def __eq__(self, other: "_HTTPRequest") -> bool: + return self.method == other.method and self.path == other.path + + def __repr__(self) -> str: + return f"_HTTPRequest(path={repr(self.path)}, method={repr(self.method)})" diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py new file mode 100644 index 0000000..8aa7cbe --- /dev/null +++ b/adafruit_httpserver/response.py @@ -0,0 +1,97 @@ +try: + from typing import Any, Optional +except ImportError: + pass + +from errno import EAGAIN, ECONNRESET +import os + +from .mime_type import MIMEType +from .status import HTTPStatus + +class HTTPResponse: + """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" + + _HEADERS_FORMAT = ( + "HTTP/1.1 {}\r\n" + "Content-Type: {}\r\n" + "Content-Length: {}\r\n" + "Connection: close\r\n" + "\r\n" + ) + + def __init__( + self, + *, + status: tuple = HTTPStatus.OK, + content_type: str = MIMEType.TEXT_PLAIN, + body: str = "", + filename: Optional[str] = None, + root: str = "", + ) -> None: + """Create an HTTP response. + + :param tuple status: The HTTP status code to return, as a tuple of (int, "message"). + Common statuses are available in `HTTPStatus`. + :param str content_type: The MIME type of the data being returned. + Common MIME types are available in `MIMEType`. + :param Union[str|bytes] body: + The data to return in the response body, if ``filename`` is not ``None``. + :param str filename: If not ``None``, + return the contents of the specified file, and ignore ``body``. + :param str root: root directory for filename, without a trailing slash + """ + self.status = status + self.content_type = content_type + self.body = body.encode() if isinstance(body, str) else body + self.filename = filename + + self.root = root + + def send(self, conn: Any) -> None: + # TODO: Use Union[SocketPool.Socket | socket.socket] for the type annotation in some way. + """Send the constructed response over the given socket.""" + if self.filename: + try: + file_length = os.stat(self.root + self.filename)[6] + self._send_file_response(conn, self.filename, self.root, file_length) + except OSError: + self._send_response( + conn, + HTTPStatus.NOT_FOUND, + MIMEType.TEXT_PLAIN, + f"{HTTPStatus.NOT_FOUND} {self.filename}\r\n", + ) + else: + self._send_response(conn, self.status, self.content_type, self.body) + + def _send_response(self, conn, status, content_type, body): + self._send_bytes( + conn, self._HEADERS_FORMAT.format(status, content_type, len(body)) + ) + self._send_bytes(conn, body) + + def _send_file_response(self, conn, filename, root, file_length): + self._send_bytes( + conn, + self._HEADERS_FORMAT.format( + self.status, MIMEType.mime_type(filename), file_length + ), + ) + with open(root + filename, "rb") as file: + while bytes_read := file.read(2048): + self._send_bytes(conn, bytes_read) + + @staticmethod + def _send_bytes(conn, buf): + bytes_sent = 0 + bytes_to_send = len(buf) + view = memoryview(buf) + 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 diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py new file mode 100644 index 0000000..cada3f8 --- /dev/null +++ b/adafruit_httpserver/server.py @@ -0,0 +1,138 @@ +try: + from typing import Any, Callable +except ImportError: + pass + +from errno import EAGAIN, ECONNRESET + +from .request import _HTTPRequest +from .response import HTTPResponse +from .status import HTTPStatus + +class HTTPServer: + """A basic socket-based HTTP server.""" + + def __init__(self, socket_source: Any) -> None: + # TODO: Use a Protocol for the type annotation. + # The Protocol could be refactored from adafruit_requests. + """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. + """ + self._buffer = bytearray(1024) + self.routes = {} + self._socket_source = socket_source + self._sock = None + self.root_path = "/" + + def route(self, path: str, method: str = "GET"): + """Decorator used to add a route. + + :param str path: filename path + :param str method: HTTP method: "GET", "POST", etc. + + Example:: + + @server.route(path, method) + def route_func(request): + raw_text = request.raw_request.decode("utf8") + print("Received a request of length", len(raw_text), "bytes") + return HTTPResponse(body="hello world") + + """ + + def route_decorator(func: Callable) -> Callable: + self.routes[_HTTPRequest(path, method)] = func + return func + + return route_decorator + + def serve_forever(self, host: str, port: int = 80, root: str = "") -> None: + """Wait for HTTP requests at the given host and port. Does not return. + + :param str host: host name or IP address + :param int port: port + :param str root: root directory to serve files from + """ + self.start(host, port, root) + + while True: + try: + self.poll() + except OSError: + continue + + def start(self, host: str, port: int = 80, root: str = "") -> None: + """ + Start the HTTP server at the given host and port. Requires calling + poll() in a while loop to handle incoming requests. + + :param str host: host name or IP address + :param int port: port + :param str root: root directory to serve files from + """ + self.root_path = root + + 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 + + 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. + """ + try: + conn, _ = self._sock.accept() + with conn: + conn.setblocking(True) + length, _ = conn.recvfrom_into(self._buffer) + + request = _HTTPRequest(raw_request=self._buffer[:length]) + + # If a route exists for this request, call it. Otherwise try to serve a file. + route = self.routes.get(request, None) + if route: + response = route(request) + elif request.method == "GET": + response = HTTPResponse(filename=request.path, root=self.root_path) + else: + response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + response.send(conn) + except OSError as ex: + # handle EAGAIN and ECONNRESET + if ex.errno == EAGAIN: + # there is no data available right now, try again later. + return + if ex.errno == ECONNRESET: + # connection reset by peer, try again later. + return + raise + + @property + def request_buffer_size(self) -> int: + """ + The maximum size of the incoming request buffer. If the default size isn't + adequate to handle your incoming data you can set this after creating the + server instance. + + Default size is 1024 bytes. + + Example:: + + server = HTTPServer(pool) + server.request_buffer_size = 2048 + + server.serve_forever(str(wifi.radio.ipv4_address)) + """ + return len(self._buffer) + + @request_buffer_size.setter + def request_buffer_size(self, value: int) -> None: + self._buffer = bytearray(value) diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py new file mode 100644 index 0000000..2433fe6 --- /dev/null +++ b/adafruit_httpserver/status.py @@ -0,0 +1,25 @@ +class HTTPStatus: # pylint: disable=too-few-public-methods + """HTTP status codes.""" + + def __init__(self, value, phrase): + """Define a status code. + + :param int value: Numeric value: 200, 404, etc. + :param str phrase: Short phrase: "OK", "Not Found', etc. + """ + self.value = value + self.phrase = phrase + + def __repr__(self): + return f'HTTPStatus({self.value}, "{self.phrase}")' + + def __str__(self): + return f"{self.value} {self.phrase}" + + +HTTPStatus.NOT_FOUND = HTTPStatus(404, "Not Found") +"""404 Not Found""" +HTTPStatus.OK = HTTPStatus(200, "OK") # pylint: disable=invalid-name +"""200 OK""" +HTTPStatus.INTERNAL_SERVER_ERROR = HTTPStatus(500, "Internal Server Error") +"""500 Internal Server Error""" From 4101db8aeef2803c5bc7e6c99455f702e4cb85f8 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 12 Nov 2022 20:59:54 +0000 Subject: [PATCH 02/25] Added HTTPMethod enum --- adafruit_httpserver/methods.py | 13 +++++++++++++ adafruit_httpserver/server.py | 7 ++++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 adafruit_httpserver/methods.py diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py new file mode 100644 index 0000000..e996779 --- /dev/null +++ b/adafruit_httpserver/methods.py @@ -0,0 +1,13 @@ + +class HTTPMethod: + """HTTP method.""" + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + TRACE = "TRACE" + CONNECT = "CONNECT" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index cada3f8..5ab17b4 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -5,6 +5,7 @@ from errno import EAGAIN, ECONNRESET +from .methods import HTTPMethod from .request import _HTTPRequest from .response import HTTPResponse from .status import HTTPStatus @@ -26,11 +27,11 @@ def __init__(self, socket_source: Any) -> None: self._sock = None self.root_path = "/" - def route(self, path: str, method: str = "GET"): + def route(self, path: str, method: HTTPMethod = HTTPMethod.GET): """Decorator used to add a route. :param str path: filename path - :param str method: HTTP method: "GET", "POST", etc. + :param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc. Example:: @@ -99,7 +100,7 @@ def poll(self): route = self.routes.get(request, None) if route: response = route(request) - elif request.method == "GET": + elif request.method == HTTPMethod.GET: response = HTTPResponse(filename=request.path, root=self.root_path) else: response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) From 450ee79bd784163f4b10a9ebf1a46c1dfa729c7e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 12 Nov 2022 21:23:20 +0000 Subject: [PATCH 03/25] Refactor of _HTTPRequest Changed name and added new attributes to HTTPRequest like method, path, query_params, http_version, headers and body --- adafruit_httpserver/request.py | 82 +++++++++++++++++++++++++--------- adafruit_httpserver/server.py | 4 +- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index b554fa5..b81f735 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -1,26 +1,64 @@ +from typing import Dict, Tuple + + +class HTTPRequest: + + method: str + path: str + query_params: Dict[str, str] = {} + http_version: str + + headers: Dict[str, str] = {} + body: bytes | None + + raw_request: bytes -class _HTTPRequest: def __init__( - self, path: str = "", method: str = "", raw_request: bytes = None + self, raw_request: bytes = None ) -> None: self.raw_request = raw_request - if raw_request is None: - self.path = path - self.method = method - else: - # Parse request data from raw request - request_text = raw_request.decode("utf8") - first_line = request_text[: request_text.find("\n")] - try: - (self.method, self.path, _httpversion) = first_line.split() - except ValueError as exc: - raise ValueError("Unparseable raw_request: ", raw_request) from exc - - def __hash__(self) -> int: - return hash(self.method) ^ hash(self.path) - - def __eq__(self, other: "_HTTPRequest") -> bool: - return self.method == other.method and self.path == other.path - - def __repr__(self) -> str: - return f"_HTTPRequest(path={repr(self.path)}, method={repr(self.method)})" + + if raw_request is None: raise ValueError("raw_request cannot be None") + + try: + self.method, self.path, self.query_params, self.http_version = self.parse_start_line(raw_request) + self.headers = self.parse_headers(raw_request) + self.body = self.parse_body(raw_request) + except Exception as error: + raise ValueError("Unparseable raw_request: ", raw_request) from error + + + @staticmethod + def parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str): + """Parse HTTP Start line to method, path, query_params and http_version.""" + + start_line = raw_request.decode("utf8").splitlines()[0] + + method, path, http_version = start_line.split() + + if "?" not in path: path += "?" + + path, query_string = path.split("?", 1) + query_params = dict([param.split("=", 1) for param in query_string.split("&")]) if query_string else {} + + return method, path, query_params, http_version + + + @staticmethod + def parse_headers(raw_request: bytes) -> Dict[str, str]: + """Parse HTTP headers from raw request.""" + parsed_request_lines = raw_request.decode("utf8").splitlines() + empty_line = parsed_request_lines.index("") + + return dict([header.split(": ", 1) for header in parsed_request_lines[1:empty_line]]) + + + @staticmethod + def parse_body(raw_request: bytes) -> Dict[str, str]: + """Parse HTTP body from raw request.""" + parsed_request_lines = raw_request.decode("utf8").splitlines() + empty_line = parsed_request_lines.index("") + + if empty_line == len(parsed_request_lines) - 1: + return None + return "\r\n".join(parsed_request_lines[empty_line+1:]) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 5ab17b4..2f38dcd 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -6,7 +6,7 @@ from errno import EAGAIN, ECONNRESET from .methods import HTTPMethod -from .request import _HTTPRequest +from .request import HTTPRequest from .response import HTTPResponse from .status import HTTPStatus @@ -94,7 +94,7 @@ def poll(self): conn.setblocking(True) length, _ = conn.recvfrom_into(self._buffer) - request = _HTTPRequest(raw_request=self._buffer[:length]) + request = HTTPRequest(raw_request=self._buffer[:length]) # If a route exists for this request, call it. Otherwise try to serve a file. route = self.routes.get(request, None) From c0ca616019a4b4915485bdd858e787e01a78969c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 12 Nov 2022 21:40:55 +0000 Subject: [PATCH 04/25] Moved previous functionality of _HTTPRequest to HTTPRoute, renamed routes to route_handlers --- adafruit_httpserver/route.py | 21 +++++++++++++++++++++ adafruit_httpserver/server.py | 18 ++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 adafruit_httpserver/route.py diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py new file mode 100644 index 0000000..d90e690 --- /dev/null +++ b/adafruit_httpserver/route.py @@ -0,0 +1,21 @@ +from .methods import HTTPMethod + + +class HTTPRoute: + def __init__( + self, + path: str = "", + method: HTTPMethod = HTTPMethod.GET + ) -> None: + + self.path = path + self.method = method + + def __hash__(self) -> int: + return hash(self.method) ^ hash(self.path) + + def __eq__(self, other: "HTTPRoute") -> bool: + return self.method == other.method and self.path == other.path + + def __repr__(self) -> str: + return f"HTTPRoute(path={repr(self.path)}, method={repr(self.method)})" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 2f38dcd..e07055b 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,6 +8,7 @@ from .methods import HTTPMethod from .request import HTTPRequest from .response import HTTPResponse +from .route import HTTPRoute from .status import HTTPStatus class HTTPServer: @@ -22,7 +23,7 @@ def __init__(self, socket_source: Any) -> None: in CircuitPython or the `socket` module in CPython. """ self._buffer = bytearray(1024) - self.routes = {} + self.route_handlers = {} self._socket_source = socket_source self._sock = None self.root_path = "/" @@ -44,7 +45,7 @@ def route_func(request): """ def route_decorator(func: Callable) -> Callable: - self.routes[_HTTPRequest(path, method)] = func + self.route_handlers[HTTPRoute(path, method)] = func return func return route_decorator @@ -96,12 +97,17 @@ def poll(self): request = HTTPRequest(raw_request=self._buffer[:length]) - # If a route exists for this request, call it. Otherwise try to serve a file. - route = self.routes.get(request, None) - if route: - response = route(request) + handler = self.route_handlers.get(HTTPRoute(request.path, request.method), None) + + # If a handler for route exists, call it. + if handler: + response = handler(request) + + # If no handler exists and request method is GET, try to serve a file. elif request.method == HTTPMethod.GET: response = HTTPResponse(filename=request.path, root=self.root_path) + + # If no handler exists and request method is not GET, return 500 Internal Server Error. else: response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) From bb6ef752e0acd92d8389ebe3ed5c0386b9cd277e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 12 Nov 2022 23:37:48 +0000 Subject: [PATCH 05/25] Refactor in HTTPStatus --- adafruit_httpserver/server.py | 6 +++--- adafruit_httpserver/status.py | 20 +++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index e07055b..445fca0 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -9,7 +9,7 @@ from .request import HTTPRequest from .response import HTTPResponse from .route import HTTPRoute -from .status import HTTPStatus +from .status import BAD_REQUEST_400 class HTTPServer: """A basic socket-based HTTP server.""" @@ -107,9 +107,9 @@ def poll(self): elif request.method == HTTPMethod.GET: response = HTTPResponse(filename=request.path, root=self.root_path) - # If no handler exists and request method is not GET, return 500 Internal Server Error. + # If no handler exists and request method is not GET, return 400 Bad Request. else: - response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) + response = HTTPResponse(status=BAD_REQUEST_400) response.send(conn) except OSError as ex: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 2433fe6..8a80750 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -1,25 +1,23 @@ class HTTPStatus: # pylint: disable=too-few-public-methods """HTTP status codes.""" - def __init__(self, value, phrase): + def __init__(self, code, text): """Define a status code. :param int value: Numeric value: 200, 404, etc. :param str phrase: Short phrase: "OK", "Not Found', etc. """ - self.value = value - self.phrase = phrase + self.code = code + self.text = text def __repr__(self): - return f'HTTPStatus({self.value}, "{self.phrase}")' + return f'HTTPStatus({self.code}, "{self.text}")' def __str__(self): - return f"{self.value} {self.phrase}" + return f"{self.code} {self.text}" -HTTPStatus.NOT_FOUND = HTTPStatus(404, "Not Found") -"""404 Not Found""" -HTTPStatus.OK = HTTPStatus(200, "OK") # pylint: disable=invalid-name -"""200 OK""" -HTTPStatus.INTERNAL_SERVER_ERROR = HTTPStatus(500, "Internal Server Error") -"""500 Internal Server Error""" +OK_200 = HTTPStatus(200, "OK") +BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") +NOT_FOUND_404 = HTTPStatus(404, "Not Found") +INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") From 9a021bd9babd3b465cb2a76182a254acc9b6627d Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 02:15:33 +0000 Subject: [PATCH 06/25] Refactor of HTTPResponse, addition of new attributes --- adafruit_httpserver/response.py | 161 ++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 50 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 8aa7cbe..f96aadd 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -1,97 +1,158 @@ try: - from typing import Any, Optional + from typing import Optional, Dict, Union + from socket import socket except ImportError: pass from errno import EAGAIN, ECONNRESET import os +from socketpool import SocketPool + from .mime_type import MIMEType -from .status import HTTPStatus +from .status import HTTPStatus, OK_200, NOT_FOUND_404 class HTTPResponse: """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" - _HEADERS_FORMAT = ( - "HTTP/1.1 {}\r\n" - "Content-Type: {}\r\n" - "Content-Length: {}\r\n" - "Connection: close\r\n" - "\r\n" - ) + http_version: str + status: HTTPStatus + headers: Dict[str, str] + content_type: str + + filename: Optional[str] + root_directory: str + + body: str def __init__( self, - *, - status: tuple = HTTPStatus.OK, - content_type: str = MIMEType.TEXT_PLAIN, + status: HTTPStatus = OK_200, body: str = "", + headers: Dict[str, str] = None, + content_type: str = MIMEType.TEXT_PLAIN, filename: Optional[str] = None, - root: str = "", + root_directory: str = "", + http_version: str = "HTTP/1.1" ) -> None: - """Create an HTTP response. - - :param tuple status: The HTTP status code to return, as a tuple of (int, "message"). - Common statuses are available in `HTTPStatus`. - :param str content_type: The MIME type of the data being returned. - Common MIME types are available in `MIMEType`. - :param Union[str|bytes] body: - The data to return in the response body, if ``filename`` is not ``None``. - :param str filename: If not ``None``, - return the contents of the specified file, and ignore ``body``. - :param str root: root directory for filename, without a trailing slash """ + Creates an HTTP response. + + Returns `body` if `filename` is `None`, otherwise returns the contents of `filename`. + """ + self.status = status + self.body = body + self.headers = headers or {} self.content_type = content_type - self.body = body.encode() if isinstance(body, str) else body self.filename = filename + self.root_directory = root_directory + self.http_version = http_version - self.root = root - - def send(self, conn: Any) -> None: - # TODO: Use Union[SocketPool.Socket | socket.socket] for the type annotation in some way. + @staticmethod + def _construct_response_bytes( + http_version: str = "HTTP/1.1", + status: HTTPStatus = OK_200, + content_type: str = "text/plain", + content_length: Union[int, None] = None, + headers: Dict[str, str] = None, + body: str = "", + ) -> str: """Send the constructed response over the given socket.""" - if self.filename: + + response = f"{http_version} {status.code} {status.text}\r\n" + + headers = headers or {} + + headers["Content-Type"] = content_type + headers["Content-Length"] = content_length if content_length is not None else len(body) + headers["Connection"] = "close" + + for header, value in headers.items(): + response += f"{header}: {value}\r\n" + + response += f"\r\n{body}" + + return response + + def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: + """ + Send the constructed response over the given socket. + """ + + if self.filename is not None: try: - file_length = os.stat(self.root + self.filename)[6] - self._send_file_response(conn, self.filename, self.root, file_length) + file_length = os.stat(self.root_directory + self.filename)[6] + self._send_file_response( + conn, + filename = self.filename, + root_directory = self.root_directory, + file_length = file_length + ) except OSError: self._send_response( conn, - HTTPStatus.NOT_FOUND, - MIMEType.TEXT_PLAIN, - f"{HTTPStatus.NOT_FOUND} {self.filename}\r\n", + status = NOT_FOUND_404, + content_type = MIMEType.TEXT_PLAIN, + body = f"{NOT_FOUND_404} {self.filename}", ) else: - self._send_response(conn, self.status, self.content_type, self.body) + self._send_response( + conn, + status = self.status, + content_type = self.content_type, + headers = self.headers, + body = self.body, + ) - def _send_response(self, conn, status, content_type, body): + def _send_response( + self, + conn: Union[SocketPool.Socket, socket.socket], + status: HTTPStatus, + content_type: str, + body: str, + headers: Dict[str, str] = None + ): self._send_bytes( - conn, self._HEADERS_FORMAT.format(status, content_type, len(body)) + conn, + self._construct_response_bytes( + status = status, + content_type = content_type, + headers = headers, + body = body, + ) ) - self._send_bytes(conn, body) - def _send_file_response(self, conn, filename, root, file_length): + def _send_file_response( + self, + conn: Union[SocketPool.Socket, socket.socket], + filename: str, + root_directory: str, + file_length: int + ): self._send_bytes( conn, - self._HEADERS_FORMAT.format( - self.status, MIMEType.mime_type(filename), file_length + self._construct_response_bytes( + status = self.status, + content_type = MIMEType.mime_type(filename), + content_length = file_length ), ) - with open(root + filename, "rb") as file: + with open(root_directory + filename, "rb") as file: while bytes_read := file.read(2048): self._send_bytes(conn, bytes_read) @staticmethod - def _send_bytes(conn, buf): + def _send_bytes( + conn: Union[SocketPool.Socket, socket.socket], + buffer: Union[bytes, bytearray, memoryview], + ): bytes_sent = 0 - bytes_to_send = len(buf) - view = memoryview(buf) + 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 + if exc.errno == EAGAIN: continue + if exc.errno == ECONNRESET: return From 9b49df87335470f243bef368ad016efb5e6bcbef Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 09:58:09 +0000 Subject: [PATCH 07/25] Unified root to root_path across all files --- adafruit_httpserver/response.py | 14 +++++++------- adafruit_httpserver/server.py | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index f96aadd..dfb1ea8 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -21,7 +21,7 @@ class HTTPResponse: content_type: str filename: Optional[str] - root_directory: str + root_path: str body: str @@ -32,7 +32,7 @@ def __init__( headers: Dict[str, str] = None, content_type: str = MIMEType.TEXT_PLAIN, filename: Optional[str] = None, - root_directory: str = "", + root_path: str = "", http_version: str = "HTTP/1.1" ) -> None: """ @@ -46,7 +46,7 @@ def __init__( self.headers = headers or {} self.content_type = content_type self.filename = filename - self.root_directory = root_directory + self.root_path = root_path self.http_version = http_version @staticmethod @@ -82,11 +82,11 @@ def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: if self.filename is not None: try: - file_length = os.stat(self.root_directory + self.filename)[6] + file_length = os.stat(self.root_path + self.filename)[6] self._send_file_response( conn, filename = self.filename, - root_directory = self.root_directory, + root_path = self.root_path, file_length = file_length ) except OSError: @@ -127,7 +127,7 @@ def _send_file_response( self, conn: Union[SocketPool.Socket, socket.socket], filename: str, - root_directory: str, + root_path: str, file_length: int ): self._send_bytes( @@ -138,7 +138,7 @@ def _send_file_response( content_length = file_length ), ) - with open(root_directory + filename, "rb") as file: + with open(root_path + filename, "rb") as file: while bytes_read := file.read(2048): self._send_bytes(conn, bytes_read) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 445fca0..698942b 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -50,14 +50,14 @@ def route_decorator(func: Callable) -> Callable: return route_decorator - def serve_forever(self, host: str, port: int = 80, root: str = "") -> None: + def serve_forever(self, host: str, port: int = 80, root_path: str = "") -> None: """Wait for HTTP requests at the given host and port. Does not return. :param str host: host name or IP address :param int port: port :param str root: root directory to serve files from """ - self.start(host, port, root) + self.start(host, port, root_path) while True: try: @@ -65,7 +65,7 @@ def serve_forever(self, host: str, port: int = 80, root: str = "") -> None: except OSError: continue - def start(self, host: str, port: int = 80, root: str = "") -> None: + def start(self, host: str, port: int = 80, root_path: str = "") -> None: """ Start the HTTP server at the given host and port. Requires calling poll() in a while loop to handle incoming requests. @@ -74,7 +74,7 @@ def start(self, host: str, port: int = 80, root: str = "") -> None: :param int port: port :param str root: root directory to serve files from """ - self.root_path = root + self.root_path = root_path self._sock = self._socket_source.socket( self._socket_source.AF_INET, self._socket_source.SOCK_STREAM @@ -105,7 +105,7 @@ def poll(self): # If no handler exists and request method is GET, try to serve a file. elif request.method == HTTPMethod.GET: - response = HTTPResponse(filename=request.path, root=self.root_path) + response = HTTPResponse(filename=request.path, root_path=self.root_path) # If no handler exists and request method is not GET, return 400 Bad Request. else: From e06d0cd29c3316b1ae1bd5d472e356f19348c73f Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 12:15:31 +0000 Subject: [PATCH 08/25] Small changes across files, comments, typing --- adafruit_httpserver/request.py | 21 ++++++++++++--------- adafruit_httpserver/response.py | 8 ++++---- adafruit_httpserver/server.py | 10 ++++------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index b81f735..1e88f6c 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -1,14 +1,17 @@ -from typing import Dict, Tuple +try: + from typing import Dict, Tuple +except ImportError: + pass class HTTPRequest: method: str path: str - query_params: Dict[str, str] = {} + query_params: Dict[str, str] http_version: str - headers: Dict[str, str] = {} + headers: Dict[str, str] body: bytes | None raw_request: bytes @@ -21,15 +24,15 @@ def __init__( if raw_request is None: raise ValueError("raw_request cannot be None") try: - self.method, self.path, self.query_params, self.http_version = self.parse_start_line(raw_request) - self.headers = self.parse_headers(raw_request) - self.body = self.parse_body(raw_request) + self.method, self.path, self.query_params, self.http_version = self._parse_start_line(raw_request) + self.headers = self._parse_headers(raw_request) + self.body = self._parse_body(raw_request) except Exception as error: raise ValueError("Unparseable raw_request: ", raw_request) from error @staticmethod - def parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str): + def _parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str): """Parse HTTP Start line to method, path, query_params and http_version.""" start_line = raw_request.decode("utf8").splitlines()[0] @@ -45,7 +48,7 @@ def parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str) @staticmethod - def parse_headers(raw_request: bytes) -> Dict[str, str]: + def _parse_headers(raw_request: bytes) -> Dict[str, str]: """Parse HTTP headers from raw request.""" parsed_request_lines = raw_request.decode("utf8").splitlines() empty_line = parsed_request_lines.index("") @@ -54,7 +57,7 @@ def parse_headers(raw_request: bytes) -> Dict[str, str]: @staticmethod - def parse_body(raw_request: bytes) -> Dict[str, str]: + def _parse_body(raw_request: bytes) -> Dict[str, str]: """Parse HTTP body from raw request.""" parsed_request_lines = raw_request.decode("utf8").splitlines() empty_line = parsed_request_lines.index("") diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index dfb1ea8..2ed319f 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -53,7 +53,7 @@ def __init__( def _construct_response_bytes( http_version: str = "HTTP/1.1", status: HTTPStatus = OK_200, - content_type: str = "text/plain", + content_type: str = MIMEType.TEXT_PLAIN, content_length: Union[int, None] = None, headers: Dict[str, str] = None, body: str = "", @@ -64,9 +64,9 @@ def _construct_response_bytes( headers = headers or {} - headers["Content-Type"] = content_type - headers["Content-Length"] = content_length if content_length is not None else len(body) - headers["Connection"] = "close" + headers.setdefault("Content-Type", content_type) + headers.setdefault("Content-Length", content_length if content_length is not None else len(body)) + headers.setdefault("Connection", "close") for header, value in headers.items(): response += f"{header}: {value}\r\n" diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 698942b..f2af029 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -1,5 +1,5 @@ try: - from typing import Any, Callable + from typing import Callable, Protocol except ImportError: pass @@ -14,9 +14,7 @@ class HTTPServer: """A basic socket-based HTTP server.""" - def __init__(self, socket_source: Any) -> None: - # TODO: Use a Protocol for the type annotation. - # The Protocol could be refactored from adafruit_requests. + def __init__(self, socket_source: Protocol) -> 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` @@ -99,8 +97,8 @@ def poll(self): handler = self.route_handlers.get(HTTPRoute(request.path, request.method), None) - # If a handler for route exists, call it. - if handler: + # If a handler for route exists and is callable, call it. + if handler is not None and callable(handler): response = handler(request) # If no handler exists and request method is GET, try to serve a file. From e0a117fedf8f047bebbc4e6d5a9121848118db79 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 12:16:18 +0000 Subject: [PATCH 09/25] Removed blocking behavior of socket --- adafruit_httpserver/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index f2af029..1608ec5 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -90,7 +90,6 @@ def poll(self): try: conn, _ = self._sock.accept() with conn: - conn.setblocking(True) length, _ = conn.recvfrom_into(self._buffer) request = HTTPRequest(raw_request=self._buffer[:length]) From 4b3e4f9866c830bbfa96c541aba5c1805aa10571 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 13:19:02 +0000 Subject: [PATCH 10/25] Refactor of MIMEType --- adafruit_httpserver/mime_type.py | 158 ++++++++++++++++--------------- adafruit_httpserver/response.py | 8 +- 2 files changed, 84 insertions(+), 82 deletions(-) diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py index f2db201..675f888 100644 --- a/adafruit_httpserver/mime_type.py +++ b/adafruit_httpserver/mime_type.py @@ -3,85 +3,87 @@ class MIMEType: From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types """ - TEXT_PLAIN = "text/plain" + 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" + 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" + GZ = "application/gzip" + GIF = "image/gif" + HTML = "text/html" + HTM = "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" + CDA = "application/x-cdf" + 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" + PNG = "image/png" + PDF = "application/pdf" + PHP = "application/x-httpd-php" + 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" + TIFF = "image/tiff" + TIF = "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" + _7Z = "application/x-7z-compressed" - _MIME_TYPES = { - "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", - "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", - "gz": "application/gzip", - "gif": "image/gif", - "html": "text/html", - "htm": "text/html", - "ico": "image/vnd.microsoft.icon", - "ics": "text/calendar", - "jar": "application/java-archive", - "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", - "cda": "application/x-cdf", - "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", - "png": "image/png", - "pdf": "application/pdf", - "php": "application/x-httpd-php", - "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", - "tiff": "image/tiff", - "tif": "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", - "7z": "application/x-7z-compressed", - } @staticmethod - def mime_type(filename): + def from_file_name(filename): """Return the mime type for the given filename. If not known, return "text/plain".""" - return MIMEType._MIME_TYPES.get(filename.split(".")[-1], MIMEType.TEXT_PLAIN) + attr_name = filename.split(".")[-1].upper() + + if attr_name[0].isdigit(): attr_name = "_" + attr_name + + return getattr(MIMEType, attr_name, MIMEType.TXT) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 2ed319f..196226b 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -30,7 +30,7 @@ def __init__( status: HTTPStatus = OK_200, body: str = "", headers: Dict[str, str] = None, - content_type: str = MIMEType.TEXT_PLAIN, + content_type: str = MIMEType.TXT, filename: Optional[str] = None, root_path: str = "", http_version: str = "HTTP/1.1" @@ -53,7 +53,7 @@ def __init__( def _construct_response_bytes( http_version: str = "HTTP/1.1", status: HTTPStatus = OK_200, - content_type: str = MIMEType.TEXT_PLAIN, + content_type: str = MIMEType.TXT, content_length: Union[int, None] = None, headers: Dict[str, str] = None, body: str = "", @@ -93,7 +93,7 @@ def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: self._send_response( conn, status = NOT_FOUND_404, - content_type = MIMEType.TEXT_PLAIN, + content_type = MIMEType.TXT, body = f"{NOT_FOUND_404} {self.filename}", ) else: @@ -134,7 +134,7 @@ def _send_file_response( conn, self._construct_response_bytes( status = self.status, - content_type = MIMEType.mime_type(filename), + content_type = MIMEType.from_file_name(filename), content_length = file_length ), ) From 6c5e20142079ed028bf7be2c3a64c70b39456fbd Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:03:49 +0000 Subject: [PATCH 11/25] Small refactor of HTTPRequest, added ability to process body bytes that are not utf-8 decodable --- adafruit_httpserver/request.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 1e88f6c..580bc46 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -23,19 +23,24 @@ def __init__( if raw_request is None: raise ValueError("raw_request cannot be None") + empty_line_index = raw_request.find(b"\r\n\r\n") + + header_bytes = raw_request[:empty_line_index] + body_bytes = raw_request[empty_line_index + 4:] + try: - self.method, self.path, self.query_params, self.http_version = self._parse_start_line(raw_request) - self.headers = self._parse_headers(raw_request) - self.body = self._parse_body(raw_request) + self.method, self.path, self.query_params, self.http_version = self._parse_start_line(header_bytes) + self.headers = self._parse_headers(header_bytes) + self.body = body_bytes except Exception as error: raise ValueError("Unparseable raw_request: ", raw_request) from error @staticmethod - def _parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str): + def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], str]: """Parse HTTP Start line to method, path, query_params and http_version.""" - start_line = raw_request.decode("utf8").splitlines()[0] + start_line = header_bytes.decode("utf8").splitlines()[0] method, path, http_version = start_line.split() @@ -48,20 +53,8 @@ def _parse_start_line(raw_request: bytes) -> Tuple(str, str, Dict[str, str], str @staticmethod - def _parse_headers(raw_request: bytes) -> Dict[str, str]: + def _parse_headers(header_bytes: bytes) -> Dict[str, str]: """Parse HTTP headers from raw request.""" - parsed_request_lines = raw_request.decode("utf8").splitlines() - empty_line = parsed_request_lines.index("") - - return dict([header.split(": ", 1) for header in parsed_request_lines[1:empty_line]]) + header_lines = header_bytes.decode("utf8").splitlines()[1:] - - @staticmethod - def _parse_body(raw_request: bytes) -> Dict[str, str]: - """Parse HTTP body from raw request.""" - parsed_request_lines = raw_request.decode("utf8").splitlines() - empty_line = parsed_request_lines.index("") - - if empty_line == len(parsed_request_lines) - 1: - return None - return "\r\n".join(parsed_request_lines[empty_line+1:]) + return dict([header.split(": ", 1) for header in header_lines[1:]]) From bc46b67e814e8025f29a54189258230e6626d90d Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:18:07 +0000 Subject: [PATCH 12/25] Fixed: Support for query params without value --- adafruit_httpserver/request.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 580bc46..61691aa 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -47,7 +47,14 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st if "?" not in path: path += "?" path, query_string = path.split("?", 1) - query_params = dict([param.split("=", 1) for param in query_string.split("&")]) if query_string else {} + + query_params = {} + for query_param in query_string.split("&"): + if "=" in query_param: + key, value = query_param.split("=", 1) + query_params[key] = value + else: + query_params[query_param] = "" return method, path, query_params, http_version From e7f0dea1794a33eddabd8387d1a5faf5d9e1f539 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:52:34 +0000 Subject: [PATCH 13/25] Added missing typing --- adafruit_httpserver/mime_type.py | 2 +- adafruit_httpserver/status.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py index 675f888..cef6f7e 100644 --- a/adafruit_httpserver/mime_type.py +++ b/adafruit_httpserver/mime_type.py @@ -80,7 +80,7 @@ class MIMEType: @staticmethod - def from_file_name(filename): + def from_file_name(filename: str): """Return the mime type for the given filename. If not known, return "text/plain".""" attr_name = filename.split(".")[-1].upper() diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 8a80750..5808a02 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -1,7 +1,7 @@ class HTTPStatus: # pylint: disable=too-few-public-methods """HTTP status codes.""" - def __init__(self, code, text): + def __init__(self, code: int, text: str): """Define a status code. :param int value: Numeric value: 200, 404, etc. From 132d36c8caf886e7aec67f50b585f2de438e4046 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:56:46 +0000 Subject: [PATCH 14/25] Added passing HTTPResponse headers to file response --- adafruit_httpserver/response.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 196226b..3083283 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -87,7 +87,8 @@ def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: conn, filename = self.filename, root_path = self.root_path, - file_length = file_length + file_length = file_length, + headers = self.headers, ) except OSError: self._send_response( @@ -128,14 +129,16 @@ def _send_file_response( conn: Union[SocketPool.Socket, socket.socket], filename: str, root_path: str, - file_length: int + file_length: int, + headers: Dict[str, str] = None ): self._send_bytes( conn, self._construct_response_bytes( status = self.status, content_type = MIMEType.from_file_name(filename), - content_length = file_length + content_length = file_length, + headers = headers, ), ) with open(root_path + filename, "rb") as file: From 41edff8ceb2ba8c5b76984d8582cbb15b6f763ed Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 22:10:05 +0000 Subject: [PATCH 15/25] Added CommonHTTPStatus --- adafruit_httpserver/response.py | 10 +++++----- adafruit_httpserver/server.py | 4 ++-- adafruit_httpserver/status.py | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 3083283..fe9c5d9 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -10,7 +10,7 @@ from socketpool import SocketPool from .mime_type import MIMEType -from .status import HTTPStatus, OK_200, NOT_FOUND_404 +from .status import HTTPStatus, CommonHTTPStatus class HTTPResponse: """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" @@ -27,7 +27,7 @@ class HTTPResponse: def __init__( self, - status: HTTPStatus = OK_200, + status: HTTPStatus = CommonHTTPStatus.OK_200, body: str = "", headers: Dict[str, str] = None, content_type: str = MIMEType.TXT, @@ -52,7 +52,7 @@ def __init__( @staticmethod def _construct_response_bytes( http_version: str = "HTTP/1.1", - status: HTTPStatus = OK_200, + status: HTTPStatus = CommonHTTPStatus.OK_200, content_type: str = MIMEType.TXT, content_length: Union[int, None] = None, headers: Dict[str, str] = None, @@ -93,9 +93,9 @@ def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: except OSError: self._send_response( conn, - status = NOT_FOUND_404, + status = CommonHTTPStatus.NOT_FOUND_404, content_type = MIMEType.TXT, - body = f"{NOT_FOUND_404} {self.filename}", + body = f"{CommonHTTPStatus.NOT_FOUND_404} {self.filename}", ) else: self._send_response( diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1608ec5..5bc0dca 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -9,7 +9,7 @@ from .request import HTTPRequest from .response import HTTPResponse from .route import HTTPRoute -from .status import BAD_REQUEST_400 +from .status import CommonHTTPStatus class HTTPServer: """A basic socket-based HTTP server.""" @@ -106,7 +106,7 @@ def poll(self): # If no handler exists and request method is not GET, return 400 Bad Request. else: - response = HTTPResponse(status=BAD_REQUEST_400) + response = HTTPResponse(status=CommonHTTPStatus.BAD_REQUEST_400) response.send(conn) except OSError as ex: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 5808a02..3c5cfc0 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -17,7 +17,16 @@ def __str__(self): return f"{self.code} {self.text}" -OK_200 = HTTPStatus(200, "OK") -BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") -NOT_FOUND_404 = HTTPStatus(404, "Not Found") -INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") +class CommonHTTPStatus(HTTPStatus): + + OK_200 = HTTPStatus(200, "OK") + """200 OK""" + + BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") + """400 Bad Request""" + + NOT_FOUND_404 = HTTPStatus(404, "Not Found") + """404 Not Found""" + + INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") + """500 Internal Server Error""" From 21e20b60412a54ddaffd738c9c2cd213eaf1a303 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 22:51:49 +0000 Subject: [PATCH 16/25] Preparing code for docs generation, black formatting, updating examples --- adafruit_httpserver/methods.py | 27 +++++++++- adafruit_httpserver/mime_type.py | 13 ++++- adafruit_httpserver/request.py | 45 ++++++++++++++--- adafruit_httpserver/response.py | 74 ++++++++++++++++------------ adafruit_httpserver/route.py | 21 +++++--- adafruit_httpserver/server.py | 26 ++++++++-- adafruit_httpserver/status.py | 13 ++++- docs/api.rst | 21 ++++++++ examples/httpserver_simplepolling.py | 3 +- examples/httpserver_simpletest.py | 3 +- examples/httpserver_temperature.py | 3 +- 11 files changed, 190 insertions(+), 59 deletions(-) diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index e996779..cdf76a3 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -1,13 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.methods.HTTPMethod` +==================================================== +* Author(s): Michał Pokusa +""" class HTTPMethod: - """HTTP method.""" + """Enum with HTTP methods.""" GET = "GET" + """GET method.""" + POST = "POST" + """POST method.""" + PUT = "PUT" + """PUT method""" + DELETE = "DELETE" + """DELETE method""" + PATCH = "PATCH" + """PATCH method""" + HEAD = "HEAD" + """HEAD method""" + OPTIONS = "OPTIONS" + """OPTIONS method""" + TRACE = "TRACE" + """TRACE method""" + CONNECT = "CONNECT" + """CONNECT method""" diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py index cef6f7e..3d2dc23 100644 --- a/adafruit_httpserver/mime_type.py +++ b/adafruit_httpserver/mime_type.py @@ -1,3 +1,12 @@ +# 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 @@ -78,12 +87,12 @@ class MIMEType: ZIP = "application/zip" _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 = filename.split(".")[-1].upper() - if attr_name[0].isdigit(): attr_name = "_" + attr_name + if attr_name[0].isdigit(): + attr_name = "_" + attr_name return getattr(MIMEType, attr_name, MIMEType.TXT) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 61691aa..7573f53 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -1,3 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.request.HTTPRequest` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + try: from typing import Dict, Tuple except ImportError: @@ -5,23 +14,44 @@ class HTTPRequest: + """ + Incoming request, constructed from raw incoming bytes, that is passed as first argument to route handlers. + """ method: str + """Request method e.g. "GET" or "POST".""" + path: str + """Path of the request.""" + query_params: Dict[str, str] + """ + Query/GET parameters in the request. + + Example:: + + request = HTTPRequest(raw_request=b"GET /?foo=bar HTTP/1.1...") + request.query_params + # {"foo": "bar"} + """ + http_version: str + """HTTP version, e.g. "HTTP/1.1".""" headers: Dict[str, str] - body: bytes | None + """Headers from the request.""" + + body: bytes + """Body of the request, as bytes.""" raw_request: bytes + """Raw bytes passed to the constructor.""" - def __init__( - self, raw_request: bytes = None - ) -> None: + def __init__(self, raw_request: bytes = None) -> None: self.raw_request = raw_request - if raw_request is None: raise ValueError("raw_request cannot be None") + if raw_request is None: + raise ValueError("raw_request cannot be None") empty_line_index = raw_request.find(b"\r\n\r\n") @@ -35,7 +65,6 @@ def __init__( except Exception as error: raise ValueError("Unparseable raw_request: ", raw_request) from error - @staticmethod def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], str]: """Parse HTTP Start line to method, path, query_params and http_version.""" @@ -44,7 +73,8 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st method, path, http_version = start_line.split() - if "?" not in path: path += "?" + if "?" not in path: + path += "?" path, query_string = path.split("?", 1) @@ -58,7 +88,6 @@ 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) -> Dict[str, str]: """Parse HTTP headers from raw request.""" diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index fe9c5d9..3c2eeb6 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -1,17 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.response.HTTPResponse` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + try: from typing import Optional, Dict, Union from socket import socket + from socketpool import SocketPool except ImportError: pass from errno import EAGAIN, ECONNRESET import os -from socketpool import SocketPool from .mime_type import MIMEType from .status import HTTPStatus, CommonHTTPStatus + class HTTPResponse: """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" @@ -33,7 +43,7 @@ def __init__( content_type: str = MIMEType.TXT, filename: Optional[str] = None, root_path: str = "", - http_version: str = "HTTP/1.1" + http_version: str = "HTTP/1.1", ) -> None: """ Creates an HTTP response. @@ -65,7 +75,7 @@ def _construct_response_bytes( headers = headers or {} headers.setdefault("Content-Type", content_type) - headers.setdefault("Content-Length", content_length if content_length is not None else len(body)) + headers.setdefault("Content-Length", content_length or len(body)) headers.setdefault("Connection", "close") for header, value in headers.items(): @@ -75,7 +85,7 @@ def _construct_response_bytes( return response - def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: + def send(self, conn: Union["SocketPool.Socket", "socket.socket"]) -> None: """ Send the constructed response over the given socket. """ @@ -85,60 +95,60 @@ def send(self, conn: Union[SocketPool.Socket, socket.socket]) -> None: file_length = os.stat(self.root_path + self.filename)[6] self._send_file_response( conn, - filename = self.filename, - root_path = self.root_path, - file_length = file_length, - headers = self.headers, + filename=self.filename, + root_path=self.root_path, + file_length=file_length, + headers=self.headers, ) except OSError: self._send_response( conn, - status = CommonHTTPStatus.NOT_FOUND_404, - content_type = MIMEType.TXT, - body = f"{CommonHTTPStatus.NOT_FOUND_404} {self.filename}", + status=CommonHTTPStatus.NOT_FOUND_404, + content_type=MIMEType.TXT, + body=f"{CommonHTTPStatus.NOT_FOUND_404} {self.filename}", ) else: self._send_response( conn, - status = self.status, - content_type = self.content_type, - headers = self.headers, - body = self.body, + status=self.status, + content_type=self.content_type, + headers=self.headers, + body=self.body, ) def _send_response( self, - conn: Union[SocketPool.Socket, socket.socket], + conn: Union["SocketPool.Socket", "socket.socket"], status: HTTPStatus, content_type: str, body: str, - headers: Dict[str, str] = None + headers: Dict[str, str] = None, ): self._send_bytes( conn, self._construct_response_bytes( - status = status, - content_type = content_type, - headers = headers, - body = body, - ) + status=status, + content_type=content_type, + headers=headers, + body=body, + ), ) def _send_file_response( self, - conn: Union[SocketPool.Socket, socket.socket], + conn: Union["SocketPool.Socket", "socket.socket"], filename: str, root_path: str, file_length: int, - headers: Dict[str, str] = None + headers: Dict[str, str] = None, ): self._send_bytes( conn, self._construct_response_bytes( - status = self.status, - content_type = MIMEType.from_file_name(filename), - content_length = file_length, - headers = headers, + status=self.status, + content_type=MIMEType.from_file_name(filename), + content_length=file_length, + headers=headers, ), ) with open(root_path + filename, "rb") as file: @@ -147,7 +157,7 @@ def _send_file_response( @staticmethod def _send_bytes( - conn: Union[SocketPool.Socket, socket.socket], + conn: Union["SocketPool.Socket", "socket.socket"], buffer: Union[bytes, bytearray, memoryview], ): bytes_sent = 0 @@ -157,5 +167,7 @@ def _send_bytes( try: bytes_sent += conn.send(view[bytes_sent:]) except OSError as exc: - if exc.errno == EAGAIN: continue - if exc.errno == ECONNRESET: return + if exc.errno == EAGAIN: + continue + if exc.errno == ECONNRESET: + return diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index d90e690..5933738 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -1,12 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.route.HTTPRoute` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + from .methods import HTTPMethod -class HTTPRoute: - def __init__( - self, - path: str = "", - method: HTTPMethod = HTTPMethod.GET - ) -> None: +class _HTTPRoute: + """Route definition for different paths, see `HTTPServer.route`.""" + + def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: self.path = path self.method = method @@ -14,7 +21,7 @@ def __init__( def __hash__(self) -> int: return hash(self.method) ^ hash(self.path) - def __eq__(self, other: "HTTPRoute") -> bool: + def __eq__(self, other: "_HTTPRoute") -> bool: return self.method == other.method and self.path == other.path def __repr__(self) -> str: diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 5bc0dca..02552dc 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -1,3 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.server.HTTPServer` +==================================================== +* Author(s): Dan Halbert +""" + try: from typing import Callable, Protocol except ImportError: @@ -8,9 +17,10 @@ from .methods import HTTPMethod from .request import HTTPRequest from .response import HTTPResponse -from .route import HTTPRoute +from .route import _HTTPRoute from .status import CommonHTTPStatus + class HTTPServer: """A basic socket-based HTTP server.""" @@ -43,7 +53,7 @@ def route_func(request): """ def route_decorator(func: Callable) -> Callable: - self.route_handlers[HTTPRoute(path, method)] = func + self.route_handlers[_HTTPRoute(path, method)] = func return func return route_decorator @@ -94,7 +104,9 @@ def poll(self): request = HTTPRequest(raw_request=self._buffer[:length]) - handler = self.route_handlers.get(HTTPRoute(request.path, request.method), None) + handler = self.route_handlers.get( + _HTTPRoute(request.path, request.method), None + ) # If a handler for route exists and is callable, call it. if handler is not None and callable(handler): @@ -102,11 +114,15 @@ def poll(self): # If no handler exists and request method is GET, try to serve a file. elif request.method == HTTPMethod.GET: - response = HTTPResponse(filename=request.path, root_path=self.root_path) + response = HTTPResponse( + filename=request.path, root_path=self.root_path + ) # If no handler exists and request method is not GET, return 400 Bad Request. else: - response = HTTPResponse(status=CommonHTTPStatus.BAD_REQUEST_400) + response = HTTPResponse( + status=CommonHTTPStatus.BAD_REQUEST_400 + ) response.send(conn) except OSError as ex: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 3c5cfc0..3371ddb 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -1,11 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.status.HTTPStatus` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + class HTTPStatus: # pylint: disable=too-few-public-methods """HTTP status codes.""" def __init__(self, code: int, text: str): """Define a status code. - :param int value: Numeric value: 200, 404, etc. - :param str phrase: Short phrase: "OK", "Not Found', etc. + :param int code: Numeric value: 200, 404, etc. + :param str text: Short phrase: "OK", "Not Found', etc. """ self.code = code self.text = text diff --git a/docs/api.rst b/docs/api.rst index 1bfce0b..9bcaff1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,3 +6,24 @@ .. automodule:: adafruit_httpserver :members: + +.. automodule:: adafruit_httpserver.methods + :members: + +.. automodule:: adafruit_httpserver.mime_type + :members: + +.. automodule:: adafruit_httpserver.request + :members: + +.. automodule:: adafruit_httpserver.response + :members: + +.. automodule:: adafruit_httpserver.route + :private-members: + +.. automodule:: adafruit_httpserver.server + :members: + +.. automodule:: adafruit_httpserver.status + :members: diff --git a/examples/httpserver_simplepolling.py b/examples/httpserver_simplepolling.py index 6bec7e8..d924a5c 100644 --- a/examples/httpserver_simplepolling.py +++ b/examples/httpserver_simplepolling.py @@ -7,7 +7,8 @@ import socketpool import wifi -from adafruit_httpserver import HTTPServer, HTTPResponse +from adafruit_httpserver.server import HTTPServer +from adafruit_httpserver.response import HTTPResponse ssid = secrets["ssid"] print("Connecting to", ssid) diff --git a/examples/httpserver_simpletest.py b/examples/httpserver_simpletest.py index 4516c14..e1be4b0 100644 --- a/examples/httpserver_simpletest.py +++ b/examples/httpserver_simpletest.py @@ -7,7 +7,8 @@ import socketpool import wifi -from adafruit_httpserver import HTTPServer, HTTPResponse +from adafruit_httpserver.server import HTTPServer +from adafruit_httpserver.response import HTTPResponse ssid = secrets["ssid"] print("Connecting to", ssid) diff --git a/examples/httpserver_temperature.py b/examples/httpserver_temperature.py index c305e11..94aa541 100644 --- a/examples/httpserver_temperature.py +++ b/examples/httpserver_temperature.py @@ -8,7 +8,8 @@ import socketpool import wifi -from adafruit_httpserver import HTTPServer, HTTPResponse +from adafruit_httpserver.server import HTTPServer +from adafruit_httpserver.response import HTTPResponse ssid = secrets["ssid"] print("Connecting to", ssid) From 6585d3f709ea02e39b463e75372f67f5a0f89e12 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 13 Nov 2022 23:51:18 +0000 Subject: [PATCH 17/25] Resolving rest of pylint and black errors --- adafruit_httpserver/methods.py | 3 +- adafruit_httpserver/mime_type.py | 160 ++++++++++++++++--------------- adafruit_httpserver/request.py | 14 ++- adafruit_httpserver/response.py | 16 ++-- adafruit_httpserver/route.py | 4 +- adafruit_httpserver/server.py | 4 +- adafruit_httpserver/status.py | 4 +- docs/api.rst | 3 - 8 files changed, 107 insertions(+), 101 deletions(-) diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index cdf76a3..319b631 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -7,7 +7,8 @@ * Author(s): Michał Pokusa """ -class HTTPMethod: + +class HTTPMethod: # pylint: disable=too-few-public-methods """Enum with HTTP methods.""" GET = "GET" diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py index 3d2dc23..39e592e 100644 --- a/adafruit_httpserver/mime_type.py +++ b/adafruit_httpserver/mime_type.py @@ -7,92 +7,94 @@ * 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 """ - 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" - 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" - GZ = "application/gzip" - GIF = "image/gif" - HTML = "text/html" - HTM = "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" - CDA = "application/x-cdf" - 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" - PNG = "image/png" - PDF = "application/pdf" - PHP = "application/x-httpd-php" - 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" - TIFF = "image/tiff" - TIF = "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" - _7Z = "application/x-7z-compressed" + 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 = filename.split(".")[-1].upper() - - if attr_name[0].isdigit(): - attr_name = "_" + attr_name + attr_name = "TYPE_" + filename.split(".")[-1].upper() - return getattr(MIMEType, attr_name, MIMEType.TXT) + return getattr(MIMEType, attr_name, MIMEType.TYPE_TXT) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 7573f53..f2d38e8 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -13,9 +13,10 @@ pass -class HTTPRequest: +class HTTPRequest: # pylint: disable=too-few-public-methods """ - Incoming request, constructed from raw incoming bytes, that is passed as first argument to route handlers. + Incoming request, constructed from raw incoming bytes. + It is passed as first argument to route handlers. """ method: str @@ -56,10 +57,15 @@ def __init__(self, raw_request: bytes = None) -> None: empty_line_index = raw_request.find(b"\r\n\r\n") header_bytes = raw_request[:empty_line_index] - body_bytes = raw_request[empty_line_index + 4:] + body_bytes = raw_request[empty_line_index + 4 :] try: - self.method, self.path, self.query_params, self.http_version = self._parse_start_line(header_bytes) + ( + self.method, + self.path, + self.query_params, + self.http_version, + ) = self._parse_start_line(header_bytes) self.headers = self._parse_headers(header_bytes) self.body = body_bytes except Exception as error: diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 3c2eeb6..def1d6a 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -35,12 +35,12 @@ class HTTPResponse: body: str - def __init__( + def __init__( # pylint: disable=too-many-arguments self, status: HTTPStatus = CommonHTTPStatus.OK_200, body: str = "", headers: Dict[str, str] = None, - content_type: str = MIMEType.TXT, + content_type: str = MIMEType.TYPE_TXT, filename: Optional[str] = None, root_path: str = "", http_version: str = "HTTP/1.1", @@ -48,7 +48,7 @@ def __init__( """ Creates an HTTP response. - Returns `body` if `filename` is `None`, otherwise returns the contents of `filename`. + Returns ``body`` if ``filename`` is ``None``, otherwise returns contents of ``filename``. """ self.status = status @@ -60,10 +60,10 @@ def __init__( self.http_version = http_version @staticmethod - def _construct_response_bytes( + def _construct_response_bytes( # pylint: disable=too-many-arguments http_version: str = "HTTP/1.1", status: HTTPStatus = CommonHTTPStatus.OK_200, - content_type: str = MIMEType.TXT, + content_type: str = MIMEType.TYPE_TXT, content_length: Union[int, None] = None, headers: Dict[str, str] = None, body: str = "", @@ -104,7 +104,7 @@ def send(self, conn: Union["SocketPool.Socket", "socket.socket"]) -> None: self._send_response( conn, status=CommonHTTPStatus.NOT_FOUND_404, - content_type=MIMEType.TXT, + content_type=MIMEType.TYPE_TXT, body=f"{CommonHTTPStatus.NOT_FOUND_404} {self.filename}", ) else: @@ -116,7 +116,7 @@ def send(self, conn: Union["SocketPool.Socket", "socket.socket"]) -> None: body=self.body, ) - def _send_response( + def _send_response( # pylint: disable=too-many-arguments self, conn: Union["SocketPool.Socket", "socket.socket"], status: HTTPStatus, @@ -134,7 +134,7 @@ def _send_response( ), ) - def _send_file_response( + def _send_file_response( # pylint: disable=too-many-arguments self, conn: Union["SocketPool.Socket", "socket.socket"], filename: str, diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 5933738..78d8c6c 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.route.HTTPRoute` +`adafruit_httpserver.route._HTTPRoute` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ @@ -11,7 +11,7 @@ class _HTTPRoute: - """Route definition for different paths, see `HTTPServer.route`.""" + """Route definition for different paths, see `adafruit_httpserver.server.HTTPServer.route`.""" def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 02552dc..5bf8f3f 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -120,9 +120,7 @@ def poll(self): # If no handler exists and request method is not GET, return 400 Bad Request. else: - response = HTTPResponse( - status=CommonHTTPStatus.BAD_REQUEST_400 - ) + response = HTTPResponse(status=CommonHTTPStatus.BAD_REQUEST_400) response.send(conn) except OSError as ex: diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 3371ddb..1d8e7c5 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -7,6 +7,7 @@ * Author(s): Dan Halbert, Michał Pokusa """ + class HTTPStatus: # pylint: disable=too-few-public-methods """HTTP status codes.""" @@ -26,7 +27,8 @@ def __str__(self): return f"{self.code} {self.text}" -class CommonHTTPStatus(HTTPStatus): +class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods + """Common HTTP status codes.""" OK_200 = HTTPStatus(200, "OK") """200 OK""" diff --git a/docs/api.rst b/docs/api.rst index 9bcaff1..cf4ba22 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -19,9 +19,6 @@ .. automodule:: adafruit_httpserver.response :members: -.. automodule:: adafruit_httpserver.route - :private-members: - .. automodule:: adafruit_httpserver.server :members: From 0fe7bd7453b4d18cf77490de8f05e14cec6202af Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 18 Nov 2022 00:26:50 +0000 Subject: [PATCH 18/25] Fix: Wrong typing and return type in HTTPResponse._construct_response_bytes --- adafruit_httpserver/response.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index def1d6a..a666ab0 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -67,8 +67,8 @@ def _construct_response_bytes( # pylint: disable=too-many-arguments content_length: Union[int, None] = None, headers: Dict[str, str] = None, body: str = "", - ) -> str: - """Send the constructed response over the given socket.""" + ) -> bytes: + """Constructs the response bytes from the given parameters.""" response = f"{http_version} {status.code} {status.text}\r\n" @@ -83,7 +83,7 @@ def _construct_response_bytes( # pylint: disable=too-many-arguments response += f"\r\n{body}" - return response + return response.encode("utf-8") def send(self, conn: Union["SocketPool.Socket", "socket.socket"]) -> None: """ From eaea81d923b60530d6e27559ec80db1549e53219 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 19 Nov 2022 01:47:12 +0000 Subject: [PATCH 19/25] Added HTTPServer.socket_timeout --- adafruit_httpserver/server.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 5bf8f3f..6345352 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -4,7 +4,7 @@ """ `adafruit_httpserver.server.HTTPServer` ==================================================== -* Author(s): Dan Halbert +* Author(s): Dan Halbert, Michał Pokusa """ try: @@ -12,7 +12,7 @@ except ImportError: pass -from errno import EAGAIN, ECONNRESET +from errno import EAGAIN, ECONNRESET, ETIMEDOUT from .methods import HTTPMethod from .request import HTTPRequest @@ -31,6 +31,7 @@ def __init__(self, socket_source: Protocol) -> None: in CircuitPython or the `socket` module in CPython. """ self._buffer = bytearray(1024) + self._timeout = 0 self.route_handlers = {} self._socket_source = socket_source self._sock = None @@ -100,6 +101,7 @@ def poll(self): try: conn, _ = self._sock.accept() with conn: + conn.settimeout(self._timeout) length, _ = conn.recvfrom_into(self._buffer) request = HTTPRequest(raw_request=self._buffer[:length]) @@ -154,3 +156,29 @@ def request_buffer_size(self) -> int: @request_buffer_size.setter def request_buffer_size(self, value: int) -> None: self._buffer = bytearray(value) + + @property + def socket_timeout(self) -> int: + """ + Timeout after which the socket will stop waiting for more incoming data. + When exceeded, raises `OSError` with `errno.ETIMEDOUT`. + + Default timeout is 0, which means socket is in non-blocking mode. + + Example:: + + server = HTTPServer(pool) + server.socket_timeout = 3 + + server.serve_forever(str(wifi.radio.ipv4_address)) + """ + return self._timeout + + @socket_timeout.setter + def socket_timeout(self, value: int) -> None: + if isinstance(value, (int, float)) and value >= 0: + self._timeout = value + else: + raise TypeError( + "HTTPServer.socket_timeout must be a non-negative numeric value." + ) From 85d1c3cc8906a2280ebca18ab403439497ebfe55 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 19 Nov 2022 01:51:40 +0000 Subject: [PATCH 20/25] Changed how incoming data is received Solved problem when data is sent in chunks and is not received in full. Bypassed ESP32 TCP buffer limit of 2880 bytes. --- adafruit_httpserver/server.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 6345352..2dd729d 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -102,9 +102,25 @@ def poll(self): conn, _ = self._sock.accept() with conn: conn.settimeout(self._timeout) - length, _ = conn.recvfrom_into(self._buffer) - - request = HTTPRequest(raw_request=self._buffer[:length]) + received_data = bytearray() + + # Receiving data until timeout + while "Receiving data": + try: + length = conn.recv_into(self._buffer) + received_data += self._buffer[:length] + except OSError as ex: + if ex.errno == ETIMEDOUT: + break + except Exception as ex: + raise ex + + # Return if no data received + if not received_data: + return + + # Parsing received data + request = HTTPRequest(raw_request=received_data) handler = self.route_handlers.get( _HTTPRoute(request.path, request.method), None From 4a1a3a129db8f14166c509725fa4a75f62d53c22 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 19 Nov 2022 13:45:53 +0000 Subject: [PATCH 21/25] Added option to pass status to HTTPResponse as tuple, overwrited eq method on HTTPStatus --- adafruit_httpserver/response.py | 7 +++---- adafruit_httpserver/status.py | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index a666ab0..a44cba1 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -8,7 +8,7 @@ """ try: - from typing import Optional, Dict, Union + from typing import Optional, Dict, Union, Tuple from socket import socket from socketpool import SocketPool except ImportError: @@ -37,7 +37,7 @@ class HTTPResponse: def __init__( # pylint: disable=too-many-arguments self, - status: HTTPStatus = CommonHTTPStatus.OK_200, + status: Union[HTTPStatus, Tuple[int, str]] = CommonHTTPStatus.OK_200, body: str = "", headers: Dict[str, str] = None, content_type: str = MIMEType.TYPE_TXT, @@ -50,8 +50,7 @@ def __init__( # pylint: disable=too-many-arguments Returns ``body`` if ``filename`` is ``None``, otherwise returns contents of ``filename``. """ - - self.status = status + self.status = status if isinstance(status, HTTPStatus) else HTTPStatus(*status) self.body = body self.headers = headers or {} self.content_type = content_type diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index 1d8e7c5..d32538c 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -26,6 +26,9 @@ def __repr__(self): def __str__(self): return f"{self.code} {self.text}" + def __eq__(self, other: "HTTPStatus"): + return self.code == other.code and self.text == other.text + class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods """Common HTTP status codes.""" From 04805467cb21ed7ed14a225ea8a9eea6f7d5a2f7 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 20 Nov 2022 14:08:26 +0000 Subject: [PATCH 22/25] Changed default socket_timeout and made it possible to be a positive number only --- adafruit_httpserver/server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 2dd729d..9dc92d8 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -31,7 +31,7 @@ def __init__(self, socket_source: Protocol) -> None: in CircuitPython or the `socket` module in CPython. """ self._buffer = bytearray(1024) - self._timeout = 0 + self._timeout = 1 self.route_handlers = {} self._socket_source = socket_source self._sock = None @@ -107,7 +107,7 @@ def poll(self): # Receiving data until timeout while "Receiving data": try: - length = conn.recv_into(self._buffer) + length = conn.recv_into(self._buffer, len(self._buffer)) received_data += self._buffer[:length] except OSError as ex: if ex.errno == ETIMEDOUT: @@ -192,9 +192,9 @@ def socket_timeout(self) -> int: @socket_timeout.setter def socket_timeout(self, value: int) -> None: - if isinstance(value, (int, float)) and value >= 0: + if isinstance(value, (int, float)) and value > 0: self._timeout = value else: - raise TypeError( - "HTTPServer.socket_timeout must be a non-negative numeric value." + raise ValueError( + "HTTPServer.socket_timeout must be a positive numeric value." ) From 4a4cdfdbcd6366e32e54b93a163d34c8c856244a Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 22 Nov 2022 02:22:05 +0000 Subject: [PATCH 23/25] Removed body attribute and added as property --- adafruit_httpserver/request.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index f2d38e8..186fa83 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -13,7 +13,7 @@ pass -class HTTPRequest: # pylint: disable=too-few-public-methods +class HTTPRequest: """ Incoming request, constructed from raw incoming bytes. It is passed as first argument to route handlers. @@ -42,9 +42,6 @@ class HTTPRequest: # pylint: disable=too-few-public-methods headers: Dict[str, str] """Headers from the request.""" - body: bytes - """Body of the request, as bytes.""" - raw_request: bytes """Raw bytes passed to the constructor.""" @@ -54,10 +51,7 @@ def __init__(self, raw_request: bytes = None) -> None: if raw_request is None: raise ValueError("raw_request cannot be None") - empty_line_index = raw_request.find(b"\r\n\r\n") - - header_bytes = raw_request[:empty_line_index] - body_bytes = raw_request[empty_line_index + 4 :] + header_bytes = self.header_body_bytes[0] try: ( @@ -67,10 +61,28 @@ def __init__(self, raw_request: bytes = None) -> None: self.http_version, ) = self._parse_start_line(header_bytes) self.headers = self._parse_headers(header_bytes) - self.body = body_bytes except Exception as error: raise ValueError("Unparseable raw_request: ", raw_request) from error + @property + def body(self) -> bytes: + """Body of the request, as bytes.""" + return self.header_body_bytes[1] + + @body.setter + def body(self, body: bytes) -> None: + self.raw_request = self.header_body_bytes[0] + b"\r\n\r\n" + body + + @property + def header_body_bytes(self) -> Tuple[bytes, bytes]: + """Return tuple of header and 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 + @staticmethod def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], str]: """Parse HTTP Start line to method, path, query_params and http_version.""" From 0d69a4c101361d864c1e4577b097611c5b0cdcfd Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 22 Nov 2022 02:30:21 +0000 Subject: [PATCH 24/25] Fix: First header was skipped and headers were case-sensitive --- adafruit_httpserver/request.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 186fa83..22758a8 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -40,7 +40,20 @@ class HTTPRequest: """HTTP version, e.g. "HTTP/1.1".""" headers: Dict[str, str] - """Headers from the request.""" + """ + Headers from the request as `dict`. + + Values should be accessed using **lower case header names**. + + Example:: + + request.headers + # {'connection': 'keep-alive', 'content-length': '64' ...} + request.headers["content-length"] + # '64' + request.headers["Content-Length"] + # KeyError: 'Content-Length' + """ raw_request: bytes """Raw bytes passed to the constructor.""" @@ -111,4 +124,8 @@ def _parse_headers(header_bytes: bytes) -> Dict[str, str]: """Parse HTTP headers from raw request.""" header_lines = header_bytes.decode("utf8").splitlines()[1:] - return dict([header.split(": ", 1) for header in header_lines[1:]]) + return { + name.lower(): value + for header_line in header_lines + for name, value in [header_line.split(": ", 1)] + } From 469f0eb9a05600cabacd2ad6ed4d163d83bd8dc9 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 22 Nov 2022 04:40:52 +0000 Subject: [PATCH 25/25] Implemented processing 'Content-Length' header and limiting received bytes --- adafruit_httpserver/server.py | 66 ++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 9dc92d8..8bf1045 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,7 +8,9 @@ """ try: - from typing import Callable, Protocol + from typing import Callable, Protocol, Union + from socket import socket + from socketpool import SocketPool except ImportError: pass @@ -92,6 +94,40 @@ def start(self, host: str, port: int = 80, root_path: str = "") -> None: self._sock.listen(10) self._sock.setblocking(False) # non-blocking socket + def _receive_header_bytes( + self, sock: Union["SocketPool.Socket", "socket.socket"] + ) -> bytes: + """Receive bytes until a empty line is received.""" + received_bytes = bytes() + while b"\r\n\r\n" not in received_bytes: + try: + length = sock.recv_into(self._buffer, len(self._buffer)) + received_bytes += self._buffer[:length] + except OSError as ex: + if ex.errno == ETIMEDOUT: + break + except Exception as ex: + raise ex + return received_bytes + + def _receive_body_bytes( + self, + sock: Union["SocketPool.Socket", "socket.socket"], + received_body_bytes: bytes, + content_length: int, + ) -> bytes: + """Receive bytes until the given content length is received.""" + while len(received_body_bytes) < content_length: + try: + length = sock.recv_into(self._buffer, len(self._buffer)) + received_body_bytes += self._buffer[:length] + except OSError as ex: + if ex.errno == ETIMEDOUT: + break + except Exception as ex: + raise ex + return received_body_bytes[:content_length] + def poll(self): """ Call this method inside your main event loop to get the server to @@ -102,25 +138,23 @@ def poll(self): conn, _ = self._sock.accept() with conn: conn.settimeout(self._timeout) - received_data = bytearray() - - # Receiving data until timeout - while "Receiving data": - try: - length = conn.recv_into(self._buffer, len(self._buffer)) - received_data += self._buffer[:length] - except OSError as ex: - if ex.errno == ETIMEDOUT: - break - except Exception as ex: - raise ex + + # Receiving data until empty line + header_bytes = self._receive_header_bytes(conn) # Return if no data received - if not received_data: + if not header_bytes: return - # Parsing received data - request = HTTPRequest(raw_request=received_data) + request = HTTPRequest(header_bytes) + + content_length = int(request.headers.get("content-length", 0)) + received_body_bytes = request.body + + # Receiving remaining body bytes + request.body = self._receive_body_bytes( + conn, received_body_bytes, content_length + ) handler = self.route_handlers.get( _HTTPRoute(request.path, request.method), None