Skip to content

Commit db3e222

Browse files
committedSep 29, 2022
Starlette support
1 parent 1f07fa2 commit db3e222

File tree

13 files changed

+452
-45
lines changed

13 files changed

+452
-45
lines changed
 

‎docs/index.rst

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ Table of contents
3131

3232
installation
3333
usage
34-
extensions
35-
customizations
3634
integrations
35+
customizations
36+
extensions
3737

3838

3939
Related projects

‎docs/integrations.rst

+43-14
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The integration supports Django from version 3.0 and above.
1616
Middleware
1717
~~~~~~~~~~
1818

19-
Django can be integrated by middleware. Add `DjangoOpenAPIMiddleware` to your `MIDDLEWARE` list and define `OPENAPI_SPEC`.
19+
Django can be integrated by middleware. Add ``DjangoOpenAPIMiddleware`` to your ``MIDDLEWARE`` list and define ``OPENAPI_SPEC``.
2020

2121
.. code-block:: python
2222
@@ -52,7 +52,7 @@ After that you have access to validation result object with all validated reques
5252
Low level
5353
~~~~~~~~~
5454

55-
You can use `DjangoOpenAPIRequest` as a Django request factory:
55+
You can use ``DjangoOpenAPIRequest`` as a Django request factory:
5656

5757
.. code-block:: python
5858
@@ -62,7 +62,7 @@ You can use `DjangoOpenAPIRequest` as a Django request factory:
6262
openapi_request = DjangoOpenAPIRequest(django_request)
6363
result = openapi_request_validator.validate(spec, openapi_request)
6464
65-
You can use `DjangoOpenAPIResponse` as a Django response factory:
65+
You can use ``DjangoOpenAPIResponse`` as a Django response factory:
6666

6767
.. code-block:: python
6868
@@ -82,7 +82,7 @@ The integration supports Falcon from version 3.0 and above.
8282
Middleware
8383
~~~~~~~~~~
8484

85-
The Falcon API can be integrated by `FalconOpenAPIMiddleware` middleware.
85+
The Falcon API can be integrated by ``FalconOpenAPIMiddleware`` middleware.
8686

8787
.. code-block:: python
8888
@@ -111,7 +111,7 @@ After that you will have access to validation result object with all validated r
111111
Low level
112112
~~~~~~~~~
113113

114-
You can use `FalconOpenAPIRequest` as a Falcon request factory:
114+
You can use ``FalconOpenAPIRequest`` as a Falcon request factory:
115115

116116
.. code-block:: python
117117
@@ -121,7 +121,7 @@ You can use `FalconOpenAPIRequest` as a Falcon request factory:
121121
openapi_request = FalconOpenAPIRequest(falcon_request)
122122
result = openapi_request_validator.validate(spec, openapi_request)
123123
124-
You can use `FalconOpenAPIResponse` as a Falcon response factory:
124+
You can use ``FalconOpenAPIResponse`` as a Falcon response factory:
125125

126126
.. code-block:: python
127127
@@ -140,7 +140,7 @@ This section describes integration with `Flask <https://flask.palletsprojects.co
140140
Decorator
141141
~~~~~~~~~
142142

143-
Flask views can be integrated by `FlaskOpenAPIViewDecorator` decorator.
143+
Flask views can be integrated by ``FlaskOpenAPIViewDecorator`` decorator.
144144

145145
.. code-block:: python
146146
@@ -163,7 +163,7 @@ If you want to decorate class based view you can use the decorators attribute:
163163
View
164164
~~~~
165165

166-
As an alternative to the decorator-based integration, a Flask method based views can be integrated by inheritance from `FlaskOpenAPIView` class.
166+
As an alternative to the decorator-based integration, a Flask method based views can be integrated by inheritance from ``FlaskOpenAPIView`` class.
167167

168168
.. code-block:: python
169169
@@ -177,7 +177,7 @@ As an alternative to the decorator-based integration, a Flask method based views
177177
Request parameters
178178
~~~~~~~~~~~~~~~~~~
179179

180-
In Flask, all unmarshalled request data are provided as Flask request object's `openapi.parameters` attribute
180+
In Flask, all unmarshalled request data are provided as Flask request object's ``openapi.parameters`` attribute
181181

182182
.. code-block:: python
183183
@@ -192,7 +192,7 @@ In Flask, all unmarshalled request data are provided as Flask request object's `
192192
Low level
193193
~~~~~~~~~
194194

195-
You can use `FlaskOpenAPIRequest` as a Flask request factory:
195+
You can use ``FlaskOpenAPIRequest`` as a Flask request factory:
196196

197197
.. code-block:: python
198198
@@ -219,7 +219,7 @@ This section describes integration with `Requests <https://requests.readthedocs.
219219
Low level
220220
~~~~~~~~~
221221

222-
You can use `RequestsOpenAPIRequest` as a Requests request factory:
222+
You can use ``RequestsOpenAPIRequest`` as a Requests request factory:
223223

224224
.. code-block:: python
225225
@@ -229,7 +229,7 @@ You can use `RequestsOpenAPIRequest` as a Requests request factory:
229229
openapi_request = RequestsOpenAPIRequest(requests_request)
230230
result = openapi_request_validator.validate(spec, openapi_request)
231231
232-
You can use `RequestsOpenAPIResponse` as a Requests response factory:
232+
You can use ``RequestsOpenAPIResponse`` as a Requests response factory:
233233

234234
.. code-block:: python
235235
@@ -240,6 +240,35 @@ You can use `RequestsOpenAPIResponse` as a Requests response factory:
240240
result = openapi_respose_validator.validate(spec, openapi_request, openapi_response)
241241
242242
243+
Starlette
244+
---------
245+
246+
This section describes integration with `Starlette <https://www.starlette.io>`__ ASGI framework.
247+
248+
Low level
249+
~~~~~~~~~
250+
251+
You can use ``StarletteOpenAPIRequest`` as a Starlette request factory:
252+
253+
.. code-block:: python
254+
255+
from openapi_core.validation.request import openapi_request_validator
256+
from openapi_core.contrib.starlette import StarletteOpenAPIRequest
257+
258+
openapi_request = StarletteOpenAPIRequest(starlette_request)
259+
result = openapi_request_validator.validate(spec, openapi_request)
260+
261+
You can use ``StarletteOpenAPIResponse`` as a Starlette response factory:
262+
263+
.. code-block:: python
264+
265+
from openapi_core.validation.response import openapi_respose_validator
266+
from openapi_core.contrib.starlette import StarletteOpenAPIResponse
267+
268+
openapi_response = StarletteOpenAPIResponse(starlette_response)
269+
result = openapi_respose_validator.validate(spec, openapi_request, openapi_response)
270+
271+
243272
Tornado
244273
-------
245274

@@ -254,7 +283,7 @@ This section describes integration with `Werkzeug <https://werkzeug.palletsproje
254283
Low level
255284
~~~~~~~~~
256285

257-
You can use `WerkzeugOpenAPIRequest` as a Werkzeug request factory:
286+
You can use ``WerkzeugOpenAPIRequest`` as a Werkzeug request factory:
258287

259288
.. code-block:: python
260289
@@ -264,7 +293,7 @@ You can use `WerkzeugOpenAPIRequest` as a Werkzeug request factory:
264293
openapi_request = WerkzeugOpenAPIRequest(werkzeug_request)
265294
result = openapi_request_validator.validate(spec, openapi_request)
266295
267-
You can use `WerkzeugOpenAPIResponse` as a Werkzeug response factory:
296+
You can use ``WerkzeugOpenAPIResponse`` as a Werkzeug response factory:
268297

269298
.. code-block:: python
270299
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest
2+
from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse
3+
4+
__all__ = [
5+
"StarletteOpenAPIRequest",
6+
"StarletteOpenAPIResponse",
7+
]
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""OpenAPI core contrib starlette requests module"""
2+
from typing import Optional
3+
4+
from asgiref.sync import AsyncToSync
5+
from starlette.requests import Request
6+
7+
from openapi_core.validation.request.datatypes import RequestParameters
8+
9+
10+
class StarletteOpenAPIRequest:
11+
def __init__(self, request: Request):
12+
self.request = request
13+
14+
self.parameters = RequestParameters(
15+
query=self.request.query_params,
16+
header=self.request.headers,
17+
cookie=self.request.cookies,
18+
)
19+
20+
self._get_body = AsyncToSync(self.request.body, force_new_loop=True) # type: ignore
21+
22+
@property
23+
def host_url(self) -> str:
24+
return self.request.base_url._url
25+
26+
@property
27+
def path(self) -> str:
28+
return self.request.url.path
29+
30+
@property
31+
def method(self) -> str:
32+
return self.request.method.lower()
33+
34+
@property
35+
def body(self) -> Optional[str]:
36+
body = self._get_body()
37+
if body is None:
38+
return None
39+
if isinstance(body, bytes):
40+
return body.decode("utf-8")
41+
assert isinstance(body, str)
42+
return body
43+
44+
@property
45+
def mimetype(self) -> str:
46+
content_type = self.request.headers["Content-Type"]
47+
if content_type:
48+
return content_type.partition(";")[0]
49+
50+
return ""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""OpenAPI core contrib starlette responses module"""
2+
from starlette.datastructures import Headers
3+
from starlette.responses import Response
4+
5+
6+
class StarletteOpenAPIResponse:
7+
def __init__(self, response: Response):
8+
self.response = response
9+
10+
@property
11+
def data(self) -> str:
12+
if isinstance(self.response.body, bytes):
13+
return self.response.body.decode("utf-8")
14+
assert isinstance(self.response.body, str)
15+
return self.response.body
16+
17+
@property
18+
def status_code(self) -> int:
19+
return self.response.status_code
20+
21+
@property
22+
def mimetype(self) -> str:
23+
return self.response.media_type or ""
24+
25+
@property
26+
def headers(self) -> Headers:
27+
return self.response.headers

‎openapi_core/schema/parameters.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import re
22
from typing import Any
33
from typing import Dict
4+
from typing import Mapping
45
from typing import Optional
5-
from typing import Union
6-
7-
from werkzeug.datastructures import Headers
86

97
from openapi_core.schema.protocols import SuportsGetAll
108
from openapi_core.schema.protocols import SuportsGetList
@@ -49,7 +47,7 @@ def get_explode(param_or_header: Spec) -> bool:
4947

5048
def get_value(
5149
param_or_header: Spec,
52-
location: Union[Headers, Dict[str, Any]],
50+
location: Mapping[str, Any],
5351
name: Optional[str] = None,
5452
) -> Any:
5553
"""Returns parameter/header value from specific location"""
@@ -80,7 +78,7 @@ def get_value(
8078

8179

8280
def get_deep_object_value(
83-
location: Union[Headers, Dict[str, Any]],
81+
location: Mapping[str, Any],
8482
name: Optional[str] = None,
8583
) -> Dict[str, Any]:
8684
values = {}

‎openapi_core/validation/request/datatypes.py

+9-14
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
from dataclasses import dataclass
55
from dataclasses import field
66
from typing import Any
7-
from typing import Dict
8-
from typing import Optional
7+
from typing import Mapping
98

109
from werkzeug.datastructures import Headers
1110
from werkzeug.datastructures import ImmutableMultiDict
@@ -28,25 +27,21 @@ class RequestParameters:
2827
Path parameters as dict. Gets resolved against spec if empty.
2928
"""
3029

31-
query: ImmutableMultiDict[str, Any] = field(
32-
default_factory=ImmutableMultiDict
33-
)
34-
header: Headers = field(default_factory=Headers)
35-
cookie: ImmutableMultiDict[str, Any] = field(
36-
default_factory=ImmutableMultiDict
37-
)
38-
path: dict[str, Any] = field(default_factory=dict)
30+
query: Mapping[str, Any] = field(default_factory=ImmutableMultiDict)
31+
header: Mapping[str, Any] = field(default_factory=Headers)
32+
cookie: Mapping[str, Any] = field(default_factory=ImmutableMultiDict)
33+
path: Mapping[str, Any] = field(default_factory=dict)
3934

4035
def __getitem__(self, location: str) -> Any:
4136
return getattr(self, location)
4237

4338

4439
@dataclass
4540
class Parameters:
46-
query: dict[str, Any] = field(default_factory=dict)
47-
header: dict[str, Any] = field(default_factory=dict)
48-
cookie: dict[str, Any] = field(default_factory=dict)
49-
path: dict[str, Any] = field(default_factory=dict)
41+
query: Mapping[str, Any] = field(default_factory=dict)
42+
header: Mapping[str, Any] = field(default_factory=dict)
43+
cookie: Mapping[str, Any] = field(default_factory=dict)
44+
path: Mapping[str, Any] = field(default_factory=dict)
5045

5146

5247
@dataclass

‎openapi_core/validation/response/protocols.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""OpenAPI core validation response protocols module"""
22
from typing import TYPE_CHECKING
3+
from typing import Any
4+
from typing import Mapping
35
from typing import Optional
46

57
if TYPE_CHECKING:
@@ -13,8 +15,6 @@
1315
from typing_extensions import Protocol
1416
from typing_extensions import runtime_checkable
1517

16-
from werkzeug.datastructures import Headers
17-
1818
from openapi_core.spec import Spec
1919
from openapi_core.validation.request.protocols import Request
2020
from openapi_core.validation.response.datatypes import ResponseValidationResult
@@ -48,7 +48,7 @@ def mimetype(self) -> str:
4848
...
4949

5050
@property
51-
def headers(self) -> Headers:
51+
def headers(self) -> Mapping[str, Any]:
5252
...
5353

5454

‎openapi_core/validation/validators.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
"""OpenAPI core validation validators module"""
22
from typing import Any
3-
from typing import Dict
3+
from typing import Mapping
44
from typing import Optional
5-
from typing import Union
6-
from urllib.parse import urljoin
7-
8-
from werkzeug.datastructures import Headers
95

106
from openapi_core.casting.schemas import schema_casters_factory
117
from openapi_core.casting.schemas.factories import SchemaCastersFactory
@@ -82,7 +78,7 @@ def _unmarshal(self, schema: Spec, value: Any) -> Any:
8278
def _get_param_or_header_value(
8379
self,
8480
param_or_header: Spec,
85-
location: Union[Headers, Dict[str, Any]],
81+
location: Mapping[str, Any],
8682
name: Optional[str] = None,
8783
) -> Any:
8884
try:

‎poetry.lock

+110-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml

+4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ strict = true
1515

1616
[[tool.mypy.overrides]]
1717
module = [
18+
"asgiref.*",
1819
"django.*",
1920
"falcon.*",
2021
"isodate.*",
@@ -70,6 +71,7 @@ django = ["django"]
7071
falcon = ["falcon"]
7172
flask = ["flask"]
7273
requests = ["requests"]
74+
starlette = ["starlette", "httpx"]
7375

7476
[tool.poetry.dev-dependencies]
7577
black = "^22.3.0"
@@ -88,6 +90,8 @@ sphinx-rtd-theme = "^0.5.2"
8890
strict-rfc3339 = "^0.7"
8991
webob = "*"
9092
mypy = "^0.971"
93+
starlette = "^0.21.0"
94+
httpx = "^0.23.0"
9195

9296
[tool.pytest.ini_options]
9397
addopts = """
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Basic OpenAPI specification used with starlette integration tests
4+
version: "0.1"
5+
servers:
6+
- url: 'http://localhost'
7+
paths:
8+
'/browse/{id}/':
9+
parameters:
10+
- name: id
11+
in: path
12+
required: true
13+
description: the ID of the resource to retrieve
14+
schema:
15+
type: integer
16+
- name: q
17+
in: query
18+
required: true
19+
description: query key
20+
schema:
21+
type: string
22+
post:
23+
requestBody:
24+
description: request data
25+
required: True
26+
content:
27+
application/json:
28+
schema:
29+
type: object
30+
required:
31+
- param1
32+
properties:
33+
param1:
34+
type: integer
35+
responses:
36+
200:
37+
description: Return the resource.
38+
content:
39+
application/json:
40+
schema:
41+
type: object
42+
required:
43+
- data
44+
properties:
45+
data:
46+
type: string
47+
headers:
48+
X-Rate-Limit:
49+
description: Rate limit
50+
schema:
51+
type: integer
52+
required: true
53+
default:
54+
description: Return errors.
55+
content:
56+
application/json:
57+
schema:
58+
type: object
59+
required:
60+
- errors
61+
properties:
62+
errors:
63+
type: array
64+
items:
65+
type: object
66+
properties:
67+
title:
68+
type: string
69+
code:
70+
type: string
71+
message:
72+
type: string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from json import dumps
2+
3+
import pytest
4+
from starlette.applications import Starlette
5+
from starlette.requests import Request
6+
from starlette.responses import JSONResponse
7+
from starlette.responses import PlainTextResponse
8+
from starlette.routing import Route
9+
from starlette.testclient import TestClient
10+
11+
from openapi_core.contrib.starlette import StarletteOpenAPIRequest
12+
from openapi_core.contrib.starlette import StarletteOpenAPIResponse
13+
from openapi_core.validation.request import openapi_request_validator
14+
from openapi_core.validation.response import openapi_response_validator
15+
16+
17+
class TestStarletteOpenAPIValidation:
18+
@pytest.fixture
19+
def spec(self, factory):
20+
specfile = "contrib/starlette/data/v3.0/starlette_factory.yaml"
21+
return factory.spec_from_file(specfile)
22+
23+
@pytest.fixture
24+
def app(self):
25+
async def test_route(scope, receive, send):
26+
request = Request(scope, receive)
27+
if request.args.get("q") == "string":
28+
response = JSONResponse(
29+
dumps({"data": "data"}),
30+
headers={"X-Rate-Limit": "12"},
31+
mimetype="application/json",
32+
status=200,
33+
)
34+
else:
35+
response = PlainTextResponse("Not Found", status=404)
36+
await response(scope, receive, send)
37+
38+
return Starlette(
39+
routes=[
40+
Route("/browse/12/", test_route),
41+
],
42+
)
43+
44+
@pytest.fixture
45+
def client(self, app):
46+
return TestClient(app, base_url="http://localhost")
47+
48+
def test_request_validator_path_pattern(self, client, spec):
49+
response_data = {"data": "data"}
50+
51+
def test_route(request):
52+
openapi_request = StarletteOpenAPIRequest(request)
53+
result = openapi_request_validator.validate(spec, openapi_request)
54+
assert not result.errors
55+
return JSONResponse(
56+
response_data,
57+
headers={"X-Rate-Limit": "12"},
58+
media_type="application/json",
59+
status_code=200,
60+
)
61+
62+
app = Starlette(
63+
routes=[
64+
Route("/browse/12/", test_route, methods=["POST"]),
65+
],
66+
)
67+
client = TestClient(app, base_url="http://localhost")
68+
query_string = {
69+
"q": "string",
70+
}
71+
headers = {"content-type": "application/json"}
72+
data = {"param1": 1}
73+
response = client.post(
74+
"/browse/12/",
75+
params=query_string,
76+
json=data,
77+
headers=headers,
78+
)
79+
80+
assert response.status_code == 200
81+
assert response.json() == response_data
82+
83+
def test_response_validator_path_pattern(self, client, spec):
84+
response_data = {"data": "data"}
85+
86+
def test_route(request):
87+
response = JSONResponse(
88+
response_data,
89+
headers={"X-Rate-Limit": "12"},
90+
media_type="application/json",
91+
status_code=200,
92+
)
93+
openapi_request = StarletteOpenAPIRequest(request)
94+
openapi_response = StarletteOpenAPIResponse(response)
95+
result = openapi_response_validator.validate(
96+
spec, openapi_request, openapi_response
97+
)
98+
assert not result.errors
99+
return response
100+
101+
app = Starlette(
102+
routes=[
103+
Route("/browse/12/", test_route, methods=["POST"]),
104+
],
105+
)
106+
client = TestClient(app, base_url="http://localhost")
107+
query_string = {
108+
"q": "string",
109+
}
110+
headers = {"content-type": "application/json"}
111+
data = {"param1": 1}
112+
response = client.post(
113+
"/browse/12/",
114+
params=query_string,
115+
json=data,
116+
headers=headers,
117+
)
118+
119+
assert response.status_code == 200
120+
assert response.json() == response_data

0 commit comments

Comments
 (0)
Please sign in to comment.