From 537874f7177f6982f0b798199acc2fd67b7d5b77 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 25 May 2023 19:32:28 +0000 Subject: [PATCH 01/16] Skipping empty chunks of data in ChunkedResponse --- adafruit_httpserver/response.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 0310f90..78ad180 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -305,7 +305,8 @@ def _send(self) -> None: self._send_headers() for chunk in self._body(): - self._send_chunk(chunk) + if 0 < len(chunk): # Don't send empty chunks + self._send_chunk(chunk) # Empty chunk to indicate end of response self._send_chunk() From 06dcf7abe2de954d048c4014b6fc78dbec9af993 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 26 May 2023 01:17:23 +0000 Subject: [PATCH 02/16] Updated docstrings of authentication related functions --- adafruit_httpserver/authentication.py | 8 ++++++++ adafruit_httpserver/server.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py index 211ab63..c5b1b60 100644 --- a/adafruit_httpserver/authentication.py +++ b/adafruit_httpserver/authentication.py @@ -41,6 +41,10 @@ def __str__(self) -> str: def check_authentication(request: Request, auths: List[Union[Basic, Bearer]]) -> bool: """ Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise. + + Example:: + + check_authentication(request, [Basic("username", "password")]) """ auth_header = request.headers.get("Authorization") @@ -56,6 +60,10 @@ def require_authentication(request: Request, auths: List[Union[Basic, Bearer]]) Checks if the request is authorized and raises ``AuthenticationError`` if not. If the error is not caught, the server will return ``401 Unauthorized``. + + Example:: + + require_authentication(request, [Basic("username", "password")]) """ if not check_authentication(request, auths): diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1666df5..e0a4347 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -362,7 +362,7 @@ def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: Example:: server = Server(pool, "/static") - server.require_authentication([Basic("user", "pass")]) + server.require_authentication([Basic("username", "password")]) """ self._auths = auths From f62899ca713c986c63b4cd693785a4ceeff17e31 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 27 May 2023 09:00:54 +0000 Subject: [PATCH 03/16] Added Server.headers and updated docs for it --- adafruit_httpserver/server.py | 35 +++++++++++++++++++++++-- docs/examples.rst | 10 +++++-- examples/httpserver_cpu_information.py | 5 ++++ examples/httpserver_multiple_servers.py | 3 +++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index e0a4347..0978a80 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,7 +8,7 @@ """ try: - from typing import Callable, Protocol, Union, List, Set, Tuple + from typing import Callable, Protocol, Union, List, Set, Tuple, Dict from socket import socket from socketpool import SocketPool except ImportError: @@ -25,6 +25,7 @@ InvalidPathError, ServingFilesDisabledError, ) +from .headers import Headers from .methods import GET, HEAD from .request import Request from .response import Response, FileResponse @@ -32,7 +33,7 @@ from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 -class Server: +class Server: # pylint: disable=too-many-instance-attributes """A basic socket-based HTTP server.""" host: str = None @@ -57,6 +58,7 @@ def __init__( self._routes = _Routes() self._socket_source = socket_source self._sock = None + self.headers = Headers() self.root_path = root_path if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) @@ -306,6 +308,12 @@ def _handle_request( status=NOT_FOUND_404, ) + def _set_default_server_headers(self, response: Response) -> None: + for name, value in self.headers.items(): + response._headers.setdefault( # pylint: disable=protected-access + name, value + ) + def poll(self): """ Call this method inside your main loop to get the server to check for new incoming client @@ -334,6 +342,8 @@ def poll(self): if response is None: return + self._set_default_server_headers(response) + # Send the response response._send() # pylint: disable=protected-access @@ -366,6 +376,27 @@ def require_authentication(self, auths: List[Union[Basic, Bearer]]) -> None: """ self._auths = auths + @property + def headers(self) -> Headers: + """ + Headers to be sent with every response, without the need to specify them in each handler. + + If a header is specified in both the handler and the server, the handler's header will be + used. + + Example:: + + server = Server(pool, "/static") + server.headers = { + "Access-Control-Allow-Origin": "*", + } + """ + return self._headers + + @headers.setter + def headers(self, value: Union[Headers, Dict[str, str]]) -> None: + self._headers = value.copy() if isinstance(value, Headers) else Headers(value) + @property def request_buffer_size(self) -> int: """ diff --git a/docs/examples.rst b/docs/examples.rst index 683c094..e6880b8 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -97,9 +97,12 @@ Get CPU information You can return data from sensors or any computed value as JSON. That makes it easy to use the data in other applications. +If you want to use the data in a web browser, it might be necessary to enable CORS. +More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS + .. literalinclude:: ../examples/httpserver_cpu_information.py :caption: examples/httpserver_cpu_information.py - :emphasize-lines: 9,27 + :emphasize-lines: 9,14-17,32 :linenos: Handling different methods @@ -233,12 +236,15 @@ Using ``.serve_forever()`` for this is not possible because of it's blocking beh Each server **must have a different port number**. +In order to distinguish between responses from different servers a 'X-Server' header is added to each response. +**This is an optional step**, both servers will work without it. + In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. You can share same handler functions between servers or use different ones for each server. .. literalinclude:: ../examples/httpserver_multiple_servers.py :caption: examples/httpserver_multiple_servers.py - :emphasize-lines: 13-14,17,25,33-34,45-46,51-52 + :emphasize-lines: 13-14,16-17,20,28,36-37,48-49,54-55 :linenos: Debug mode diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index 96ed985..2557074 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -11,6 +11,11 @@ pool = socketpool.SocketPool(wifi.radio) server = Server(pool, debug=True) +# (Optional) Allow cross-origin requests. +server.headers = { + "Access-Control-Allow-Origin": "*", +} + @server.route("/cpu-information", append_slash=True) def cpu_information_handler(request: Request): diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py index 1ac3ed4..88047a3 100644 --- a/examples/httpserver_multiple_servers.py +++ b/examples/httpserver_multiple_servers.py @@ -11,7 +11,10 @@ pool = socketpool.SocketPool(wifi.radio) bedroom_server = Server(pool, "/bedroom", debug=True) +bedroom_server.headers["X-Server"] = "Bedroom" + office_server = Server(pool, "/office", debug=True) +office_server.headers["X-Server"] = "Office" @bedroom_server.route("/bedroom") From a56a507374a1480b6647bc0d3aaa0fa13d102357 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 27 May 2023 14:22:55 +0000 Subject: [PATCH 04/16] Changes to some Server docstrings --- adafruit_httpserver/server.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 0978a80..040467c 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -36,11 +36,14 @@ class Server: # pylint: disable=too-many-instance-attributes """A basic socket-based HTTP server.""" - host: str = None - """Host name or IP address the server is listening on.""" + host: str + """Host name or IP address the server is listening on. ``None`` if server is stopped.""" - port: int = None - """Port the server is listening on.""" + port: int + """Port the server is listening on. ``None`` if server is stopped.""" + + root_path: str + """Root directory to serve files from. ``None`` if serving files is disabled.""" def __init__( self, socket_source: Protocol, root_path: str = None, *, debug: bool = False @@ -59,6 +62,7 @@ def __init__( self._socket_source = socket_source self._sock = None self.headers = Headers() + self.host, self.port = None, None self.root_path = root_path if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) @@ -388,6 +392,7 @@ def headers(self) -> Headers: server = Server(pool, "/static") server.headers = { + "X-Server": "Adafruit CircuitPython HTTP Server", "Access-Control-Allow-Origin": "*", } """ From 63e7eb8372cc2088c71ee578ec237300157ffeb1 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 8 Jun 2023 23:38:37 +0000 Subject: [PATCH 05/16] Added docs and example about **params --- docs/examples.rst | 4 +++- examples/httpserver_url_parameters.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/examples.rst b/docs/examples.rst index e6880b8..7e0c4ad 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -180,6 +180,8 @@ In the example below the second route has only one URL parameter, so the ``actio Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type. Also note that the names of the function parameters **have to match** with the ones used in route, but they **do not have to** be in the same order. +Alternatively you can use e.g. ``**params`` to get all the parameters as a dictionary and access them using ``params['parameter_name']``. + It is also possible to specify a wildcard route: - ``...`` - matches one path segment, e.g ``/api/...`` will match ``/api/123``, but **not** ``/api/123/456`` @@ -189,7 +191,7 @@ In both cases, wildcards will not match empty path segment, so ``/api/.../users` .. literalinclude:: ../examples/httpserver_url_parameters.py :caption: examples/httpserver_url_parameters.py - :emphasize-lines: 30-34,53-54 + :emphasize-lines: 30-34,53-54,65-66 :linenos: Authentication diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py index 9905bc4..fd38988 100644 --- a/examples/httpserver_url_parameters.py +++ b/examples/httpserver_url_parameters.py @@ -50,6 +50,18 @@ def perform_action( ) +@server.route("/device//status/") +def device_status_on_date(request: Request, **params: dict): + """ + Return the status of a specified device between two dates. + """ + + device_id = params.get("device_id") + date = params.get("date") + + return Response(request, f"Status of {device_id} on {date}: ...") + + @server.route("/device/.../status", append_slash=True) @server.route("/device/....", append_slash=True) def device_status(request: Request): From e05a5f2c3e190f5c14f13842994869409445840f Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 10 Jun 2023 08:43:04 +0000 Subject: [PATCH 06/16] Added missing newline to CPU information example --- docs/examples.rst | 2 +- examples/httpserver_cpu_information.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/examples.rst b/docs/examples.rst index 7e0c4ad..8cdacbe 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -102,7 +102,7 @@ More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS .. literalinclude:: ../examples/httpserver_cpu_information.py :caption: examples/httpserver_cpu_information.py - :emphasize-lines: 9,14-17,32 + :emphasize-lines: 9,15-18,33 :linenos: Handling different methods diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index 2557074..22abe74 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -8,6 +8,7 @@ from adafruit_httpserver import Server, Request, JSONResponse + pool = socketpool.SocketPool(wifi.radio) server = Server(pool, debug=True) From 6a60edc57577f570f8799bbd145c629f5da5cac3 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 10 Jun 2023 12:34:18 +0000 Subject: [PATCH 07/16] Added FormData class --- adafruit_httpserver/__init__.py | 2 +- adafruit_httpserver/request.py | 112 +++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index cb152b2..83bc086 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -51,7 +51,7 @@ CONNECT, ) from .mime_types import MIMETypes -from .request import Request +from .request import FormData, Request from .response import ( Response, FileResponse, diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index e911570..bf2f094 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -8,7 +8,7 @@ """ try: - from typing import Dict, Tuple, Union, TYPE_CHECKING + from typing import List, Dict, Tuple, Union, Any, TYPE_CHECKING from socket import socket from socketpool import SocketPool @@ -22,6 +22,108 @@ from .headers import Headers +class FormData: + """ + Class for parsing and storing form data from POST requests. + + Supports ``application/x-www-form-urlencoded``, ``multipart/form-data`` and ``text/plain`` + content types. + + Examples:: + + form_data = FormData(b"foo=bar&baz=qux", "application/x-www-form-urlencoded") + + # or + + form_data = FormData(b"foo=bar\\r\\nbaz=qux", "text/plain") + + # FormData({"foo": "bar", "baz": "qux"}) + + form_data.get("foo") # "bar" + form_data["foo"] # "bar" + form_data.get("non-existent-key") # None + form_data.get_list("baz") # ["qux"] + "unknown-key" in form_data # False + form_data.fields # ["foo", "baz"] + """ + + _storage: Dict[str, List[Union[str, bytes]]] + + def __init__(self, data: bytes, content_type: str) -> None: + self.content_type = content_type + self._storage = {} + + if content_type.startswith("application/x-www-form-urlencoded"): + self._parse_x_www_form_urlencoded(data) + + elif content_type.startswith("multipart/form-data"): + boundary = content_type.split("boundary=")[1] + self._parse_multipart_form_data(data, boundary) + + elif content_type.startswith("text/plain"): + self._parse_text_plain(data) + + def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None: + if field_name not in self._storage: + self._storage[field_name] = [value] + else: + self._storage[field_name].append(value) + + def _parse_x_www_form_urlencoded(self, data: bytes) -> None: + decoded_data = data.decode() + + for field_name, value in [ + key_value.split("=", 1) for key_value in decoded_data.split("&") + ]: + self._add_field_value(field_name, value) + + def _parse_multipart_form_data(self, data: bytes, boundary: str) -> None: + blocks = data.split(b"--" + boundary.encode())[1:-1] + + for block in blocks: + disposition, content = block.split(b"\r\n\r\n", 1) + field_name = disposition.split(b'"', 2)[1].decode() + value = content[:-2] + + self._add_field_value(field_name, value) + + def _parse_text_plain(self, data: bytes) -> None: + lines = data.split(b"\r\n")[:-1] + + for line in lines: + field_name, value = line.split(b"=", 1) + + self._add_field_value(field_name.decode(), value.decode()) + + def get(self, field_name: str, default: Any = None) -> Union[str, bytes, None]: + """Get the value of a field.""" + return self._storage.get(field_name, [default])[0] + + def get_list(self, field_name: str) -> List[Union[str, bytes]]: + """Get the list of values of a field.""" + return self._storage.get(field_name, []) + + @property + def fields(self): + """Returns a list of field names.""" + return list(self._storage.keys()) + + def __getitem__(self, field_name: str): + return self.get(field_name) + + def __iter__(self): + return iter(self._storage) + + def __len__(self): + return len(self._storage) + + def __contains__(self, key: str): + return key in self._storage + + def __repr__(self) -> str: + return f"FormData({repr(self._storage)})" + + class Request: """ Incoming request, constructed from raw incoming bytes. @@ -91,6 +193,7 @@ def __init__( self.connection = connection self.client_address = client_address self.raw_request = raw_request + self._form_data = None if raw_request is None: raise ValueError("raw_request cannot be None") @@ -117,6 +220,13 @@ def body(self) -> bytes: def body(self, body: bytes) -> None: self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body + @property + def form_data(self) -> Union[FormData, None]: + """POST data of the request""" + if self._form_data is None and self.method == "POST": + self._form_data = FormData(self.body, self.headers["Content-Type"]) + return self._form_data + def json(self) -> Union[dict, None]: """Body of the request, as a JSON-decoded dictionary.""" return json.loads(self.body) if self.body else None From 6d7ecd3360db60e0bf5fc55af34649166a74a3d8 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 10 Jun 2023 12:36:18 +0000 Subject: [PATCH 08/16] Updated docs and examples about FormData --- docs/examples.rst | 28 ++++++++++++++ examples/httpserver_form_data.py | 66 ++++++++++++++++++++++++++++++++ examples/httpserver_neopixel.py | 3 ++ 3 files changed, 97 insertions(+) create mode 100644 examples/httpserver_form_data.py diff --git a/docs/examples.rst b/docs/examples.rst index 8cdacbe..0a04ea5 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -146,6 +146,34 @@ Tested on ESP32-S2 Feather. :emphasize-lines: 25-27,39,51,60,66 :linenos: +Form data parsing +--------------------- + +Another way to pass data to the handler function is to use form data. +Remember that it is only possible to use it with ``POST`` method. +`More about POST method. `_ + +It is important to use correct ``enctype``, depending on the type of data you want to send. + +- ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces. + If you use it, values will be automatically parsed as strings, but special characters will be URL encoded + e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"`` +- ``multipart/form-data`` - For sending text and binary files and/or text data with special characters + When used, values will **not** be automatically parsed as strings, they will stay as bytes instead. + e.g. ``"Hello World! ^-$%"`` will be saved as ``b'Hello World! ^-$%'``, which can be decoded using ``.decode()`` method. +- ``text/plain`` - For sending text data with special characters. + If used, values will be automatically parsed as strings, including special characters, emojis etc. + e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello World! ^-$%"``, this is the **recommended** option. + +If you pass multiple values with the same name, they will be saved as a list, that can be accessed using ``request.data.get_list()``. +Even if there is only one value, it will still get a list, and if there multiple values, but you use ``request.data.get()`` it will +return only the first one. + +.. literalinclude:: ../examples/httpserver_form_data.py + :caption: examples/httpserver_form_data.py + :emphasize-lines: 32,47,50 + :linenos: + Chunked response ---------------- diff --git a/examples/httpserver_form_data.py b/examples/httpserver_form_data.py new file mode 100644 index 0000000..c077ec6 --- /dev/null +++ b/examples/httpserver_form_data.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response, GET, POST + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +FORM_HTML_TEMPLATE = """ + + + Form with {enctype} enctype + + + + +
+ + +
+ + +
+ +

Form with {enctype} enctype

+
+ + +
+ {submitted_value} + + +""" + + +@server.route("/form", [GET, POST]) +def form(request: Request): + """ + Serve a form with the given enctype, and display back the submitted value. + """ + enctype = request.query_params.get("enctype", "text/plain") + + if request.method == POST: + posted_value = request.data.get("something") + + return Response( + request, + FORM_HTML_TEMPLATE.format( + enctype=enctype, + submitted_value=( + f"

Submitted form value: {posted_value}

" + if request.method == POST + else "" + ), + ), + content_type="text/html", + ) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 7e7dab3..ad80ba3 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -37,6 +37,9 @@ def change_neopixel_color_handler_post_body(request: Request): data = request.body # e.g b"255,0,0" r, g, b = data.decode().split(",") # ["255", "0", "0"] + # or + data = request.data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0 + r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) pixel.fill((int(r), int(g), int(b))) From a74ae99f7ebfc2bcb76d29badb50c9384608ca6a Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 10 Jun 2023 13:04:29 +0000 Subject: [PATCH 09/16] Added QueryParams and interface for both storage classes --- adafruit_httpserver/__init__.py | 2 +- adafruit_httpserver/request.py | 127 +++++++++++++++++++------------- 2 files changed, 78 insertions(+), 51 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 83bc086..1861a1d 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -51,7 +51,7 @@ CONNECT, ) from .mime_types import MIMETypes -from .request import FormData, Request +from .request import QueryParams, FormData, Request from .response import ( Response, FileResponse, diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index bf2f094..d276234 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -22,7 +22,77 @@ from .headers import Headers -class FormData: +class _IFieldStorage: + """Interface with shared methods for QueryParams and FormData.""" + + _storage: Dict[str, List[Union[str, bytes]]] + + def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None: + if field_name not in self._storage: + self._storage[field_name] = [value] + else: + self._storage[field_name].append(value) + + def get(self, field_name: str, default: Any = None) -> Union[str, bytes, None]: + """Get the value of a field.""" + return self._storage.get(field_name, [default])[0] + + def get_list(self, field_name: str) -> List[Union[str, bytes]]: + """Get the list of values of a field.""" + return self._storage.get(field_name, []) + + @property + def fields(self): + """Returns a list of field names.""" + return list(self._storage.keys()) + + def __getitem__(self, field_name: str): + return self.get(field_name) + + def __iter__(self): + return iter(self._storage) + + def __len__(self): + return len(self._storage) + + def __contains__(self, key: str): + return key in self._storage + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self._storage)})" + + +class QueryParams(_IFieldStorage): + """ + Class for parsing and storing GET quer parameters requests. + + Examples:: + + query_params = QueryParams(b"foo=bar&baz=qux&baz=quux") + # QueryParams({"foo": "bar", "baz": ["qux", "quux"]}) + + query_params.get("foo") # "bar" + query_params["foo"] # "bar" + query_params.get("non-existent-key") # None + query_params.get_list("baz") # ["qux", "quux"] + "unknown-key" in query_params # False + query_params.fields # ["foo", "baz"] + """ + + _storage: Dict[str, List[Union[str, bytes]]] + + def __init__(self, query_string: str) -> None: + self._storage = {} + + for query_param in query_string.split("&"): + if "=" in query_param: + key, value = query_param.split("=", 1) + self._add_field_value(key, value) + elif query_param: + self._add_field_value(query_param, "") + + +class FormData(_IFieldStorage): """ Class for parsing and storing form data from POST requests. @@ -31,18 +101,15 @@ class FormData: Examples:: - form_data = FormData(b"foo=bar&baz=qux", "application/x-www-form-urlencoded") - + form_data = FormData(b"foo=bar&baz=qux&baz=quuz", "application/x-www-form-urlencoded") # or - - form_data = FormData(b"foo=bar\\r\\nbaz=qux", "text/plain") - + form_data = FormData(b"foo=bar\\r\\nbaz=qux\\r\\nbaz=quux", "text/plain") # FormData({"foo": "bar", "baz": "qux"}) form_data.get("foo") # "bar" form_data["foo"] # "bar" form_data.get("non-existent-key") # None - form_data.get_list("baz") # ["qux"] + form_data.get_list("baz") # ["qux", "quux"] "unknown-key" in form_data # False form_data.fields # ["foo", "baz"] """ @@ -63,12 +130,6 @@ def __init__(self, data: bytes, content_type: str) -> None: elif content_type.startswith("text/plain"): self._parse_text_plain(data) - def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None: - if field_name not in self._storage: - self._storage[field_name] = [value] - else: - self._storage[field_name].append(value) - def _parse_x_www_form_urlencoded(self, data: bytes) -> None: decoded_data = data.decode() @@ -95,34 +156,6 @@ def _parse_text_plain(self, data: bytes) -> None: self._add_field_value(field_name.decode(), value.decode()) - def get(self, field_name: str, default: Any = None) -> Union[str, bytes, None]: - """Get the value of a field.""" - return self._storage.get(field_name, [default])[0] - - def get_list(self, field_name: str) -> List[Union[str, bytes]]: - """Get the list of values of a field.""" - return self._storage.get(field_name, []) - - @property - def fields(self): - """Returns a list of field names.""" - return list(self._storage.keys()) - - def __getitem__(self, field_name: str): - return self.get(field_name) - - def __iter__(self): - return iter(self._storage) - - def __len__(self): - return len(self._storage) - - def __contains__(self, key: str): - return key in self._storage - - def __repr__(self) -> str: - return f"FormData({repr(self._storage)})" - class Request: """ @@ -156,7 +189,7 @@ class Request: path: str """Path of the request, e.g. ``"/foo/bar"``.""" - query_params: Dict[str, str] + query_params: QueryParams """ Query/GET parameters in the request. @@ -164,7 +197,7 @@ class Request: request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...") request.query_params - # {"foo": "bar"} + # QueryParams({"foo": "bar"}) """ http_version: str @@ -258,13 +291,7 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st 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 - elif query_param: - query_params[query_param] = "" + query_params = QueryParams(query_string) return method, path, query_params, http_version From 5f9a8d409c9796d4ef4ae221cbe72865deece75d Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 10 Jun 2023 18:47:25 +0000 Subject: [PATCH 10/16] Fixed docs for inherited properties of _IFieldStorage --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index a8fad68..4da3815 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -12,6 +12,7 @@ .. automodule:: adafruit_httpserver.request :members: + :inherited-members: .. automodule:: adafruit_httpserver.response :members: From 5aea936e5818b16a97e004b48374d214f91afaf4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 30 Jun 2023 10:11:31 +0000 Subject: [PATCH 11/16] Updated docstrings in Request --- adafruit_httpserver/request.py | 53 ++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index d276234..a589b28 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -179,8 +179,7 @@ class Request: Example:: - request.client_address - # ('192.168.137.1', 40684) + request.client_address # ('192.168.137.1', 40684) """ method: str @@ -195,9 +194,11 @@ class Request: Example:: - request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...") - request.query_params - # QueryParams({"foo": "bar"}) + request = Request(..., raw_request=b"GET /?foo=bar&baz=qux HTTP/1.1...") + + request.query_params # QueryParams({"foo": "bar"}) + request.query_params["foo"] # "bar" + request.query_params.get_list("baz") # ["qux"] """ http_version: str @@ -255,7 +256,47 @@ def body(self, body: bytes) -> None: @property def form_data(self) -> Union[FormData, None]: - """POST data of the request""" + """ + POST data of the request. + + Example:: + + # application/x-www-form-urlencoded + request = Request(..., + raw_request=b\"\"\"... + foo=bar&baz=qux\"\"\" + ) + + # or + + # multipart/form-data + request = Request(..., + raw_request=b\"\"\"... + --boundary + Content-Disposition: form-data; name="foo" + + bar + --boundary + Content-Disposition: form-data; name="baz" + + qux + --boundary--\"\"\" + ) + + # or + + # text/plain + request = Request(..., + raw_request=b\"\"\"... + foo=bar + baz=qux + \"\"\" + ) + + request.form_data # FormData({'foo': ['bar'], 'baz': ['qux']}) + request.form_data["foo"] # "bar" + request.form_data.get_list("baz") # ["qux"] + """ if self._form_data is None and self.method == "POST": self._form_data = FormData(self.body, self.headers["Content-Type"]) return self._form_data From b271a3c58057091a5d12416bd43d6002452ec160 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 2 Jul 2023 09:26:12 +0000 Subject: [PATCH 12/16] Fix: data to form_data in docs and examples --- docs/examples.rst | 4 ++-- examples/httpserver_form_data.py | 4 ++-- examples/httpserver_neopixel.py | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 0a04ea5..2aff28c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -165,8 +165,8 @@ It is important to use correct ``enctype``, depending on the type of data you wa If used, values will be automatically parsed as strings, including special characters, emojis etc. e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello World! ^-$%"``, this is the **recommended** option. -If you pass multiple values with the same name, they will be saved as a list, that can be accessed using ``request.data.get_list()``. -Even if there is only one value, it will still get a list, and if there multiple values, but you use ``request.data.get()`` it will +If you pass multiple values with the same name, they will be saved as a list, that can be accessed using ``request.form_data.get_list()``. +Even if there is only one value, it will still get a list, and if there multiple values, but you use ``request.form_data.get()`` it will return only the first one. .. literalinclude:: ../examples/httpserver_form_data.py diff --git a/examples/httpserver_form_data.py b/examples/httpserver_form_data.py index c077ec6..f93ed5f 100644 --- a/examples/httpserver_form_data.py +++ b/examples/httpserver_form_data.py @@ -47,14 +47,14 @@ def form(request: Request): enctype = request.query_params.get("enctype", "text/plain") if request.method == POST: - posted_value = request.data.get("something") + posted_value = request.form_data.get("something") return Response( request, FORM_HTML_TEMPLATE.format( enctype=enctype, submitted_value=( - f"

Submitted form value: {posted_value}

" + f"

Enctype: {enctype}

\n

Submitted form value: {posted_value}

" if request.method == POST else "" ), diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index ad80ba3..7c1e4d0 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -31,14 +31,23 @@ def change_neopixel_color_handler_query_params(request: Request): return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") -@server.route("/change-neopixel-color", POST) +@server.route("/change-neopixel-color/body", POST) def change_neopixel_color_handler_post_body(request: Request): """Changes the color of the built-in NeoPixel using POST body.""" data = request.body # e.g b"255,0,0" r, g, b = data.decode().split(",") # ["255", "0", "0"] - # or - data = request.data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0 + + pixel.fill((int(r), int(g), int(b))) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + +@server.route("/change-neopixel-color/form-data", POST) +def change_neopixel_color_handler_post_form_data(request: Request): + """Changes the color of the built-in NeoPixel using POST form data.""" + + data = request.form_data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0 r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) pixel.fill((int(r), int(g), int(b))) From e3c83b42767e69b9c2e5cd9d67e5afa4a7d691d9 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 4 Jul 2023 18:44:48 +0000 Subject: [PATCH 13/16] Added returns to Server.pool() --- adafruit_httpserver/__init__.py | 8 +++++++- adafruit_httpserver/server.py | 21 ++++++++++++++++----- docs/examples.rst | 2 ++ examples/httpserver_start_and_poll.py | 13 +++++++++++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index 1861a1d..187aa4e 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -59,7 +59,13 @@ JSONResponse, Redirect, ) -from .server import Server +from .server import ( + Server, + NO_REQUEST, + CONNECTION_TIMED_OUT, + REQUEST_HANDLED_NO_RESPONSE, + REQUEST_HANDLED_RESPONSE_SENT, +) from .status import ( Status, OK_200, diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 040467c..308c548 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -33,6 +33,12 @@ from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 +NO_REQUEST = 0 +CONNECTION_TIMED_OUT = 1 +REQUEST_HANDLED_NO_RESPONSE = 2 +REQUEST_HANDLED_RESPONSE_SENT = 3 + + class Server: # pylint: disable=too-many-instance-attributes """A basic socket-based HTTP server.""" @@ -318,10 +324,13 @@ def _set_default_server_headers(self, response: Response) -> None: name, value ) - def poll(self): + def poll(self) -> int: """ Call this method inside your main loop to get the server to check for new incoming client requests. When a request comes in, it will be handled by the handler function. + + Returns int representing the result of the poll + e.g. ``NO_REQUEST`` or ``REQUEST_HANDLED_RESPONSE_SENT``. """ if self.stopped: raise ServerStoppedError @@ -333,7 +342,7 @@ def poll(self): # Receive the whole request if (request := self._receive_request(conn, client_address)) is None: - return + return CONNECTION_TIMED_OUT # Find a handler for the route handler = self._routes.find_handler( @@ -344,7 +353,7 @@ def poll(self): response = self._handle_request(request, handler) if response is None: - return + return REQUEST_HANDLED_NO_RESPONSE self._set_default_server_headers(response) @@ -354,14 +363,16 @@ def poll(self): if self.debug: _debug_response_sent(response) + return REQUEST_HANDLED_RESPONSE_SENT + except Exception as error: # pylint: disable=broad-except if isinstance(error, OSError): # There is no data available right now, try again later. if error.errno == EAGAIN: - return + return NO_REQUEST # Connection reset by peer, try again later. if error.errno == ECONNRESET: - return + return NO_REQUEST if self.debug: _debug_exception_in_handler(error) diff --git a/docs/examples.rst b/docs/examples.rst index 2aff28c..80941e5 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -72,6 +72,8 @@ Between calling ``.poll()`` you can do something useful, for example read a sensor and capture an average or a running total of the last 10 samples. +``.poll()`` return value can be used to check if there was a request and if it was handled. + .. literalinclude:: ../examples/httpserver_start_and_poll.py :caption: examples/httpserver_start_and_poll.py :emphasize-lines: 24,33 diff --git a/examples/httpserver_start_and_poll.py b/examples/httpserver_start_and_poll.py index 340316a..1d41284 100644 --- a/examples/httpserver_start_and_poll.py +++ b/examples/httpserver_start_and_poll.py @@ -5,7 +5,12 @@ import socketpool import wifi -from adafruit_httpserver import Server, Request, FileResponse +from adafruit_httpserver import ( + Server, + REQUEST_HANDLED_RESPONSE_SENT, + Request, + FileResponse, +) pool = socketpool.SocketPool(wifi.radio) @@ -30,7 +35,11 @@ def base(request: Request): # or a running total of the last 10 samples # Process any waiting requests - server.poll() + pool_result = server.poll() + + if pool_result == REQUEST_HANDLED_RESPONSE_SENT: + # Do something only after handling a request + pass # If you want you can stop the server by calling server.stop() anywhere in your code except OSError as error: From cc75a5a0913c3bcf4b1cf574751aa8d7e28acfaa Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 4 Jul 2023 23:39:50 +0000 Subject: [PATCH 14/16] Fix: Setting Server.stopped to True in constructor --- adafruit_httpserver/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 308c548..9f51f75 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -72,7 +72,7 @@ def __init__( self.root_path = root_path if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) - self.stopped = False + self.stopped = True self.debug = debug From 8e2d5433344c09e07c386c97652579401ff9e14c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Thu, 6 Jul 2023 20:03:15 +0000 Subject: [PATCH 15/16] Replaced Server.poll() return with str --- adafruit_httpserver/server.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 9f51f75..95d2a52 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -33,10 +33,10 @@ from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 -NO_REQUEST = 0 -CONNECTION_TIMED_OUT = 1 -REQUEST_HANDLED_NO_RESPONSE = 2 -REQUEST_HANDLED_RESPONSE_SENT = 3 +NO_REQUEST = "no_request" +CONNECTION_TIMED_OUT = "connection_timed_out" +REQUEST_HANDLED_NO_RESPONSE = "request_handled_no_response" +REQUEST_HANDLED_RESPONSE_SENT = "request_handled_response_sent" class Server: # pylint: disable=too-many-instance-attributes @@ -324,12 +324,12 @@ def _set_default_server_headers(self, response: Response) -> None: name, value ) - def poll(self) -> int: + def poll(self) -> str: """ Call this method inside your main loop to get the server to check for new incoming client requests. When a request comes in, it will be handled by the handler function. - Returns int representing the result of the poll + Returns str representing the result of the poll e.g. ``NO_REQUEST`` or ``REQUEST_HANDLED_RESPONSE_SENT``. """ if self.stopped: From 14585bf70e99a77c290eaed91cb59f95db9d4c6c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 12 Jul 2023 20:31:12 +0000 Subject: [PATCH 16/16] Updated README.md --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5977b72..7da0b7a 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ HTTP Server for CircuitPython. - HTTP 1.1. - Serves files from a designated root. - Routing for serving computed responses from handlers. -- Gives access to request headers, query parameters, body and client's address, the one from which the request came. +- Gives access to request headers, query parameters, form data, body and client's address (the one from which the request came). - Supports chunked transfer encoding. - Supports URL parameters and wildcard URLs. - Supports HTTP Basic and Bearer Authentication on both server and route per level.