/ adafruit_httpserver.py
adafruit_httpserver.py
1 # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries 2 # 3 # SPDX-License-Identifier: MIT 4 """ 5 `adafruit_httpserver` 6 ================================================================================ 7 8 Simple HTTP Server for CircuitPython 9 10 11 * Author(s): Dan Halbert 12 13 Implementation Notes 14 -------------------- 15 16 **Software and Dependencies:** 17 18 * Adafruit CircuitPython firmware for the supported boards: 19 https://github.com/adafruit/circuitpython/releases 20 """ 21 22 try: 23 from typing import Any, Callable, Optional 24 except ImportError: 25 pass 26 27 from errno import EAGAIN, ECONNRESET 28 import os 29 30 __version__ = "0.0.0+auto.0" 31 __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" 32 33 34 class HTTPStatus: # pylint: disable=too-few-public-methods 35 """HTTP status codes.""" 36 37 def __init__(self, value, phrase): 38 """Define a status code. 39 40 :param int value: Numeric value: 200, 404, etc. 41 :param str phrase: Short phrase: "OK", "Not Found', etc. 42 """ 43 self.value = value 44 self.phrase = phrase 45 46 def __repr__(self): 47 return f'HTTPStatus({self.value}, "{self.phrase}")' 48 49 def __str__(self): 50 return f"{self.value} {self.phrase}" 51 52 53 HTTPStatus.NOT_FOUND = HTTPStatus(404, "Not Found") 54 """404 Not Found""" 55 HTTPStatus.OK = HTTPStatus(200, "OK") # pylint: disable=invalid-name 56 """200 OK""" 57 HTTPStatus.INTERNAL_SERVER_ERROR = HTTPStatus(500, "Internal Server Error") 58 """500 Internal Server Error""" 59 60 61 class _HTTPRequest: 62 def __init__( 63 self, path: str = "", method: str = "", raw_request: bytes = None 64 ) -> None: 65 self.raw_request = raw_request 66 if raw_request is None: 67 self.path = path 68 self.method = method 69 else: 70 # Parse request data from raw request 71 request_text = raw_request.decode("utf8") 72 first_line = request_text[: request_text.find("\n")] 73 try: 74 (self.method, self.path, _httpversion) = first_line.split() 75 except ValueError as exc: 76 raise ValueError("Unparseable raw_request: ", raw_request) from exc 77 78 def __hash__(self) -> int: 79 return hash(self.method) ^ hash(self.path) 80 81 def __eq__(self, other: "_HTTPRequest") -> bool: 82 return self.method == other.method and self.path == other.path 83 84 def __repr__(self) -> str: 85 return f"_HTTPRequest(path={repr(self.path)}, method={repr(self.method)})" 86 87 88 class MIMEType: 89 """Common MIME types. 90 From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types 91 """ 92 93 TEXT_PLAIN = "text/plain" 94 95 _MIME_TYPES = { 96 "aac": "audio/aac", 97 "abw": "application/x-abiword", 98 "arc": "application/x-freearc", 99 "avi": "video/x-msvideo", 100 "azw": "application/vnd.amazon.ebook", 101 "bin": "application/octet-stream", 102 "bmp": "image/bmp", 103 "bz": "application/x-bzip", 104 "bz2": "application/x-bzip2", 105 "csh": "application/x-csh", 106 "css": "text/css", 107 "csv": "text/csv", 108 "doc": "application/msword", 109 "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 110 "eot": "application/vnd.ms-fontobject", 111 "epub": "application/epub+zip", 112 "gz": "application/gzip", 113 "gif": "image/gif", 114 "html": "text/html", 115 "htm": "text/html", 116 "ico": "image/vnd.microsoft.icon", 117 "ics": "text/calendar", 118 "jar": "application/java-archive", 119 "jpeg .jpg": "image/jpeg", 120 "js": "text/javascript", 121 "json": "application/json", 122 "jsonld": "application/ld+json", 123 "mid": "audio/midi", 124 "midi": "audio/midi", 125 "mjs": "text/javascript", 126 "mp3": "audio/mpeg", 127 "cda": "application/x-cdf", 128 "mp4": "video/mp4", 129 "mpeg": "video/mpeg", 130 "mpkg": "application/vnd.apple.installer+xml", 131 "odp": "application/vnd.oasis.opendocument.presentation", 132 "ods": "application/vnd.oasis.opendocument.spreadsheet", 133 "odt": "application/vnd.oasis.opendocument.text", 134 "oga": "audio/ogg", 135 "ogv": "video/ogg", 136 "ogx": "application/ogg", 137 "opus": "audio/opus", 138 "otf": "font/otf", 139 "png": "image/png", 140 "pdf": "application/pdf", 141 "php": "application/x-httpd-php", 142 "ppt": "application/vnd.ms-powerpoint", 143 "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", 144 "rar": "application/vnd.rar", 145 "rtf": "application/rtf", 146 "sh": "application/x-sh", 147 "svg": "image/svg+xml", 148 "swf": "application/x-shockwave-flash", 149 "tar": "application/x-tar", 150 "tiff": "image/tiff", 151 "tif": "image/tiff", 152 "ts": "video/mp2t", 153 "ttf": "font/ttf", 154 "txt": TEXT_PLAIN, 155 "vsd": "application/vnd.visio", 156 "wav": "audio/wav", 157 "weba": "audio/webm", 158 "webm": "video/webm", 159 "webp": "image/webp", 160 "woff": "font/woff", 161 "woff2": "font/woff2", 162 "xhtml": "application/xhtml+xml", 163 "xls": "application/vnd.ms-excel", 164 "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 165 "xml": "application/xml", 166 "xul": "application/vnd.mozilla.xul+xml", 167 "zip": "application/zip", 168 "7z": "application/x-7z-compressed", 169 } 170 171 @staticmethod 172 def mime_type(filename): 173 """Return the mime type for the given filename. If not known, return "text/plain".""" 174 return MIMEType._MIME_TYPES.get(filename.split(".")[-1], MIMEType.TEXT_PLAIN) 175 176 177 class HTTPResponse: 178 """Details of an HTTP response. Use in `HTTPServer.route` decorator functions.""" 179 180 _HEADERS_FORMAT = ( 181 "HTTP/1.1 {}\r\n" 182 "Content-Type: {}\r\n" 183 "Content-Length: {}\r\n" 184 "Connection: close\r\n" 185 "\r\n" 186 ) 187 188 def __init__( 189 self, 190 *, 191 status: tuple = HTTPStatus.OK, 192 content_type: str = MIMEType.TEXT_PLAIN, 193 body: str = "", 194 filename: Optional[str] = None, 195 root: str = "", 196 ) -> None: 197 """Create an HTTP response. 198 199 :param tuple status: The HTTP status code to return, as a tuple of (int, "message"). 200 Common statuses are available in `HTTPStatus`. 201 :param str content_type: The MIME type of the data being returned. 202 Common MIME types are available in `MIMEType`. 203 :param Union[str|bytes] body: 204 The data to return in the response body, if ``filename`` is not ``None``. 205 :param str filename: If not ``None``, 206 return the contents of the specified file, and ignore ``body``. 207 :param str root: root directory for filename, without a trailing slash 208 """ 209 self.status = status 210 self.content_type = content_type 211 self.body = body.encode() if isinstance(body, str) else body 212 self.filename = filename 213 214 self.root = root 215 216 def send(self, conn: Any) -> None: 217 # TODO: Use Union[SocketPool.Socket | socket.socket] for the type annotation in some way. 218 """Send the constructed response over the given socket.""" 219 if self.filename: 220 try: 221 file_length = os.stat(self.root + self.filename)[6] 222 self._send_file_response(conn, self.filename, self.root, file_length) 223 except OSError: 224 self._send_response( 225 conn, 226 HTTPStatus.NOT_FOUND, 227 MIMEType.TEXT_PLAIN, 228 f"{HTTPStatus.NOT_FOUND} {self.filename}\r\n", 229 ) 230 else: 231 self._send_response(conn, self.status, self.content_type, self.body) 232 233 def _send_response(self, conn, status, content_type, body): 234 self._send_bytes( 235 conn, self._HEADERS_FORMAT.format(status, content_type, len(body)) 236 ) 237 self._send_bytes(conn, body) 238 239 def _send_file_response(self, conn, filename, root, file_length): 240 self._send_bytes( 241 conn, 242 self._HEADERS_FORMAT.format( 243 self.status, MIMEType.mime_type(filename), file_length 244 ), 245 ) 246 with open(root + filename, "rb") as file: 247 while bytes_read := file.read(2048): 248 self._send_bytes(conn, bytes_read) 249 250 def _send_bytes(self, conn, buf): # pylint: disable=no-self-use 251 bytes_sent = 0 252 bytes_to_send = len(buf) 253 view = memoryview(buf) 254 while bytes_sent < bytes_to_send: 255 try: 256 bytes_sent += conn.send(view[bytes_sent:]) 257 except OSError as exc: 258 if exc.errno == EAGAIN: 259 continue 260 if exc.errno == ECONNRESET: 261 return 262 263 264 class HTTPServer: 265 """A basic socket-based HTTP server.""" 266 267 def __init__(self, socket_source: Any) -> None: 268 # TODO: Use a Protocol for the type annotation. 269 # The Protocol could be refactored from adafruit_requests. 270 """Create a server, and get it ready to run. 271 272 :param socket: An object that is a source of sockets. This could be a `socketpool` 273 in CircuitPython or the `socket` module in CPython. 274 """ 275 self._buffer = bytearray(1024) 276 self.routes = {} 277 self._socket_source = socket_source 278 self._sock = None 279 self.root_path = "/" 280 281 def route(self, path: str, method: str = "GET"): 282 """Decorator used to add a route. 283 284 :param str path: filename path 285 :param str method: HTTP method: "GET", "POST", etc. 286 287 Example:: 288 289 @server.route(path, method) 290 def route_func(request): 291 raw_text = request.raw_request.decode("utf8") 292 print("Received a request of length", len(raw_text), "bytes") 293 return HTTPResponse(body="hello world") 294 295 """ 296 297 def route_decorator(func: Callable) -> Callable: 298 self.routes[_HTTPRequest(path, method)] = func 299 return func 300 301 return route_decorator 302 303 def serve_forever(self, host: str, port: int = 80, root: str = "") -> None: 304 """Wait for HTTP requests at the given host and port. Does not return. 305 306 :param str host: host name or IP address 307 :param int port: port 308 :param str root: root directory to serve files from 309 """ 310 self.start(host, port, root) 311 312 while True: 313 try: 314 self.poll() 315 except OSError: 316 continue 317 318 def start(self, host: str, port: int = 80, root: str = "") -> None: 319 """ 320 Start the HTTP server at the given host and port. Requires calling 321 poll() in a while loop to handle incoming requests. 322 323 :param str host: host name or IP address 324 :param int port: port 325 :param str root: root directory to serve files from 326 """ 327 self.root_path = root 328 329 self._sock = self._socket_source.socket( 330 self._socket_source.AF_INET, self._socket_source.SOCK_STREAM 331 ) 332 self._sock.bind((host, port)) 333 self._sock.listen(10) 334 self._sock.setblocking(False) # non-blocking socket 335 336 def poll(self): 337 """ 338 Call this method inside your main event loop to get the server to 339 check for new incoming client requests. When a request comes in, 340 the application callable will be invoked. 341 """ 342 try: 343 conn, _ = self._sock.accept() 344 with conn: 345 length, _ = conn.recvfrom_into(self._buffer) 346 347 request = _HTTPRequest(raw_request=self._buffer[:length]) 348 349 # If a route exists for this request, call it. Otherwise try to serve a file. 350 route = self.routes.get(request, None) 351 if route: 352 response = route(request) 353 elif request.method == "GET": 354 response = HTTPResponse(filename=request.path, root=self.root_path) 355 else: 356 response = HTTPResponse(status=HTTPStatus.INTERNAL_SERVER_ERROR) 357 358 response.send(conn) 359 except OSError as ex: 360 # handle EAGAIN and ECONNRESET 361 if ex.errno == EAGAIN: 362 # there is no data available right now, try again later. 363 return 364 if ex.errno == ECONNRESET: 365 # connection reset by peer, try again later. 366 return 367 raise 368 369 @property 370 def request_buffer_size(self) -> int: 371 """ 372 The maximum size of the incoming request buffer. If the default size isn't 373 adequate to handle your incoming data you can set this after creating the 374 server instance. 375 376 Default size is 1024 bytes. 377 378 Example:: 379 380 server = HTTPServer(pool) 381 server.request_buffer_size = 2048 382 383 server.serve_forever(str(wifi.radio.ipv4_address)) 384 """ 385 return len(self._buffer) 386 387 @request_buffer_size.setter 388 def request_buffer_size(self, value: int) -> None: 389 self._buffer = bytearray(value)