Skip to content

Commit 863ba3f

Browse files
committed
Media type encoding support
1 parent 330cb71 commit 863ba3f

File tree

11 files changed

+538
-103
lines changed

11 files changed

+538
-103
lines changed

Diff for: openapi_core/deserializing/media_types/__init__.py

+20-12
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
1-
from json import loads as json_loads
2-
from xml.etree.ElementTree import fromstring as xml_loads
1+
from collections import defaultdict
32

43
from openapi_core.deserializing.media_types.datatypes import (
54
MediaTypeDeserializersDict,
65
)
76
from openapi_core.deserializing.media_types.factories import (
87
MediaTypeDeserializersFactory,
98
)
9+
from openapi_core.deserializing.media_types.util import binary_loads
1010
from openapi_core.deserializing.media_types.util import data_form_loads
11+
from openapi_core.deserializing.media_types.util import json_loads
1112
from openapi_core.deserializing.media_types.util import plain_loads
1213
from openapi_core.deserializing.media_types.util import urlencoded_form_loads
14+
from openapi_core.deserializing.media_types.util import xml_loads
15+
from openapi_core.deserializing.styles import style_deserializers_factory
1316

1417
__all__ = ["media_type_deserializers_factory"]
1518

16-
media_type_deserializers: MediaTypeDeserializersDict = {
17-
"text/html": plain_loads,
18-
"text/plain": plain_loads,
19-
"application/json": json_loads,
20-
"application/vnd.api+json": json_loads,
21-
"application/xml": xml_loads,
22-
"application/xhtml+xml": xml_loads,
23-
"application/x-www-form-urlencoded": urlencoded_form_loads,
24-
"multipart/form-data": data_form_loads,
25-
}
19+
media_type_deserializers: MediaTypeDeserializersDict = defaultdict(
20+
lambda: binary_loads,
21+
**{
22+
"text/html": plain_loads,
23+
"text/plain": plain_loads,
24+
"application/octet-stream": binary_loads,
25+
"application/json": json_loads,
26+
"application/vnd.api+json": json_loads,
27+
"application/xml": xml_loads,
28+
"application/xhtml+xml": xml_loads,
29+
"application/x-www-form-urlencoded": urlencoded_form_loads,
30+
"multipart/form-data": data_form_loads,
31+
}
32+
)
2633

2734
media_type_deserializers_factory = MediaTypeDeserializersFactory(
35+
style_deserializers_factory,
2836
media_type_deserializers=media_type_deserializers,
2937
)
+159-10
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,182 @@
11
import warnings
22
from typing import Any
3+
from typing import Mapping
34
from typing import Optional
5+
from typing import cast
46
from xml.etree.ElementTree import ParseError
57

8+
from jsonschema_path import SchemaPath
9+
610
from openapi_core.deserializing.media_types.datatypes import (
711
DeserializerCallable,
812
)
13+
from openapi_core.deserializing.media_types.datatypes import (
14+
MediaTypeDeserializersDict,
15+
)
916
from openapi_core.deserializing.media_types.exceptions import (
1017
MediaTypeDeserializeError,
1118
)
19+
from openapi_core.deserializing.styles.factories import (
20+
StyleDeserializersFactory,
21+
)
22+
from openapi_core.schema.encodings import get_content_type
23+
from openapi_core.schema.parameters import get_style_and_explode
24+
from openapi_core.schema.protocols import SuportsGetAll
25+
from openapi_core.schema.protocols import SuportsGetList
26+
from openapi_core.schema.schemas import get_properties
27+
28+
29+
class MediaTypesDeserializer:
30+
def __init__(
31+
self,
32+
media_type_deserializers: Optional[MediaTypeDeserializersDict] = None,
33+
extra_media_type_deserializers: Optional[
34+
MediaTypeDeserializersDict
35+
] = None,
36+
):
37+
if media_type_deserializers is None:
38+
media_type_deserializers = {}
39+
self.media_type_deserializers = media_type_deserializers
40+
if extra_media_type_deserializers is None:
41+
extra_media_type_deserializers = {}
42+
self.extra_media_type_deserializers = extra_media_type_deserializers
43+
44+
def deserialize(self, mimetype: str, value: Any, **parameters: str) -> Any:
45+
deserializer_callable = self.get_deserializer_callable(mimetype)
46+
47+
try:
48+
return deserializer_callable(value, **parameters)
49+
except (ParseError, ValueError, TypeError, AttributeError):
50+
raise MediaTypeDeserializeError(mimetype, value)
51+
52+
def get_deserializer_callable(
53+
self,
54+
mimetype: str,
55+
) -> DeserializerCallable:
56+
if mimetype in self.extra_media_type_deserializers:
57+
return self.extra_media_type_deserializers[mimetype]
58+
return self.media_type_deserializers[mimetype]
1259

1360

14-
class CallableMediaTypeDeserializer:
61+
class MediaTypeDeserializer:
1562
def __init__(
1663
self,
64+
style_deserializers_factory: StyleDeserializersFactory,
65+
media_types_deserializer: MediaTypesDeserializer,
1766
mimetype: str,
18-
deserializer_callable: Optional[DeserializerCallable] = None,
67+
schema: Optional[SchemaPath] = None,
68+
encoding: Optional[SchemaPath] = None,
1969
**parameters: str,
2070
):
71+
self.style_deserializers_factory = style_deserializers_factory
72+
self.media_types_deserializer = media_types_deserializer
2173
self.mimetype = mimetype
22-
self.deserializer_callable = deserializer_callable
74+
self.schema = schema
75+
self.encoding = encoding
2376
self.parameters = parameters
2477

2578
def deserialize(self, value: Any) -> Any:
26-
if self.deserializer_callable is None:
27-
warnings.warn(f"Unsupported {self.mimetype} mimetype")
28-
return value
79+
deserialized = self.media_types_deserializer.deserialize(
80+
self.mimetype, value, **self.parameters
81+
)
2982

30-
try:
31-
return self.deserializer_callable(value, **self.parameters)
32-
except (ParseError, ValueError, TypeError, AttributeError):
33-
raise MediaTypeDeserializeError(self.mimetype, value)
83+
if (
84+
self.mimetype != "application/x-www-form-urlencoded"
85+
and not self.mimetype.startswith("multipart")
86+
):
87+
return deserialized
88+
89+
# decode multipart request bodies
90+
return self.decode(deserialized)
91+
92+
def evolve(
93+
self, mimetype: str, schema: Optional[SchemaPath]
94+
) -> "MediaTypeDeserializer":
95+
cls = self.__class__
96+
97+
return cls(
98+
self.style_deserializers_factory,
99+
self.media_types_deserializer,
100+
mimetype,
101+
schema=schema,
102+
)
103+
104+
def decode(self, location: Mapping[str, Any]) -> Mapping[str, Any]:
105+
# schema is required for multipart
106+
assert self.schema is not None
107+
schema_props = self.schema.get("properties")
108+
properties = {}
109+
for prop_name, prop_schema in get_properties(self.schema).items():
110+
try:
111+
properties[prop_name] = self.decode_property(
112+
prop_name, prop_schema, location
113+
)
114+
except KeyError:
115+
if "default" not in prop_schema:
116+
continue
117+
properties[prop_name] = prop_schema["default"]
118+
119+
return properties
120+
121+
def decode_property(
122+
self,
123+
prop_name: str,
124+
prop_schema: SchemaPath,
125+
location: Mapping[str, Any],
126+
) -> Any:
127+
if self.encoding is None or prop_name not in self.encoding:
128+
return self.decode_property_content_type(
129+
prop_name, prop_schema, location
130+
)
131+
132+
prep_encoding = self.encoding / prop_name
133+
if (
134+
"style" not in prep_encoding
135+
and "explode" not in prep_encoding
136+
and "allowReserved" not in prep_encoding
137+
):
138+
return self.decode_property_content_type(
139+
prop_name, prop_schema, location, prep_encoding
140+
)
141+
142+
return self.decode_property_style(
143+
prop_name, prop_schema, location, prep_encoding
144+
)
145+
146+
def decode_property_style(
147+
self,
148+
prop_name: str,
149+
prop_schema: SchemaPath,
150+
location: Mapping[str, Any],
151+
prep_encoding: SchemaPath,
152+
) -> Any:
153+
prop_style, prop_explode = get_style_and_explode(
154+
prep_encoding, default_location="query"
155+
)
156+
prop_deserializer = self.style_deserializers_factory.create(
157+
prop_style, prop_explode, prop_schema, name=prop_name
158+
)
159+
return prop_deserializer.deserialize(location)
160+
161+
def decode_property_content_type(
162+
self,
163+
prop_name: str,
164+
prop_schema: SchemaPath,
165+
location: Mapping[str, Any],
166+
prep_encoding: Optional[SchemaPath] = None,
167+
) -> Any:
168+
prop_content_type = get_content_type(prop_schema, prep_encoding)
169+
prop_deserializer = self.evolve(
170+
prop_content_type,
171+
prop_schema,
172+
)
173+
prop_schema_type = prop_schema.getkey("type", "")
174+
if prop_schema_type == "array":
175+
if isinstance(location, SuportsGetAll):
176+
value = location.getall(prop_name)
177+
if isinstance(location, SuportsGetList):
178+
value = location.getlist(prop_name)
179+
return list(map(prop_deserializer.deserialize, value))
180+
else:
181+
value = location[prop_name]
182+
return prop_deserializer.deserialize(value)

Diff for: openapi_core/deserializing/media_types/factories.py

+24-16
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,60 @@
11
from typing import Mapping
22
from typing import Optional
33

4+
from jsonschema_path import SchemaPath
5+
46
from openapi_core.deserializing.media_types.datatypes import (
57
DeserializerCallable,
68
)
79
from openapi_core.deserializing.media_types.datatypes import (
810
MediaTypeDeserializersDict,
911
)
1012
from openapi_core.deserializing.media_types.deserializers import (
11-
CallableMediaTypeDeserializer,
13+
MediaTypeDeserializer,
14+
)
15+
from openapi_core.deserializing.media_types.deserializers import (
16+
MediaTypesDeserializer,
17+
)
18+
from openapi_core.deserializing.styles.factories import (
19+
StyleDeserializersFactory,
1220
)
1321

1422

1523
class MediaTypeDeserializersFactory:
1624
def __init__(
1725
self,
26+
style_deserializers_factory: StyleDeserializersFactory,
1827
media_type_deserializers: Optional[MediaTypeDeserializersDict] = None,
1928
):
29+
self.style_deserializers_factory = style_deserializers_factory
2030
if media_type_deserializers is None:
2131
media_type_deserializers = {}
2232
self.media_type_deserializers = media_type_deserializers
2333

2434
def create(
2535
self,
2636
mimetype: str,
37+
schema: Optional[SchemaPath] = None,
2738
parameters: Optional[Mapping[str, str]] = None,
39+
encoding: Optional[SchemaPath] = None,
2840
extra_media_type_deserializers: Optional[
2941
MediaTypeDeserializersDict
3042
] = None,
31-
) -> CallableMediaTypeDeserializer:
43+
) -> MediaTypeDeserializer:
3244
if parameters is None:
3345
parameters = {}
3446
if extra_media_type_deserializers is None:
3547
extra_media_type_deserializers = {}
36-
deserialize_callable = self.get_deserializer_callable(
37-
mimetype,
38-
extra_media_type_deserializers=extra_media_type_deserializers,
48+
media_types_deserializer = MediaTypesDeserializer(
49+
self.media_type_deserializers,
50+
extra_media_type_deserializers,
3951
)
4052

41-
return CallableMediaTypeDeserializer(
42-
mimetype, deserialize_callable, **parameters
53+
return MediaTypeDeserializer(
54+
self.style_deserializers_factory,
55+
media_types_deserializer,
56+
mimetype,
57+
schema=schema,
58+
encoding=encoding,
59+
**parameters,
4360
)
44-
45-
def get_deserializer_callable(
46-
self,
47-
mimetype: str,
48-
extra_media_type_deserializers: MediaTypeDeserializersDict,
49-
) -> Optional[DeserializerCallable]:
50-
if mimetype in extra_media_type_deserializers:
51-
return extra_media_type_deserializers[mimetype]
52-
return self.media_type_deserializers.get(mimetype)

Diff for: openapi_core/deserializing/media_types/util.py

+41-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
from email.parser import Parser
2+
from json import loads
23
from typing import Any
3-
from typing import Dict
4+
from typing import Mapping
45
from typing import Union
56
from urllib.parse import parse_qsl
7+
from xml.etree.ElementTree import Element
8+
from xml.etree.ElementTree import fromstring
9+
10+
from werkzeug.datastructures import ImmutableMultiDict
11+
12+
13+
def binary_loads(value: Union[str, bytes], **parameters: str) -> bytes:
14+
charset = "utf-8"
15+
if "charset" in parameters:
16+
charset = parameters["charset"]
17+
if isinstance(value, str):
18+
return value.encode(charset)
19+
return value
620

721

822
def plain_loads(value: Union[str, bytes], **parameters: str) -> str:
@@ -18,20 +32,37 @@ def plain_loads(value: Union[str, bytes], **parameters: str) -> str:
1832
return value
1933

2034

21-
def urlencoded_form_loads(value: Any, **parameters: str) -> Dict[str, Any]:
35+
def json_loads(value: Union[str, bytes], **parameters: str) -> Any:
36+
return loads(value)
37+
38+
39+
def xml_loads(value: Union[str, bytes], **parameters: str) -> Element:
40+
return fromstring(value)
41+
42+
43+
def urlencoded_form_loads(value: Any, **parameters: str) -> Mapping[str, Any]:
2244
return dict(parse_qsl(value))
2345

2446

2547
def data_form_loads(
2648
value: Union[str, bytes], **parameters: str
27-
) -> Dict[str, Any]:
49+
) -> Mapping[str, Any]:
2850
if isinstance(value, bytes):
2951
value = value.decode("ASCII", errors="surrogateescape")
52+
boundary = ""
53+
if "boundary" in parameters:
54+
boundary = parameters["boundary"]
3055
parser = Parser()
31-
parts = parser.parsestr(value, headersonly=False)
32-
return {
33-
part.get_param("name", header="content-disposition"): part.get_payload(
34-
decode=True
35-
)
36-
for part in parts.get_payload()
37-
}
56+
mimetype = "multipart/form-data"
57+
header = f'Content-Type: {mimetype}; boundary="{boundary}"'
58+
text = "\n\n".join([header, value])
59+
parts = parser.parsestr(text, headersonly=False)
60+
return ImmutableMultiDict(
61+
[
62+
(
63+
part.get_param("name", header="content-disposition"),
64+
part.get_payload(decode=True),
65+
)
66+
for part in parts.get_payload()
67+
]
68+
)

0 commit comments

Comments
 (0)