Skip to content

Commit 8fdb454

Browse files
authored
Merge pull request #571 from seandstewart/aiohttp-web-support
Add support for `aiohttp.web`
2 parents 104c0f8 + 4ef7760 commit 8fdb454

File tree

11 files changed

+847
-104
lines changed

11 files changed

+847
-104
lines changed

docs/integrations.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,36 @@ Integrations
33

44
Openapi-core integrates with your popular libraries and frameworks. Each integration offers different levels of integration that help validate and unmarshal your request and response data.
55

6+
aiohttp.web
7+
-----------
8+
9+
This section describes integration with `aiohttp.web <https://docs.aiohttp.org/en/stable/web.html>`__ framework.
10+
11+
Low level
12+
~~~~~~~~~
13+
14+
You can use ``AIOHTTPOpenAPIWebRequest`` as an aiohttp request factory:
15+
16+
.. code-block:: python
17+
18+
from openapi_core import unmarshal_request
19+
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest
20+
21+
request_body = await aiohttp_request.text()
22+
openapi_request = AIOHTTPOpenAPIWebRequest(aiohttp_request, body=request_body)
23+
result = unmarshal_request(openapi_request, spec=spec)
24+
25+
You can use ``AIOHTTPOpenAPIWebRequest`` as an aiohttp response factory:
26+
27+
.. code-block:: python
28+
29+
from openapi_core import unmarshal_response
30+
from openapi_core.contrib.starlette import AIOHTTPOpenAPIWebRequest
31+
32+
openapi_response = StarletteOpenAPIResponse(aiohttp_response)
33+
result = unmarshal_response(openapi_request, openapi_response, spec=spec)
34+
35+
636
Bottle
737
------
838

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from openapi_core.contrib.aiohttp.requests import AIOHTTPOpenAPIWebRequest
2+
from openapi_core.contrib.aiohttp.responses import AIOHTTPOpenAPIWebResponse
3+
4+
__all__ = [
5+
"AIOHTTPOpenAPIWebRequest",
6+
"AIOHTTPOpenAPIWebResponse",
7+
]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""OpenAPI core contrib aiohttp requests module"""
2+
from __future__ import annotations
3+
4+
from typing import cast
5+
6+
from aiohttp import web
7+
from asgiref.sync import AsyncToSync
8+
9+
from openapi_core.datatypes import RequestParameters
10+
11+
12+
class Empty:
13+
...
14+
15+
16+
_empty = Empty()
17+
18+
19+
class AIOHTTPOpenAPIWebRequest:
20+
__slots__ = ("request", "parameters", "_get_body", "_body")
21+
22+
def __init__(self, request: web.Request, *, body: str | None):
23+
if not isinstance(request, web.Request):
24+
raise TypeError(
25+
f"'request' argument is not type of {web.Request.__qualname__!r}"
26+
)
27+
self.request = request
28+
self.parameters = RequestParameters(
29+
query=self.request.query,
30+
header=self.request.headers,
31+
cookie=self.request.cookies,
32+
)
33+
self._body = body
34+
35+
@property
36+
def host_url(self) -> str:
37+
return self.request.url.host or ""
38+
39+
@property
40+
def path(self) -> str:
41+
return self.request.url.path
42+
43+
@property
44+
def method(self) -> str:
45+
return self.request.method.lower()
46+
47+
@property
48+
def body(self) -> str | None:
49+
return self._body
50+
51+
@property
52+
def mimetype(self) -> str:
53+
return self.request.content_type
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""OpenAPI core contrib aiohttp responses module"""
2+
3+
import multidict
4+
from aiohttp import web
5+
6+
7+
class AIOHTTPOpenAPIWebResponse:
8+
def __init__(self, response: web.Response):
9+
if not isinstance(response, web.Response):
10+
raise TypeError(
11+
f"'response' argument is not type of {web.Response.__qualname__!r}"
12+
)
13+
self.response = response
14+
15+
@property
16+
def data(self) -> str:
17+
if isinstance(self.response.body, bytes):
18+
return self.response.body.decode("utf-8")
19+
assert isinstance(self.response.body, str)
20+
return self.response.body
21+
22+
@property
23+
def status_code(self) -> int:
24+
return self.response.status
25+
26+
@property
27+
def mimetype(self) -> str:
28+
return self.response.content_type or ""
29+
30+
@property
31+
def headers(self) -> multidict.CIMultiDict[str]:
32+
return self.response.headers

poetry.lock

Lines changed: 448 additions & 100 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ python = "^3.7.0"
5959
django = {version = ">=3.0", optional = true}
6060
falcon = {version = ">=3.0", optional = true}
6161
flask = {version = "*", optional = true}
62+
aiohttp = {version = ">=3.0", optional = true}
63+
starlette = {version = ">=0.26.1,<0.28.0", optional = true}
6264
isodate = "*"
6365
more-itertools = "*"
6466
parse = "*"
@@ -71,16 +73,17 @@ jsonschema-spec = "^0.1.1"
7173
backports-cached-property = {version = "^1.0.2", python = "<3.8" }
7274
asgiref = "^3.6.0"
7375
jsonschema = "^4.17.3"
74-
starlette = {version = ">=0.26.1,<0.28.0", optional = true}
76+
multidict = {version = "^6.0.4", optional = true}
7577

7678
[tool.poetry.extras]
7779
django = ["django"]
7880
falcon = ["falcon"]
7981
flask = ["flask"]
8082
requests = ["requests"]
83+
aiohttp = ["aiohttp", "multidict"]
8184
starlette = ["starlette"]
8285

83-
[tool.poetry.dev-dependencies]
86+
[tool.poetry.group.dev.dependencies]
8487
black = "^23.3.0"
8588
django = ">=3.0"
8689
djangorestframework = "^3.11.2"
@@ -97,7 +100,8 @@ webob = "*"
97100
mypy = "^1.2"
98101
httpx = "^0.24.0"
99102
deptry = { version = "^0.11.0", python = ">=3.8" }
100-
103+
aiohttp = "^3.8.4"
104+
pytest-aiohttp = "^1.0.4"
101105

102106
[tool.poetry.group.docs.dependencies]
103107
sphinx = "^5.3.0"
@@ -113,6 +117,7 @@ addopts = """
113117
--cov-report=term-missing
114118
--cov-report=xml
115119
"""
120+
asyncio_mode = "auto"
116121

117122
[tool.black]
118123
line-length = 79
@@ -125,4 +130,4 @@ force_single_line = true
125130
[tool.deptry.package_module_name_map]
126131
backports-cached-property = [
127132
"backports"
128-
]
133+
]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import asyncio
2+
import pathlib
3+
from typing import Any
4+
from unittest import mock
5+
6+
import pytest
7+
from aiohttp import web
8+
from aiohttp.test_utils import TestClient
9+
10+
from openapi_core import openapi_request_validator
11+
from openapi_core import openapi_response_validator
12+
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest
13+
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebResponse
14+
15+
16+
@pytest.fixture
17+
def spec(factory):
18+
directory = pathlib.Path(__file__).parent
19+
specfile = directory / "data" / "v3.0" / "aiohttp_factory.yaml"
20+
return factory.spec_from_file(str(specfile))
21+
22+
23+
@pytest.fixture
24+
def response_getter() -> mock.MagicMock:
25+
# Using a mock here allows us to control the return value for different scenarios.
26+
return mock.MagicMock(return_value={"data": "data"})
27+
28+
29+
@pytest.fixture
30+
def no_validation(response_getter):
31+
async def test_route(request: web.Request) -> web.Response:
32+
await asyncio.sleep(0)
33+
response = web.json_response(
34+
response_getter(),
35+
headers={"X-Rate-Limit": "12"},
36+
status=200,
37+
)
38+
return response
39+
40+
return test_route
41+
42+
43+
@pytest.fixture
44+
def request_validation(spec, response_getter):
45+
async def test_route(request: web.Request) -> web.Response:
46+
request_body = await request.text()
47+
openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body)
48+
result = openapi_request_validator.validate(spec, openapi_request)
49+
response: dict[str, Any] = response_getter()
50+
status = 200
51+
if result.errors:
52+
status = 400
53+
response = {"errors": [{"message": str(e) for e in result.errors}]}
54+
return web.json_response(
55+
response,
56+
headers={"X-Rate-Limit": "12"},
57+
status=status,
58+
)
59+
60+
return test_route
61+
62+
63+
@pytest.fixture
64+
def response_validation(spec, response_getter):
65+
async def test_route(request: web.Request) -> web.Response:
66+
request_body = await request.text()
67+
openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body)
68+
response_body = response_getter()
69+
response = web.json_response(
70+
response_body,
71+
headers={"X-Rate-Limit": "12"},
72+
status=200,
73+
)
74+
openapi_response = AIOHTTPOpenAPIWebResponse(response)
75+
result = openapi_response_validator.validate(
76+
spec, openapi_request, openapi_response
77+
)
78+
if result.errors:
79+
response = web.json_response(
80+
{"errors": [{"message": str(e) for e in result.errors}]},
81+
headers={"X-Rate-Limit": "12"},
82+
status=400,
83+
)
84+
return response
85+
86+
return test_route
87+
88+
89+
@pytest.fixture(
90+
params=["no_validation", "request_validation", "response_validation"]
91+
)
92+
def router(
93+
request,
94+
no_validation,
95+
request_validation,
96+
response_validation,
97+
) -> web.RouteTableDef:
98+
test_routes = dict(
99+
no_validation=no_validation,
100+
request_validation=request_validation,
101+
response_validation=response_validation,
102+
)
103+
router_ = web.RouteTableDef()
104+
handler = test_routes[request.param]
105+
route = router_.post("/browse/{id}/")(handler)
106+
return router_
107+
108+
109+
@pytest.fixture
110+
def app(router):
111+
app = web.Application()
112+
app.add_routes(router)
113+
114+
return app
115+
116+
117+
@pytest.fixture
118+
async def client(app, aiohttp_client) -> TestClient:
119+
return await aiohttp_client(app)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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: '/'
7+
description: 'testing'
8+
paths:
9+
'/browse/{id}/':
10+
parameters:
11+
- name: id
12+
in: path
13+
required: true
14+
description: the ID of the resource to retrieve
15+
schema:
16+
type: integer
17+
- name: q
18+
in: query
19+
required: true
20+
description: query key
21+
schema:
22+
type: string
23+
post:
24+
requestBody:
25+
description: request data
26+
required: True
27+
content:
28+
application/json:
29+
schema:
30+
type: object
31+
required:
32+
- param1
33+
properties:
34+
param1:
35+
type: integer
36+
responses:
37+
200:
38+
description: Return the resource.
39+
content:
40+
application/json:
41+
schema:
42+
type: object
43+
required:
44+
- data
45+
properties:
46+
data:
47+
type: string
48+
headers:
49+
X-Rate-Limit:
50+
description: Rate limit
51+
schema:
52+
type: integer
53+
required: true
54+
default:
55+
description: Return errors.
56+
content:
57+
application/json:
58+
schema:
59+
type: object
60+
required:
61+
- errors
62+
properties:
63+
errors:
64+
type: array
65+
items:
66+
type: object
67+
properties:
68+
title:
69+
type: string
70+
code:
71+
type: string
72+
message:
73+
type: string

0 commit comments

Comments
 (0)