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. diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index cb152b2..187aa4e 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 QueryParams, FormData, Request from .response import ( Response, FileResponse, @@ -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/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/request.py b/adafruit_httpserver/request.py index e911570..a589b28 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,141 @@ from .headers import Headers +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. + + Supports ``application/x-www-form-urlencoded``, ``multipart/form-data`` and ``text/plain`` + content types. + + Examples:: + + 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\\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", "quux"] + "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 _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()) + + class Request: """ Incoming request, constructed from raw incoming bytes. @@ -44,8 +179,7 @@ class Request: Example:: - request.client_address - # ('192.168.137.1', 40684) + request.client_address # ('192.168.137.1', 40684) """ method: str @@ -54,15 +188,17 @@ 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. Example:: - request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...") - request.query_params - # {"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 @@ -91,6 +227,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 +254,53 @@ 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. + + 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 + 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 @@ -148,13 +332,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 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() diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 1666df5..95d2a52 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,14 +33,23 @@ from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 -class Server: +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 """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 + """Port the server is listening on. ``None`` if server is stopped.""" - port: int = None - """Port the server is listening on.""" + 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 @@ -57,10 +67,12 @@ def __init__( self._routes = _Routes() 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) - self.stopped = False + self.stopped = True self.debug = debug @@ -306,10 +318,19 @@ def _handle_request( status=NOT_FOUND_404, ) - def poll(self): + 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) -> 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 str representing the result of the poll + e.g. ``NO_REQUEST`` or ``REQUEST_HANDLED_RESPONSE_SENT``. """ if self.stopped: raise ServerStoppedError @@ -321,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( @@ -332,7 +353,9 @@ 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) # Send the response response._send() # pylint: disable=protected-access @@ -340,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) @@ -362,10 +387,32 @@ 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 + @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 = { + "X-Server": "Adafruit CircuitPython HTTP Server", + "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/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: diff --git a/docs/examples.rst b/docs/examples.rst index 683c094..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 @@ -97,9 +99,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,15-18,33 :linenos: Handling different methods @@ -143,6 +148,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.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 + :caption: examples/httpserver_form_data.py + :emphasize-lines: 32,47,50 + :linenos: + Chunked response ---------------- @@ -177,6 +210,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`` @@ -186,7 +221,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 @@ -233,12 +268,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..22abe74 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -8,9 +8,15 @@ from adafruit_httpserver import Server, Request, JSONResponse + 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_form_data.py b/examples/httpserver_form_data.py new file mode 100644 index 0000000..f93ed5f --- /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.form_data.get("something") + + return Response( + request, + FORM_HTML_TEMPLATE.format( + enctype=enctype, + submitted_value=( + f"

Enctype: {enctype}

\n

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_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") diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index 7e7dab3..7c1e4d0 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -31,7 +31,7 @@ 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.""" @@ -43,6 +43,18 @@ def change_neopixel_color_handler_post_body(request: Request): 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))) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + @server.route("/change-neopixel-color/json", POST) def change_neopixel_color_handler_post_json(request: Request): """Changes the color of the built-in NeoPixel using JSON POST body.""" 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: 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):