Skip to content

Commit 868c081

Browse files
authored
Merge pull request #422 from p1c2u/feature/x-model-extension-import-model-class
x-model extension import model class
2 parents 5553fe4 + c2e166e commit 868c081

File tree

18 files changed

+219
-184
lines changed

18 files changed

+219
-184
lines changed

docs/extensions.rst

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
Extensions
2+
==========
3+
4+
x-model
5+
-------
6+
7+
By default, objects are unmarshalled to dynamically created dataclasses. You can use your own dataclasses, pydantic models or models generated by third party generators (i.e. `datamodel-code-generator <https://github.com/koxudaxi/datamodel-code-generator>`__) by providing ``x-model`` property inside schema definition with location of your class.
8+
9+
.. code-block:: yaml
10+
11+
...
12+
components:
13+
schemas:
14+
Coordinates:
15+
x-model: foo.bar.Coordinates
16+
type: object
17+
required:
18+
- lat
19+
- lon
20+
properties:
21+
lat:
22+
type: number
23+
lon:
24+
type: number
25+
26+
.. code-block:: python
27+
28+
# foo/bar.py
29+
from dataclasses import dataclass
30+
31+
@dataclass
32+
class Coordinates:
33+
lat: float
34+
lon: float

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Table of contents
3131

3232
installation
3333
usage
34+
extensions
3435
customizations
3536
integrations
3637

openapi_core/contrib/django/handlers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def format_openapi_error(cls, error: Exception) -> Dict[str, Any]:
4747
return {
4848
"title": str(error),
4949
"status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400),
50-
"class": str(type(error)),
50+
"type": str(type(error)),
5151
}
5252

5353
@classmethod

openapi_core/contrib/falcon/handlers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def format_openapi_error(cls, error: Exception) -> Dict[str, Any]:
5353
return {
5454
"title": str(error),
5555
"status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400),
56-
"class": str(type(error)),
56+
"type": str(type(error)),
5757
}
5858

5959
@classmethod
+36-18
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,51 @@
11
"""OpenAPI X-Model extension factories module"""
2+
from dataclasses import make_dataclass
3+
from pydoc import ErrorDuringImport
4+
from pydoc import locate
25
from typing import Any
36
from typing import Dict
7+
from typing import Iterable
48
from typing import Optional
59
from typing import Type
610

7-
from openapi_core.extensions.models.models import Model
11+
from openapi_core.extensions.models.types import Field
812

913

10-
class ModelClassFactory:
14+
class DictFactory:
1115

12-
base_class = Model
16+
base_class = dict
1317

14-
def create(self, name: str) -> Type[Model]:
15-
return type(name, (self.base_class,), {})
18+
def create(self, fields: Iterable[Field]) -> Type[Dict[Any, Any]]:
19+
return self.base_class
1620

1721

18-
class ModelFactory:
19-
def __init__(
20-
self, model_class_factory: Optional[ModelClassFactory] = None
21-
):
22-
self.model_class_factory = model_class_factory or ModelClassFactory()
23-
22+
class DataClassFactory(DictFactory):
2423
def create(
25-
self, properties: Optional[Dict[str, Any]], name: Optional[str] = None
26-
) -> Model:
27-
name = name or "Model"
24+
self,
25+
fields: Iterable[Field],
26+
name: str = "Model",
27+
) -> Type[Any]:
28+
return make_dataclass(name, fields, frozen=True)
2829

29-
model_class = self._create_class(name)
30-
return model_class(properties)
3130

32-
def _create_class(self, name: str) -> Type[Model]:
33-
return self.model_class_factory.create(name)
31+
class ModelClassImporter(DataClassFactory):
32+
def create(
33+
self,
34+
fields: Iterable[Field],
35+
name: str = "Model",
36+
model: Optional[str] = None,
37+
) -> Any:
38+
if model is None:
39+
return super().create(fields, name=name)
40+
41+
model_class = self._get_class(model)
42+
if model_class is not None:
43+
return model_class
44+
45+
return super().create(fields, name=model)
46+
47+
def _get_class(self, model_class_path: str) -> Optional[object]:
48+
try:
49+
return locate(model_class_path)
50+
except ErrorDuringImport:
51+
return None

openapi_core/extensions/models/models.py

-29
This file was deleted.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from typing import Any
2+
from typing import Tuple
3+
from typing import Union
4+
5+
Field = Union[str, Tuple[str, Any]]

openapi_core/unmarshalling/schemas/unmarshallers.py

+15-13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING
44
from typing import Any
55
from typing import Dict
6+
from typing import Iterable
67
from typing import List
78
from typing import Optional
89

@@ -16,7 +17,7 @@
1617
from openapi_schema_validator._format import oas30_format_checker
1718
from openapi_schema_validator._types import is_string
1819

19-
from openapi_core.extensions.models.factories import ModelFactory
20+
from openapi_core.extensions.models.factories import ModelClassImporter
2021
from openapi_core.schema.schemas import get_all_properties
2122
from openapi_core.schema.schemas import get_all_properties_names
2223
from openapi_core.spec import Spec
@@ -196,8 +197,8 @@ class ObjectUnmarshaller(ComplexUnmarshaller):
196197
}
197198

198199
@property
199-
def model_factory(self) -> ModelFactory:
200-
return ModelFactory()
200+
def object_class_factory(self) -> ModelClassImporter:
201+
return ModelClassImporter()
201202

202203
def unmarshal(self, value: Any) -> Any:
203204
try:
@@ -230,11 +231,11 @@ def _unmarshal_object(self, value: Any) -> Any:
230231
else:
231232
properties = self._unmarshal_properties(value)
232233

233-
if "x-model" in self.schema:
234-
name = self.schema["x-model"]
235-
return self.model_factory.create(properties, name=name)
234+
model = self.schema.getkey("x-model")
235+
fields: Iterable[str] = properties and properties.keys() or []
236+
object_class = self.object_class_factory.create(fields, model=model)
236237

237-
return properties
238+
return object_class(**properties)
238239

239240
def _unmarshal_properties(
240241
self, value: Any, one_of_schema: Optional[Spec] = None
@@ -253,17 +254,18 @@ def _unmarshal_properties(
253254
additional_properties = self.schema.getkey(
254255
"additionalProperties", True
255256
)
256-
if isinstance(additional_properties, dict):
257-
additional_prop_schema = self.schema / "additionalProperties"
257+
if additional_properties is not False:
258+
# free-form object
259+
if additional_properties is True:
260+
additional_prop_schema = Spec.from_dict({})
261+
# defined schema
262+
else:
263+
additional_prop_schema = self.schema / "additionalProperties"
258264
for prop_name in extra_props:
259265
prop_value = value[prop_name]
260266
properties[prop_name] = self.unmarshallers_factory.create(
261267
additional_prop_schema
262268
)(prop_value)
263-
elif additional_properties is True:
264-
for prop_name in extra_props:
265-
prop_value = value[prop_name]
266-
properties[prop_name] = prop_value
267269

268270
for prop_name, prop in list(all_props.items()):
269271
read_only = prop.getkey("readOnly", False)

tests/integration/contrib/django/test_django_project.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def test_get_no_required_param(self, client):
5454
expected_data = {
5555
"errors": [
5656
{
57-
"class": (
57+
"type": (
5858
"<class 'openapi_core.validation.exceptions."
5959
"MissingRequiredParameter'>"
6060
),
@@ -101,7 +101,7 @@ def test_post_server_invalid(self, client):
101101
expected_data = {
102102
"errors": [
103103
{
104-
"class": (
104+
"type": (
105105
"<class 'openapi_core.templating.paths.exceptions."
106106
"ServerNotFound'>"
107107
),
@@ -148,7 +148,7 @@ def test_post_required_header_param_missing(self, client):
148148
expected_data = {
149149
"errors": [
150150
{
151-
"class": (
151+
"type": (
152152
"<class 'openapi_core.validation.exceptions."
153153
"MissingRequiredParameter'>"
154154
),
@@ -176,7 +176,7 @@ def test_post_media_type_invalid(self, client):
176176
expected_data = {
177177
"errors": [
178178
{
179-
"class": (
179+
"type": (
180180
"<class 'openapi_core.templating.media_types."
181181
"exceptions.MediaTypeNotFound'>"
182182
),
@@ -213,7 +213,7 @@ def test_post_required_cookie_param_missing(self, client):
213213
expected_data = {
214214
"errors": [
215215
{
216-
"class": (
216+
"type": (
217217
"<class 'openapi_core.validation.exceptions."
218218
"MissingRequiredParameter'>"
219219
),
@@ -267,7 +267,7 @@ def test_get_unauthorized(self, client):
267267
expected_data = {
268268
"errors": [
269269
{
270-
"class": (
270+
"type": (
271271
"<class 'openapi_core.validation.exceptions."
272272
"InvalidSecurity'>"
273273
),
@@ -289,7 +289,7 @@ def test_delete_method_invalid(self, client):
289289
expected_data = {
290290
"errors": [
291291
{
292-
"class": (
292+
"type": (
293293
"<class 'openapi_core.templating.paths.exceptions."
294294
"OperationNotFound'>"
295295
),

tests/integration/contrib/falcon/test_falcon_project.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def test_post_server_invalid(self, client):
6565
expected_data = {
6666
"errors": [
6767
{
68-
"class": (
68+
"type": (
6969
"<class 'openapi_core.templating.paths.exceptions."
7070
"ServerNotFound'>"
7171
),
@@ -119,7 +119,7 @@ def test_post_required_header_param_missing(self, client):
119119
expected_data = {
120120
"errors": [
121121
{
122-
"class": (
122+
"type": (
123123
"<class 'openapi_core.validation.exceptions."
124124
"MissingRequiredParameter'>"
125125
),
@@ -155,7 +155,7 @@ def test_post_media_type_invalid(self, client):
155155
expected_data = {
156156
"errors": [
157157
{
158-
"class": (
158+
"type": (
159159
"<class 'openapi_core.templating.media_types."
160160
"exceptions.MediaTypeNotFound'>"
161161
),
@@ -198,7 +198,7 @@ def test_post_required_cookie_param_missing(self, client):
198198
expected_data = {
199199
"errors": [
200200
{
201-
"class": (
201+
"type": (
202202
"<class 'openapi_core.validation.exceptions."
203203
"MissingRequiredParameter'>"
204204
),
@@ -249,7 +249,7 @@ def test_get_server_invalid(self, client):
249249
expected_data = {
250250
"errors": [
251251
{
252-
"class": (
252+
"type": (
253253
"<class 'openapi_core.templating.paths.exceptions."
254254
"ServerNotFound'>"
255255
),
@@ -283,7 +283,7 @@ def test_get_unauthorized(self, client):
283283
expected_data = {
284284
"errors": [
285285
{
286-
"class": (
286+
"type": (
287287
"<class 'openapi_core.validation.exceptions."
288288
"InvalidSecurity'>"
289289
),
@@ -324,7 +324,7 @@ def test_delete_method_invalid(self, client):
324324
expected_data = {
325325
"errors": [
326326
{
327-
"class": (
327+
"type": (
328328
"<class 'openapi_core.templating.paths.exceptions."
329329
"OperationNotFound'>"
330330
),

0 commit comments

Comments
 (0)