Skip to content

Commit

Permalink
Merge pull request #58 from michalpokusa/server-headers
Browse files Browse the repository at this point in the history
Server headers, FormData, some docs improvements and fix for bug in ChunkedResponse
  • Loading branch information
FoamyGuy authored Jul 17, 2023
2 parents 8165933 + 14585bf commit e989afe
Show file tree
Hide file tree
Showing 14 changed files with 424 additions and 37 deletions.
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

0 comments on commit e989afe

Please sign in to comment.