Skip to content

Commit 089569d

Browse files
committed
Merge branch 'form-data-parsing' into server-headers
2 parents 63e7eb8 + a74ae99 commit 089569d

File tree

6 files changed

+247
-12
lines changed

6 files changed

+247
-12
lines changed

adafruit_httpserver/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
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,

adafruit_httpserver/request.py

Lines changed: 147 additions & 10 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.
@@ -54,15 +189,15 @@ class Request:
54189
path: str
55190
"""Path of the request, e.g. ``"/foo/bar"``."""
56191

57-
query_params: Dict[str, str]
192+
query_params: QueryParams
58193
"""
59194
Query/GET parameters in the request.
60195
61196
Example::
62197
63198
request = Request(raw_request=b"GET /?foo=bar HTTP/1.1...")
64199
request.query_params
65-
# {"foo": "bar"}
200+
# QueryParams({"foo": "bar"})
66201
"""
67202

68203
http_version: str
@@ -91,6 +226,7 @@ def __init__(
91226
self.connection = connection
92227
self.client_address = client_address
93228
self.raw_request = raw_request
229+
self._form_data = None
94230

95231
if raw_request is None:
96232
raise ValueError("raw_request cannot be None")
@@ -117,6 +253,13 @@ def body(self) -> bytes:
117253
def body(self, body: bytes) -> None:
118254
self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body
119255

256+
@property
257+
def form_data(self) -> Union[FormData, None]:
258+
"""POST data of the request"""
259+
if self._form_data is None and self.method == "POST":
260+
self._form_data = FormData(self.body, self.headers["Content-Type"])
261+
return self._form_data
262+
120263
def json(self) -> Union[dict, None]:
121264
"""Body of the request, as a JSON-decoded dictionary."""
122265
return json.loads(self.body) if self.body else None
@@ -148,13 +291,7 @@ def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], st
148291

149292
path, query_string = path.split("?", 1)
150293

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] = ""
294+
query_params = QueryParams(query_string)
158295

159296
return method, path, query_params, http_version
160297

docs/examples.rst

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
102102

103103
.. literalinclude:: ../examples/httpserver_cpu_information.py
104104
:caption: examples/httpserver_cpu_information.py
105-
:emphasize-lines: 9,14-17,32
105+
:emphasize-lines: 9,15-18,33
106106
:linenos:
107107

108108
Handling different methods
@@ -146,6 +146,34 @@ Tested on ESP32-S2 Feather.
146146
:emphasize-lines: 25-27,39,51,60,66
147147
:linenos:
148148

149+
Form data parsing
150+
---------------------
151+
152+
Another way to pass data to the handler function is to use form data.
153+
Remember that it is only possible to use it with ``POST`` method.
154+
`More about POST method. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST>`_
155+
156+
It is important to use correct ``enctype``, depending on the type of data you want to send.
157+
158+
- ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces.
159+
If you use it, values will be automatically parsed as strings, but special characters will be URL encoded
160+
e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"``
161+
- ``multipart/form-data`` - For sending text and binary files and/or text data with special characters
162+
When used, values will **not** be automatically parsed as strings, they will stay as bytes instead.
163+
e.g. ``"Hello World! ^-$%"`` will be saved as ``b'Hello World! ^-$%'``, which can be decoded using ``.decode()`` method.
164+
- ``text/plain`` - For sending text data with special characters.
165+
If used, values will be automatically parsed as strings, including special characters, emojis etc.
166+
e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello World! ^-$%"``, this is the **recommended** option.
167+
168+
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()``.
169+
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
170+
return only the first one.
171+
172+
.. literalinclude:: ../examples/httpserver_form_data.py
173+
:caption: examples/httpserver_form_data.py
174+
:emphasize-lines: 32,47,50
175+
:linenos:
176+
149177
Chunked response
150178
----------------
151179

examples/httpserver_cpu_information.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from adafruit_httpserver import Server, Request, JSONResponse
1010

11+
1112
pool = socketpool.SocketPool(wifi.radio)
1213
server = Server(pool, debug=True)
1314

examples/httpserver_form_data.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: Unlicense
4+
5+
import socketpool
6+
import wifi
7+
8+
from adafruit_httpserver import Server, Request, Response, GET, POST
9+
10+
11+
pool = socketpool.SocketPool(wifi.radio)
12+
server = Server(pool, debug=True)
13+
14+
15+
FORM_HTML_TEMPLATE = """
16+
<html lang="en">
17+
<head>
18+
<title>Form with {enctype} enctype</title>
19+
</head>
20+
<body>
21+
<a href="/form?enctype=application/x-www-form-urlencoded">
22+
<button>Load <strong>application/x-www-form-urlencoded</strong> form</button>
23+
</a><br />
24+
<a href="/form?enctype=multipart/form-data">
25+
<button>Load <strong>multipart/form-data</strong> form</button>
26+
</a><br />
27+
<a href="/form?enctype=text/plain">
28+
<button>Load <strong>text/plain</strong> form</button>
29+
</a><br />
30+
31+
<h2>Form with {enctype} enctype</h2>
32+
<form action="/form" method="post" enctype="{enctype}">
33+
<input type="text" name="something" placeholder="Type something...">
34+
<input type="submit" value="Submit">
35+
</form>
36+
{submitted_value}
37+
</body>
38+
</html>
39+
"""
40+
41+
42+
@server.route("/form", [GET, POST])
43+
def form(request: Request):
44+
"""
45+
Serve a form with the given enctype, and display back the submitted value.
46+
"""
47+
enctype = request.query_params.get("enctype", "text/plain")
48+
49+
if request.method == POST:
50+
posted_value = request.data.get("something")
51+
52+
return Response(
53+
request,
54+
FORM_HTML_TEMPLATE.format(
55+
enctype=enctype,
56+
submitted_value=(
57+
f"<h3>Submitted form value: {posted_value}</h3>"
58+
if request.method == POST
59+
else ""
60+
),
61+
),
62+
content_type="text/html",
63+
)
64+
65+
66+
server.serve_forever(str(wifi.radio.ipv4_address))

examples/httpserver_neopixel.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def change_neopixel_color_handler_post_body(request: Request):
3737

3838
data = request.body # e.g b"255,0,0"
3939
r, g, b = data.decode().split(",") # ["255", "0", "0"]
40+
# or
41+
data = request.data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0
42+
r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0)
4043

4144
pixel.fill((int(r), int(g), int(b)))
4245

0 commit comments

Comments
 (0)