Skip to content

Add support for aiohttp.web #571

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/integrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@ Integrations

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.

aiohttp.web
-----------

This section describes integration with `aiohttp.web <https://docs.aiohttp.org/en/stable/web.html>`__ framework.

Low level
~~~~~~~~~

You can use ``AIOHTTPOpenAPIWebRequest`` as an aiohttp request factory:

.. code-block:: python

from openapi_core import unmarshal_request
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest

request_body = await aiohttp_request.text()
openapi_request = AIOHTTPOpenAPIWebRequest(aiohttp_request, body=request_body)
result = unmarshal_request(openapi_request, spec=spec)

You can use ``AIOHTTPOpenAPIWebRequest`` as an aiohttp response factory:

.. code-block:: python

from openapi_core import unmarshal_response
from openapi_core.contrib.starlette import AIOHTTPOpenAPIWebRequest

openapi_response = StarletteOpenAPIResponse(aiohttp_response)
result = unmarshal_response(openapi_request, openapi_response, spec=spec)


Bottle
------

Expand Down
7 changes: 7 additions & 0 deletions openapi_core/contrib/aiohttp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from openapi_core.contrib.aiohttp.requests import AIOHTTPOpenAPIWebRequest
from openapi_core.contrib.aiohttp.responses import AIOHTTPOpenAPIWebResponse

__all__ = [
"AIOHTTPOpenAPIWebRequest",
"AIOHTTPOpenAPIWebResponse",
]
53 changes: 53 additions & 0 deletions openapi_core/contrib/aiohttp/requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""OpenAPI core contrib aiohttp requests module"""
from __future__ import annotations

from typing import cast

from aiohttp import web
from asgiref.sync import AsyncToSync

from openapi_core.datatypes import RequestParameters


class Empty:
...


_empty = Empty()


class AIOHTTPOpenAPIWebRequest:
__slots__ = ("request", "parameters", "_get_body", "_body")

def __init__(self, request: web.Request, *, body: str | None):
if not isinstance(request, web.Request):
raise TypeError(
f"'request' argument is not type of {web.Request.__qualname__!r}"
)
self.request = request
self.parameters = RequestParameters(
query=self.request.query,
header=self.request.headers,
cookie=self.request.cookies,
)
self._body = body

@property
def host_url(self) -> str:
return self.request.url.host or ""

@property
def path(self) -> str:
return self.request.url.path

@property
def method(self) -> str:
return self.request.method.lower()

@property
def body(self) -> str | None:
return self._body

@property
def mimetype(self) -> str:
return self.request.content_type
32 changes: 32 additions & 0 deletions openapi_core/contrib/aiohttp/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""OpenAPI core contrib aiohttp responses module"""

import multidict
from aiohttp import web


class AIOHTTPOpenAPIWebResponse:
def __init__(self, response: web.Response):
if not isinstance(response, web.Response):
raise TypeError(
f"'response' argument is not type of {web.Response.__qualname__!r}"
)
self.response = response

@property
def data(self) -> str:
if isinstance(self.response.body, bytes):
return self.response.body.decode("utf-8")
assert isinstance(self.response.body, str)
return self.response.body

@property
def status_code(self) -> int:
return self.response.status

@property
def mimetype(self) -> str:
return self.response.content_type or ""

@property
def headers(self) -> multidict.CIMultiDict[str]:
return self.response.headers
548 changes: 448 additions & 100 deletions poetry.lock

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ python = "^3.7.0"
django = {version = ">=3.0", optional = true}
falcon = {version = ">=3.0", optional = true}
flask = {version = "*", optional = true}
aiohttp = {version = ">=3.0", optional = true}
starlette = {version = ">=0.26.1,<0.28.0", optional = true}
isodate = "*"
more-itertools = "*"
parse = "*"
Expand All @@ -71,16 +73,17 @@ jsonschema-spec = "^0.1.1"
backports-cached-property = {version = "^1.0.2", python = "<3.8" }
asgiref = "^3.6.0"
jsonschema = "^4.17.3"
starlette = {version = ">=0.26.1,<0.28.0", optional = true}
multidict = {version = "^6.0.4", optional = true}

[tool.poetry.extras]
django = ["django"]
falcon = ["falcon"]
flask = ["flask"]
requests = ["requests"]
aiohttp = ["aiohttp", "multidict"]
starlette = ["starlette"]

[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
django = ">=3.0"
djangorestframework = "^3.11.2"
Expand All @@ -97,7 +100,8 @@ webob = "*"
mypy = "^1.2"
httpx = "^0.24.0"
deptry = { version = "^0.11.0", python = ">=3.8" }

aiohttp = "^3.8.4"
pytest-aiohttp = "^1.0.4"

[tool.poetry.group.docs.dependencies]
sphinx = "^5.3.0"
Expand All @@ -113,6 +117,7 @@ addopts = """
--cov-report=term-missing
--cov-report=xml
"""
asyncio_mode = "auto"

[tool.black]
line-length = 79
Expand All @@ -125,4 +130,4 @@ force_single_line = true
[tool.deptry.package_module_name_map]
backports-cached-property = [
"backports"
]
]
119 changes: 119 additions & 0 deletions tests/integration/contrib/aiohttp/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import asyncio
import pathlib
from typing import Any
from unittest import mock

import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient

from openapi_core import openapi_request_validator
from openapi_core import openapi_response_validator
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest
from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebResponse


@pytest.fixture
def spec(factory):
directory = pathlib.Path(__file__).parent
specfile = directory / "data" / "v3.0" / "aiohttp_factory.yaml"
return factory.spec_from_file(str(specfile))


@pytest.fixture
def response_getter() -> mock.MagicMock:
# Using a mock here allows us to control the return value for different scenarios.
return mock.MagicMock(return_value={"data": "data"})


@pytest.fixture
def no_validation(response_getter):
async def test_route(request: web.Request) -> web.Response:
await asyncio.sleep(0)
response = web.json_response(
response_getter(),
headers={"X-Rate-Limit": "12"},
status=200,
)
return response

return test_route


@pytest.fixture
def request_validation(spec, response_getter):
async def test_route(request: web.Request) -> web.Response:
request_body = await request.text()
openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body)
result = openapi_request_validator.validate(spec, openapi_request)
response: dict[str, Any] = response_getter()
status = 200
if result.errors:
status = 400
response = {"errors": [{"message": str(e) for e in result.errors}]}
return web.json_response(
response,
headers={"X-Rate-Limit": "12"},
status=status,
)

return test_route


@pytest.fixture
def response_validation(spec, response_getter):
async def test_route(request: web.Request) -> web.Response:
request_body = await request.text()
openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body)
response_body = response_getter()
response = web.json_response(
response_body,
headers={"X-Rate-Limit": "12"},
status=200,
)
openapi_response = AIOHTTPOpenAPIWebResponse(response)
result = openapi_response_validator.validate(
spec, openapi_request, openapi_response
)
if result.errors:
response = web.json_response(
{"errors": [{"message": str(e) for e in result.errors}]},
headers={"X-Rate-Limit": "12"},
status=400,
)
return response

return test_route


@pytest.fixture(
params=["no_validation", "request_validation", "response_validation"]
)
def router(
request,
no_validation,
request_validation,
response_validation,
) -> web.RouteTableDef:
test_routes = dict(
no_validation=no_validation,
request_validation=request_validation,
response_validation=response_validation,
)
router_ = web.RouteTableDef()
handler = test_routes[request.param]
route = router_.post("/browse/{id}/")(handler)
return router_


@pytest.fixture
def app(router):
app = web.Application()
app.add_routes(router)

return app


@pytest.fixture
async def client(app, aiohttp_client) -> TestClient:
return await aiohttp_client(app)
73 changes: 73 additions & 0 deletions tests/integration/contrib/aiohttp/data/v3.0/aiohttp_factory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
openapi: "3.0.0"
info:
title: Basic OpenAPI specification used with starlette integration tests
version: "0.1"
servers:
- url: '/'
description: 'testing'
paths:
'/browse/{id}/':
parameters:
- name: id
in: path
required: true
description: the ID of the resource to retrieve
schema:
type: integer
- name: q
in: query
required: true
description: query key
schema:
type: string
post:
requestBody:
description: request data
required: True
content:
application/json:
schema:
type: object
required:
- param1
properties:
param1:
type: integer
responses:
200:
description: Return the resource.
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
type: string
headers:
X-Rate-Limit:
description: Rate limit
schema:
type: integer
required: true
default:
description: Return errors.
content:
application/json:
schema:
type: object
required:
- errors
properties:
errors:
type: array
items:
type: object
properties:
title:
type: string
code:
type: string
message:
type: string
Loading