Skip to content

Commit 3546b98

Browse files
committed
forms - multipart/form-data & application/application/x-www-form-urlencoded
1 parent a786d29 commit 3546b98

15 files changed

+1231
-33
lines changed

aiopenapi3/v20/glue.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import List, Union, cast
22
import json
3+
import urllib.parse
34

45
import httpx
56
import pydantic
@@ -122,12 +123,15 @@ def _prepare_parameters(self, provided):
122123
assert isinstance(values, dict)
123124

124125
if spec.in_ == "formData":
125-
if "multipart/form-data" not in self.operation.consumes:
126-
raise ValueError(f"operation does not consume form data but parameter {name} is formData")
127-
if spec.type == "file":
128-
self.req.files.update(values)
129-
else:
126+
if "multipart/form-data" in self.operation.consumes:
127+
if spec.type == "file":
128+
self.req.files.update(values)
129+
else:
130+
self.req.data.update(values)
131+
elif "application/x-www-form-urlencoded" in self.operation.consumes:
130132
self.req.data.update(values)
133+
else:
134+
raise ValueError(f"operation does not consume form data but parameter {name} is formData")
131135

132136
if spec.in_ == "path":
133137
# The string method `format` is incapable of partial updates,

aiopenapi3/v30/formdata.py

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import base64
2+
import quopri
3+
from typing import List, Tuple, Dict, Union
4+
from email.mime import multipart, nonmultipart
5+
from email.message import _unquotevalue, Message
6+
import collections
7+
8+
9+
class MIMEFormdata(nonmultipart.MIMENonMultipart):
10+
def __init__(self, keyname, *args, **kwargs):
11+
super(MIMEFormdata, self).__init__(*args, **kwargs)
12+
self.add_header("Content-Disposition", f'form-data; name="{keyname}"')
13+
del self["MIME-Version"]
14+
15+
16+
class MIMEMultipart(multipart.MIMEMultipart):
17+
def __init__(self):
18+
super().__init__("form-data")
19+
del self["MIME-Version"]
20+
21+
def _write_headers(self, generator):
22+
pass
23+
24+
25+
from .parameter import encode_parameter
26+
27+
28+
def parameters_from_multipart(data, media, rbq):
29+
params = list()
30+
for k in data.__fields_set__:
31+
v = getattr(data, k)
32+
ct = "text/plain"
33+
34+
if (p := media.schema_.properties.get(k, None)) is not None:
35+
"""OpenAPI 3.0 - Special Considerations for multipart Content"""
36+
if p.type == "array":
37+
p = p.items
38+
if p.type == "string" and (
39+
p.format in ("binary", "base64") or getattr(p, "contentEncoding", None) is not None
40+
):
41+
ct = "application/octet-stream"
42+
elif p.type == "object":
43+
ct = "application/json"
44+
45+
if (e := media.encoding.get(k, None)) != None:
46+
ct = e.contentType or ct
47+
style = e.style or "form"
48+
explode = e.explode if e.explode is not None else (True if style == "form" else False)
49+
allowReserved = e.allowReserved or False
50+
headers = {name: rbq[name] for name in e.headers.keys() if name in rbq}
51+
else:
52+
allowReserved = False
53+
style = "form"
54+
explode = True
55+
headers = dict()
56+
57+
m = media.schema_.properties[k]
58+
if isinstance(v, list):
59+
for i in v:
60+
r = encode_parameter(k, i, style, explode, allowReserved, "query", m.items)
61+
params.append((k, ct, r, headers, m.items))
62+
else:
63+
r = encode_parameter(k, v, style, explode, allowReserved, "query", m)
64+
params.append((k, ct, r, headers, m))
65+
return params
66+
67+
68+
def parameters_from_urlencoded(data: "BaseModel", media: "Media"):
69+
params = collections.defaultdict(lambda: list())
70+
for k in data.__fields_set__:
71+
v = getattr(data, k)
72+
73+
if (e := media.encoding.get(k, None)) != None:
74+
explode = e.explode
75+
allowReserved = e.allowReserved
76+
style = e.style
77+
else:
78+
explode = True
79+
allowReserved = False
80+
style = "form"
81+
82+
m = media.schema_.properties[k]
83+
if isinstance(v, list):
84+
for i in v:
85+
r = encode_parameter(k, i, style, explode, allowReserved, "query", m.items)
86+
params[k].append(r)
87+
else:
88+
r = encode_parameter(k, v, style, explode, allowReserved, "query", m)
89+
params[k].append(r)
90+
return params
91+
92+
93+
def encode_content(data, codec):
94+
"""
95+
… supports all encodings defined in [RFC4648], including “base64” and “base64url”, as well as “quoted-printable” from [RFC2045].
96+
:param data:
97+
:param codec:
98+
:return:
99+
"""
100+
if codec in ["base16", "base32", "base64", "base64url"]:
101+
if codec == "base16":
102+
r = base64.b16encode(data)
103+
elif codec == "base32":
104+
r = base64.b32encode(data)
105+
elif codec == "base64":
106+
r = base64.b64encode(data)
107+
elif codec == "base64url":
108+
r = base64.urlsafe_b64encode(data).rstrip(b"=")
109+
return r.decode()
110+
elif codec == "quoted-printable":
111+
return quopri.encodestring(data)
112+
else:
113+
raise ValueError(f"unsupported codec {codec}")
114+
115+
116+
def encode_multipart_parameters(fields: List[Tuple[str, str, Union[str, bytes], Dict[str, str], "Schema"]]):
117+
"""
118+
As shown in
119+
https://julien.danjou.info/handling-multipart-form-data-python/
120+
121+
:param fields:
122+
:return:
123+
"""
124+
m = MIMEMultipart()
125+
126+
for (field, ct, value, headers, schema) in fields:
127+
type, subtype, params = decode_content_type(ct)
128+
129+
if type in ["image", "audio", "application"]:
130+
if isinstance(value, bytes):
131+
v = value
132+
else:
133+
v = value.encode()
134+
135+
codec = "base64"
136+
137+
if hasattr(schema, "contentEncoding"):
138+
"""OpenAPI 3.1"""
139+
if schema.contentEncoding:
140+
codec = schema.contentEncoding
141+
headers["Content-Encoding"] = codec
142+
else:
143+
"""OpenAPI 3.0"""
144+
145+
data = encode_content(v, codec)
146+
147+
elif type in ["text", "rfc822"]:
148+
data = value
149+
else:
150+
type, subtype = "text", "plain"
151+
data = value
152+
153+
env = MIMEFormdata(field, type, subtype)
154+
155+
for header, value in headers.items():
156+
env.add_header(header, value)
157+
158+
for k, v in params:
159+
env.set_param(k, v, "Content-Type")
160+
161+
env.set_payload(data)
162+
163+
m.attach(env)
164+
165+
return m
166+
167+
168+
def decode_content_type(value: str) -> Tuple[str, str, List[Tuple[str, str]]]:
169+
"""
170+
msg = Message._get_params_preserve({"content-type": value}, header="content-type", failobj=None)
171+
ct, *params = list(map(lambda x: (x[0], _unquotevalue(x[1])) if x[0].lower() == x[0] else x, msg))
172+
"""
173+
m = Message()
174+
m.add_header("content-type", value)
175+
ct, *params = m.get_params()
176+
177+
type, _, subtype = ct[0].partition("/")
178+
return type, subtype, params

aiopenapi3/v30/glue.py

+50-24
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from typing import List, Union, cast
22
import json
3+
import urllib.parse
34

45
import httpx
56
import pydantic
67
import pydantic.json
78

9+
import aiopenapi3.v30.media
810
from ..base import SchemaBase, ParameterBase
911
from ..request import RequestBase, AsyncRequestBase
1012
from ..errors import HTTPStatusError, ContentTypeError, ResponseDecodingError, ResponseSchemaError
13+
from .formdata import parameters_from_multipart, parameters_from_urlencoded, encode_multipart_parameters
1114

1215

1316
class Request(RequestBase):
@@ -110,9 +113,18 @@ def _prepare_parameters(self, provided):
110113
https://spec.openapis.org/oas/v3.0.3#parameter-object
111114
A unique parameter is defined by a combination of a name and location.
112115
"""
116+
113117
provided = provided or dict()
114118
possible = {_.name: _ for _ in self.operation.parameters + self.root.paths[self.path].parameters}
115119

120+
if self.operation.requestBody:
121+
rbq = dict() # requestBody Parameters
122+
ct = "multipart/form-data"
123+
if ct in self.operation.requestBody.content:
124+
for k, v in self.operation.requestBody.content[ct].encoding.items():
125+
rbq.update(v.headers)
126+
possible.update(rbq)
127+
116128
parameters = {
117129
i.name: i.schema_.default for i in filter(lambda x: x.schema_.default is not None, possible.values())
118130
}
@@ -129,29 +141,30 @@ def _prepare_parameters(self, provided):
129141
)
130142

131143
path_parameters = {}
132-
144+
rbqh = dict()
133145
for name, value in parameters.items():
134146
spec = possible[name]
135147
values = spec._encode(name, value)
136148
assert isinstance(values, dict)
137-
if spec.in_ == "path":
149+
150+
if isinstance(spec, (aiopenapi3.v30.parameter.Header, aiopenapi3.v31.parameter.Header)):
151+
rbqh.update(values)
152+
elif spec.in_ == "header":
153+
self.req.headers.update(values)
154+
elif spec.in_ == "path":
138155
# The string method `format` is incapable of partial updates,
139156
# as such we need to collect all the path parameters before
140157
# applying them to the format string.
141158
path_parameters.update(values)
142-
143-
if spec.in_ == "query":
159+
elif spec.in_ == "query":
144160
self.req.params.update(values)
145-
146-
if spec.in_ == "header":
147-
self.req.headers.update(values)
148-
149-
if spec.in_ == "cookie":
161+
elif spec.in_ == "cookie":
150162
self.req.cookies.update(values)
151163

152164
self.req.url = self.req.url.format(**path_parameters)
165+
return rbqh
153166

154-
def _prepare_body(self, data):
167+
def _prepare_body(self, data, rbq):
155168
if not self.operation.requestBody:
156169
return
157170

@@ -173,23 +186,37 @@ def _prepare_body(self, data):
173186
data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending
174187
self.req.content = data
175188
self.req.headers["Content-Type"] = "application/json"
176-
# elif "multipart/form-data" in self.operation.requestBody.content:
177-
# """
178-
# https://swagger.io/docs/specification/describing-request-body/multipart-requests/
179-
# """
180-
# pass
181-
# elif "multipart/mixed" in self.operation.requestBody.content:
182-
# pass
183-
# elif "multipart/form-data" in self.operation.requestBody.content:
184-
# pass
185-
189+
elif (ct := "multipart/form-data") in self.operation.requestBody.content:
190+
"""
191+
https://swagger.io/docs/specification/describing-request-body/multipart-requests/
192+
https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object
193+
"""
194+
media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct]
195+
if not media.schema_ or not isinstance(data, media.schema_.get_type()):
196+
"""expect the data to be a model"""
197+
raise TypeError((type(data), media.schema_.get_type()))
198+
199+
params = parameters_from_multipart(data, media, rbq)
200+
msg = encode_multipart_parameters(params)
201+
self.req.content = msg.as_string()
202+
self.req.headers["Content-Type"] = f'{msg.get_content_type()}; boundary="{msg.get_boundary()}"'
203+
elif (ct := "application/x-www-form-urlencoded") in self.operation.requestBody.content:
204+
self.req.headers["Content-Type"] = ct
205+
media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct]
206+
if not media.schema_ or not isinstance(data, media.schema_.get_type()):
207+
"""expect the data to be a model"""
208+
raise TypeError((type(data), media.schema_.get_type()))
209+
210+
params = parameters_from_urlencoded(data, media)
211+
msg = urllib.parse.urlencode(params, doseq=True)
212+
self.req.content = msg
186213
else:
187-
raise NotImplementedError()
214+
raise NotImplementedError(self.operation.requestBody.content)
188215

189216
def _prepare(self, data, parameters):
190217
self._prepare_security()
191-
self._prepare_parameters(parameters)
192-
self._prepare_body(data)
218+
rbq = self._prepare_parameters(parameters)
219+
self._prepare_body(data, rbq)
193220

194221
def _build_req(self, session):
195222
req = session.build_request(
@@ -280,7 +307,6 @@ def _process(self, result):
280307
)
281308

282309
if content_type.lower() == "application/json":
283-
284310
data = ctx.received
285311
try:
286312
data = json.loads(data)

aiopenapi3/v30/parameter.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import enum
2+
import datetime
3+
import decimal
4+
import uuid
25
from typing import Union, Optional, Dict, Any
36

47
from pydantic import Field, root_validator
@@ -70,7 +73,6 @@ def _encode__matrix(self, name, value, explode):
7073
else:
7174
# ;R=100;G=200;B=150
7275
value = f";{value}"
73-
pass
7476
return {name: value}
7577

7678
def _encode__label(self, name, value, explode):
@@ -257,6 +259,18 @@ class Parameter(ParameterBase, _ParameterCodec):
257259
in_: _In = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"]
258260

259261

262+
def encode_parameter(
263+
name: str, value: object, style: str, explode: bool, allowReserved: bool, in_: str, schema_: Schema
264+
) -> Union[str, bytes]:
265+
p = Parameter(name=name, style=style, explode=explode, allowReserved=allowReserved, **{"in": in_, "schema": None})
266+
p.schema_ = schema_
267+
r = p._encode(name, value)[name]
268+
if isinstance(r, (int, float, decimal.Decimal, datetime.datetime, datetime.date, datetime.time, uuid.UUID)):
269+
r = str(r)
270+
assert isinstance(r, (str, bytes))
271+
return r
272+
273+
260274
class Header(ParameterBase, _ParameterCodec):
261275
"""
262276

0 commit comments

Comments
 (0)