/ 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)