Skip to content

Server headers, FormData, some docs improvements and fix for bug in ChunkedResponse #58

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions adafruit_httpserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,21 @@
CONNECT,
)
from .mime_types import MIMETypes
from .request import Request
from .request import QueryParams, FormData, Request
from .response import (
Response,
FileResponse,
ChunkedResponse,
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,
Expand Down
8 changes: 8 additions & 0 deletions adafruit_httpserver/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down
206 changes: 192 additions & 14 deletions adafruit_httpserver/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion adafruit_httpserver/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading