Skip to content

Commit e989afe

Browse files
authored
Merge pull request #58 from michalpokusa/server-headers
Server headers, FormData, some docs improvements and fix for bug in ChunkedResponse
2 parents 8165933 + 14585bf commit e989afe

14 files changed

+424
-37
lines changed

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ HTTP Server for CircuitPython.
2727
- HTTP 1.1.
2828
- Serves files from a designated root.
2929
- Routing for serving computed responses from handlers.
30-
- Gives access to request headers, query parameters, body and client's address, the one from which the request came.
30+
- Gives access to request headers, query parameters, form data, body and client's address (the one from which the request came).
3131
- Supports chunked transfer encoding.
3232
- Supports URL parameters and wildcard URLs.
3333
- Supports HTTP Basic and Bearer Authentication on both server and route per level.

adafruit_httpserver/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,21 @@
5151
CONNECT,
5252
)
5353
from .mime_types import MIMETypes
54-
from .request import Request
54+
from .request import QueryParams, FormData, Request
5555
from .response import (
5656
Response,
5757
FileResponse,
5858
ChunkedResponse,
5959
JSONResponse,
6060
Redirect,
6161
)
62-
from .server import Server
62+
from .server import (
63+
Server,
64+
NO_REQUEST,
65+
CONNECTION_TIMED_OUT,
66+
REQUEST_HANDLED_NO_RESPONSE,
67+
REQUEST_HANDLED_RESPONSE_SENT,
68+
)
6369
from .status import (
6470
Status,
6571
OK_200,

adafruit_httpserver/authentication.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def __str__(self) -> str:
4141
def check_authentication(request: Request, auths: List[Union[Basic, Bearer]]) -> bool:
4242
"""
4343
Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise.
44+
45+
Example::
46+
47+
check_authentication(request, [Basic("username", "password")])
4448
"""
4549

4650
auth_header = request.headers.get("Authorization")
@@ -56,6 +60,10 @@ def require_authentication(request: Request, auths: List[Union[Basic, Bearer]])
5660
Checks if the request is authorized and raises ``AuthenticationError`` if not.
5761
5862
If the error is not caught, the server will return ``401 Unauthorized``.
63+
64+
Example::
65+
66+
require_authentication(request, [Basic("username", "password")])
5967
"""
6068

6169
if not check_authentication(request, auths):

adafruit_httpserver/request.py

Lines changed: 192 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010
try:
11-
from typing import Dict, Tuple, Union, TYPE_CHECKING
11+
from typing import List, Dict, Tuple, Union, Any, TYPE_CHECKING
1212
from socket import socket
1313
from socketpool import SocketPool
1414

@@ -22,6 +22,141 @@
2222
from .headers import Headers
2323

2424

25+
class _IFieldStorage:
26+
"""Interface with shared methods for QueryParams and FormData."""
27+
28+
_storage: Dict[str, List[Union[str, bytes]]]
29+
30+
def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None:
31+
if field_name not in self._storage:
32+
self._storage[field_name] = [value]
33+
else:
34+
self._storage[field_name].append(value)
35+
36+
def get(self, field_name: str, default: Any = None) -> Union[str, bytes, None]:
37+
"""Get the value of a field."""
38+
return self._storage.get(field_name, [default])[0]
39+
40+
def get_list(self, field_name: str) -> List[Union[str, bytes]]:
41+
"""Get the list of values of a field."""
42+
return self._storage.get(field_name, [])
43+
44+
@property
45+
def fields(self):
46+
"""Returns a list of field names."""
47+
return list(self._storage.keys())
48+
49+
def __getitem__(self, field_name: str):
50+
return self.get(field_name)
51+
52+
def __iter__(self):
53+
return iter(self._storage)
54+
55+
def __len__(self):
56+
return len(self._storage)
57+
58+
def __contains__(self, key: str):
59+
return key in self._storage
60+
61+
def __repr__(self) -> str:
62+
return f"{self.__class__.__name__}({repr(self._storage)})"
63+
64+
65+
class QueryParams(_IFieldStorage):
66+
"""
67+
Class for parsing and storing GET quer parameters requests.
68+
69+
Examples::
70+
71+
query_params = QueryParams(b"foo=bar&baz=qux&baz=quux")
72+
# QueryParams({"foo": "bar", "baz": ["qux", "quux"]})
73+
74+
query_params.get("foo") # "bar"
75+
query_params["foo"] # "bar"
76+
query_params.get("non-existent-key") # None
77+
query_params.get_list("baz") # ["qux", "quux"]
78+
"unknown-key" in query_params # False
79+
query_params.fields # ["foo", "baz"]
80+
"""
81+
82+
_storage: Dict[str, List[Union[str, bytes]]]
83+
84+
def __init__(self, query_string: str) -> None:
85+
self._storage = {}
86+
87+
for query_param in query_string.split("&"):
88+
if "=" in query_param:
89+
key, value = query_param.split("=", 1)
90+
self._add_field_value(key, value)
91+
elif query_param:
92+
self._add_field_value(query_param, "")
93+
94+
95+
class FormData(_IFieldStorage):
96+
"""
97+
Class for parsing and storing form data from POST requests.
98+
99+
Supports ``application/x-www-form-urlencoded``, ``multipart/form-data`` and ``text/plain``
100+
content types.
101+
102+
Examples::
103+
104+
form_data = FormData(b"foo=bar&baz=qux&baz=quuz", "application/x-www-form-urlencoded")
105+
# or
106+
form_data = FormData(b"foo=bar\\r\\nbaz=qux\\r\\nbaz=quux", "text/plain")
107+
# FormData({"foo": "bar", "baz": "qux"})
108+
109+
form_data.get("foo") # "bar"
110+
form_data["foo"] # "bar"
111+
form_data.get("non-existent-key") # None
112+
form_data.get_list("baz") # ["qux", "quux"]
113+
"unknown-key" in form_data # False
114+
form_data.fields # ["foo", "baz"]
115+
"""
116+
117+
_storage: Dict[str, List[Union[str, bytes]]]
118+
119+
def __init__(self, data: bytes, content_type: str) -> None:
120+
self.content_type = content_type
121+
self._storage = {}
122+
123+
if content_type.startswith("application/x-www-form-urlencoded"):
124+
self._parse_x_www_form_urlencoded(data)
125+
126+
elif content_type.startswith("multipart/form-data"):
127+
boundary = content_type.split("boundary=")[1]
128+
self._parse_multipart_form_data(data, boundary)
129+
130+
elif content_type.startswith("text/plain"):
131+
self._parse_text_plain(data)
132+
133+
def _parse_x_www_form_urlencoded(self, data: bytes) -> None:
134+
decoded_data = data.decode()
135+
136+
for field_name, value in [
137+
key_value.split("=", 1) for key_value in decoded_data.split("&")
138+
]:
139+
self._add_field_value(field_name, value)
140+
141+
def _parse_multipart_form_data(self, data: bytes, boundary: str) -> None:
142+
blocks = data.split(b"--" + boundary.encode())[1:-1]
143+
144+
for block in blocks:
145+
disposition, content = block.split(b"\r\n\r\n", 1)
146+
field_name = disposition.split(b'"', 2)[1].decode()
147+
value = content[:-2]
148+
149+
self._add_field_value(field_name, value)
150+
151+
def _parse_text_plain(self, data: bytes) -> None:
152+
lines = data.split(b"\r\n")[:-1]
153+
154+
for line in lines:
155+
field_name, value = line.split(b"=", 1)
156+
157+
self._add_field_value(field_name.decode(), value.decode())
158+
159+
25160
class Request:
26161
"""
27162
Incoming request, constructed from raw incoming bytes.
@@ -44,8 +179,7 @@ class Request:
44179
45180
Example::
46181
47-
request.client_address
48-
# ('192.168.137.1', 40684)
182+
request.client_address # ('192.168.137.1', 40684)
49183
"""
50184

51185
method: str
@@ -54,15 +188,17 @@ class Request:
54188
path: str
55189
"""Path of the request, e.g. ``"/foo/bar"``."""
56190

57-
query_params: Dict[str, str]
191+
query_params: QueryParams
58192
"""
59193
Query/GET parameters in the request.
60194
61195
Example::
62196
63-
request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...")
64-
request.query_params
65-
# {"foo": "bar"}
197+
request = Request(..., raw_request=b"GET /?foo=bar&baz=qux HTTP/1.1...")
198+
199+
request.query_params # QueryParams({"foo": "bar"})
200+
request.query_params["foo"] # "bar"
201+
request.query_params.get_list("baz") # ["qux"]
66202
"""
67203

68204
http_version: str
@@ -91,6 +227,7 @@ def __init__(
91227
self.connection = connection
92228
self.client_address = client_address
93229
self.raw_request = raw_request
230+
self._form_data = None
94231

95232
if raw_request is None:
96233
raise ValueError("raw_request cannot be None")
@@ -117,6 +254,53 @@ def body(self) -> bytes:
117254
def body(self, body: bytes) -> None:
118255
self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body
119256

257+
@property
258+
def form_data(self) -> Union[FormData, None]:
259+
"""
260+
POST data of the request.
261+
262+
Example::
263+
264+
# application/x-www-form-urlencoded
265+
request = Request(...,
266+
raw_request=b\"\"\"...
267+
foo=bar&baz=qux\"\"\"
268+
)
269+
270+
# or
271+
272+
# multipart/form-data
273+
request = Request(...,
274+
raw_request=b\"\"\"...
275+
--boundary
276+
Content-Disposition: form-data; name="foo"
277+
278+
bar
279+
--boundary
280+
Content-Disposition: form-data; name="baz"
281+
282+
qux
283+
--boundary--\"\"\"
284+
)
285+
286+
# or
287+
288+
# text/plain
289+
request = Request(...,
290+
raw_request=b\"\"\"...
291+
foo=bar
292+
baz=qux
293+
\"\"\"
294+
)
295+
296+
request.form_data # FormData({'foo': ['bar'], 'baz': ['qux']})
297+
request.form_data["foo"] # "bar"
298+
request.form_data.get_list("baz") # ["qux"]
299+
"""
300+
if self._form_data is None and self.method == "POST":
301+
self._form_data = FormData(self.body, self.headers["Content-Type"])
302+
return self._form_data
303+
120304
def json(self) -> Union[dict, None]:
121305
"""Body of the request, as a JSON-decoded dictionary."""
122306
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
148332

149333
path, query_string = path.split("?", 1)
150334

151-
query_params = {}
152-
for query_param in query_string.split("&"):
153-
if "=" in query_param:
154-
key, value = query_param.split("=", 1)
155-
query_params[key] = value
156-
elif query_param:
157-
query_params[query_param] = ""
335+
query_params = QueryParams(query_string)
158336

159337
return method, path, query_params, http_version
160338

adafruit_httpserver/response.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,8 @@ def _send(self) -> None:
305305
self._send_headers()
306306

307307
for chunk in self._body():
308-
self._send_chunk(chunk)
308+
if 0 < len(chunk): # Don't send empty chunks
309+
self._send_chunk(chunk)
309310

310311
# Empty chunk to indicate end of response
311312
self._send_chunk()

0 commit comments

Comments
 (0)