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/methods.py b/adafruit_httpserver/methods.py new file mode 100644 index 0000000..319b631 --- /dev/null +++ b/adafruit_httpserver/methods.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.methods.HTTPMethod` +==================================================== +* Author(s): Michał Pokusa +""" + + +class HTTPMethod: # pylint: disable=too-few-public-methods + """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 new file mode 100644 index 0000000..39e592e --- /dev/null +++ b/adafruit_httpserver/mime_type.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.mime_type.MIMEType` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + + +class MIMEType: + """Common MIME types. + From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + """ + + TYPE_AAC = "audio/aac" + TYPE_ABW = "application/x-abiword" + TYPE_ARC = "application/x-freearc" + TYPE_AVI = "video/x-msvideo" + TYPE_AZW = "application/vnd.amazon.ebook" + TYPE_BIN = "application/octet-stream" + TYPE_BMP = "image/bmp" + TYPE_BZ = "application/x-bzip" + TYPE_BZ2 = "application/x-bzip2" + TYPE_CSH = "application/x-csh" + TYPE_CSS = "text/css" + TYPE_CSV = "text/csv" + TYPE_DOC = "application/msword" + TYPE_DOCX = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + TYPE_EOT = "application/vnd.ms-fontobject" + TYPE_EPUB = "application/epub+zip" + TYPE_GZ = "application/gzip" + TYPE_GIF = "image/gif" + TYPE_HTML = "text/html" + TYPE_HTM = "text/html" + TYPE_ICO = "image/vnd.microsoft.icon" + TYPE_ICS = "text/calendar" + TYPE_JAR = "application/java-archive" + TYPE_JPEG = "image/jpeg" + TYPE_JPG = "image/jpeg" + TYPE_JS = "text/javascript" + TYPE_JSON = "application/json" + TYPE_JSONLD = "application/ld+json" + TYPE_MID = "audio/midi" + TYPE_MIDI = "audio/midi" + TYPE_MJS = "text/javascript" + TYPE_MP3 = "audio/mpeg" + TYPE_CDA = "application/x-cdf" + TYPE_MP4 = "video/mp4" + TYPE_MPEG = "video/mpeg" + TYPE_MPKG = "application/vnd.apple.installer+xml" + TYPE_ODP = "application/vnd.oasis.opendocument.presentation" + TYPE_ODS = "application/vnd.oasis.opendocument.spreadsheet" + TYPE_ODT = "application/vnd.oasis.opendocument.text" + TYPE_OGA = "audio/ogg" + TYPE_OGV = "video/ogg" + TYPE_OGX = "application/ogg" + TYPE_OPUS = "audio/opus" + TYPE_OTF = "font/otf" + TYPE_PNG = "image/png" + TYPE_PDF = "application/pdf" + TYPE_PHP = "application/x-httpd-php" + TYPE_PPT = "application/vnd.ms-powerpoint" + TYPE_PPTX = ( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + TYPE_RAR = "application/vnd.rar" + TYPE_RTF = "application/rtf" + TYPE_SH = "application/x-sh" + TYPE_SVG = "image/svg+xml" + TYPE_SWF = "application/x-shockwave-flash" + TYPE_TAR = "application/x-tar" + TYPE_TIFF = "image/tiff" + TYPE_TIF = "image/tiff" + TYPE_TS = "video/mp2t" + TYPE_TTF = "font/ttf" + TYPE_TXT = "text/plain" + TYPE_VSD = "application/vnd.visio" + TYPE_WAV = "audio/wav" + TYPE_WEBA = "audio/webm" + TYPE_WEBM = "video/webm" + TYPE_WEBP = "image/webp" + TYPE_WOFF = "font/woff" + TYPE_WOFF2 = "font/woff2" + TYPE_XHTML = "application/xhtml+xml" + TYPE_XLS = "application/vnd.ms-excel" + TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + TYPE_XML = "application/xml" + TYPE_XUL = "application/vnd.mozilla.xul+xml" + TYPE_ZIP = "application/zip" + TYPE_7Z = "application/x-7z-compressed" + + @staticmethod + def from_file_name(filename: str): + """Return the mime type for the given filename. If not known, return "text/plain".""" + attr_name = "TYPE_" + filename.split(".")[-1].upper() + + return getattr(MIMEType, attr_name, MIMEType.TYPE_TXT) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py new file mode 100644 index 0000000..22758a8 --- /dev/null +++ b/adafruit_httpserver/request.py @@ -0,0 +1,131 @@ +# 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: + pass + + +class HTTPRequest: + """ + Incoming request, constructed from raw incoming bytes. + It 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] + """ + 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.""" + + 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") + + header_bytes = self.header_body_bytes[0] + + try: + ( + self.method, + self.path, + self.query_params, + self.http_version, + ) = self._parse_start_line(header_bytes) + self.headers = self._parse_headers(header_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.""" + + start_line = header_bytes.decode("utf8").splitlines()[0] + + method, path, http_version = start_line.split() + + if "?" not in path: + path += "?" + + path, query_string = path.split("?", 1) + + 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 + + @staticmethod + def _parse_headers(header_bytes: bytes) -> Dict[str, str]: + """Parse HTTP headers from raw request.""" + header_lines = header_bytes.decode("utf8").splitlines()[1:] + + return { + name.lower(): value + for header_line in header_lines + for name, value in [header_line.split(": ", 1)] + } diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py new file mode 100644 index 0000000..a44cba1 --- /dev/null +++ b/adafruit_httpserver/response.py @@ -0,0 +1,172 @@ +# 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, Tuple + from socket import socket + from socketpool import SocketPool +except ImportError: + pass + +from errno import EAGAIN, ECONNRESET +import os + + +from .mime_type import MIMEType +from .status import HTTPStatus, CommonHTTPStatus + + +class HTTPResponse: + """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" + + http_version: str + status: HTTPStatus + headers: Dict[str, str] + content_type: str + + filename: Optional[str] + root_path: str + + body: str + + def __init__( # pylint: disable=too-many-arguments + self, + status: Union[HTTPStatus, Tuple[int, str]] = CommonHTTPStatus.OK_200, + body: str = "", + headers: Dict[str, str] = None, + content_type: str = MIMEType.TYPE_TXT, + filename: Optional[str] = None, + root_path: str = "", + http_version: str = "HTTP/1.1", + ) -> None: + """ + Creates an HTTP response. + + Returns ``body`` if ``filename`` is ``None``, otherwise returns contents of ``filename``. + """ + self.status = status if isinstance(status, HTTPStatus) else HTTPStatus(*status) + self.body = body + self.headers = headers or {} + self.content_type = content_type + self.filename = filename + self.root_path = root_path + self.http_version = http_version + + @staticmethod + def _construct_response_bytes( # pylint: disable=too-many-arguments + http_version: str = "HTTP/1.1", + status: HTTPStatus = CommonHTTPStatus.OK_200, + content_type: str = MIMEType.TYPE_TXT, + content_length: Union[int, None] = None, + headers: Dict[str, str] = None, + body: str = "", + ) -> bytes: + """Constructs the response bytes from the given parameters.""" + + response = f"{http_version} {status.code} {status.text}\r\n" + + headers = headers or {} + + headers.setdefault("Content-Type", content_type) + headers.setdefault("Content-Length", content_length or len(body)) + headers.setdefault("Connection", "close") + + for header, value in headers.items(): + response += f"{header}: {value}\r\n" + + response += f"\r\n{body}" + + return response.encode("utf-8") + + 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_path + self.filename)[6] + self._send_file_response( + conn, + 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.TYPE_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, + ) + + def _send_response( # pylint: disable=too-many-arguments + self, + conn: Union["SocketPool.Socket", "socket.socket"], + status: HTTPStatus, + content_type: str, + body: str, + headers: Dict[str, str] = None, + ): + self._send_bytes( + conn, + self._construct_response_bytes( + status=status, + content_type=content_type, + headers=headers, + body=body, + ), + ) + + def _send_file_response( # pylint: disable=too-many-arguments + self, + conn: Union["SocketPool.Socket", "socket.socket"], + filename: str, + root_path: str, + 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, + headers=headers, + ), + ) + with open(root_path + filename, "rb") as file: + while bytes_read := file.read(2048): + self._send_bytes(conn, bytes_read) + + @staticmethod + def _send_bytes( + conn: Union["SocketPool.Socket", "socket.socket"], + buffer: Union[bytes, bytearray, memoryview], + ): + bytes_sent = 0 + bytes_to_send = len(buffer) + view = memoryview(buffer) + while bytes_sent < bytes_to_send: + try: + bytes_sent += conn.send(view[bytes_sent:]) + except OSError as exc: + if exc.errno == EAGAIN: + continue + if exc.errno == ECONNRESET: + return diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py new file mode 100644 index 0000000..78d8c6c --- /dev/null +++ b/adafruit_httpserver/route.py @@ -0,0 +1,28 @@ +# 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: + """Route definition for different paths, see `adafruit_httpserver.server.HTTPServer.route`.""" + + 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 new file mode 100644 index 0000000..8bf1045 --- /dev/null +++ b/adafruit_httpserver/server.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.server.HTTPServer` +==================================================== +* Author(s): Dan Halbert, Michał Pokusa +""" + +try: + from typing import Callable, Protocol, Union + from socket import socket + from socketpool import SocketPool +except ImportError: + pass + +from errno import EAGAIN, ECONNRESET, ETIMEDOUT + +from .methods import HTTPMethod +from .request import HTTPRequest +from .response import HTTPResponse +from .route import _HTTPRoute +from .status import CommonHTTPStatus + + +class HTTPServer: + """A basic socket-based HTTP server.""" + + 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` + in CircuitPython or the `socket` module in CPython. + """ + self._buffer = bytearray(1024) + self._timeout = 1 + self.route_handlers = {} + self._socket_source = socket_source + self._sock = None + self.root_path = "/" + + def route(self, path: str, method: HTTPMethod = HTTPMethod.GET): + """Decorator used to add a route. + + :param str path: filename path + :param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.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.route_handlers[_HTTPRoute(path, method)] = func + return func + + return route_decorator + + 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_path) + + while True: + try: + self.poll() + except OSError: + continue + + 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. + + :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_path + + 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 _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 + 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.settimeout(self._timeout) + + # Receiving data until empty line + header_bytes = self._receive_header_bytes(conn) + + # Return if no data received + if not header_bytes: + return + + 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 + ) + + # 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. + elif request.method == HTTPMethod.GET: + 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.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) + + @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 ValueError( + "HTTPServer.socket_timeout must be a positive numeric value." + ) diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py new file mode 100644 index 0000000..d32538c --- /dev/null +++ b/adafruit_httpserver/status.py @@ -0,0 +1,46 @@ +# 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 code: Numeric value: 200, 404, etc. + :param str text: Short phrase: "OK", "Not Found', etc. + """ + self.code = code + self.text = text + + def __repr__(self): + return f'HTTPStatus({self.code}, "{self.text}")' + + 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.""" + + 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""" diff --git a/docs/api.rst b/docs/api.rst index 1bfce0b..cf4ba22 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,3 +6,21 @@ .. 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.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)