Skip to content

Starlette support #427

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 1 commit into from
Sep 29, 2022
Merged
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
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -31,9 +31,9 @@ Table of contents

installation
usage
extensions
customizations
integrations
customizations
extensions


Related projects
57 changes: 43 additions & 14 deletions docs/integrations.rst
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ The integration supports Django from version 3.0 and above.
Middleware
~~~~~~~~~~

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

.. code-block:: python

@@ -52,7 +52,7 @@ After that you have access to validation result object with all validated reques
Low level
~~~~~~~~~

You can use `DjangoOpenAPIRequest` as a Django request factory:
You can use ``DjangoOpenAPIRequest`` as a Django request factory:

.. code-block:: python

@@ -62,7 +62,7 @@ You can use `DjangoOpenAPIRequest` as a Django request factory:
openapi_request = DjangoOpenAPIRequest(django_request)
result = openapi_request_validator.validate(spec, openapi_request)

You can use `DjangoOpenAPIResponse` as a Django response factory:
You can use ``DjangoOpenAPIResponse`` as a Django response factory:

.. code-block:: python

@@ -82,7 +82,7 @@ The integration supports Falcon from version 3.0 and above.
Middleware
~~~~~~~~~~

The Falcon API can be integrated by `FalconOpenAPIMiddleware` middleware.
The Falcon API can be integrated by ``FalconOpenAPIMiddleware`` middleware.

.. code-block:: python

@@ -111,7 +111,7 @@ After that you will have access to validation result object with all validated r
Low level
~~~~~~~~~

You can use `FalconOpenAPIRequest` as a Falcon request factory:
You can use ``FalconOpenAPIRequest`` as a Falcon request factory:

.. code-block:: python

@@ -121,7 +121,7 @@ You can use `FalconOpenAPIRequest` as a Falcon request factory:
openapi_request = FalconOpenAPIRequest(falcon_request)
result = openapi_request_validator.validate(spec, openapi_request)

You can use `FalconOpenAPIResponse` as a Falcon response factory:
You can use ``FalconOpenAPIResponse`` as a Falcon response factory:

.. code-block:: python

@@ -140,7 +140,7 @@ This section describes integration with `Flask <https://flask.palletsprojects.co
Decorator
~~~~~~~~~

Flask views can be integrated by `FlaskOpenAPIViewDecorator` decorator.
Flask views can be integrated by ``FlaskOpenAPIViewDecorator`` decorator.

.. code-block:: python

@@ -163,7 +163,7 @@ If you want to decorate class based view you can use the decorators attribute:
View
~~~~

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

.. code-block:: python

@@ -177,7 +177,7 @@ As an alternative to the decorator-based integration, a Flask method based views
Request parameters
~~~~~~~~~~~~~~~~~~

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

.. code-block:: python

@@ -192,7 +192,7 @@ In Flask, all unmarshalled request data are provided as Flask request object's `
Low level
~~~~~~~~~

You can use `FlaskOpenAPIRequest` as a Flask request factory:
You can use ``FlaskOpenAPIRequest`` as a Flask request factory:

.. code-block:: python

@@ -219,7 +219,7 @@ This section describes integration with `Requests <https://requests.readthedocs.
Low level
~~~~~~~~~

You can use `RequestsOpenAPIRequest` as a Requests request factory:
You can use ``RequestsOpenAPIRequest`` as a Requests request factory:

.. code-block:: python

@@ -229,7 +229,7 @@ You can use `RequestsOpenAPIRequest` as a Requests request factory:
openapi_request = RequestsOpenAPIRequest(requests_request)
result = openapi_request_validator.validate(spec, openapi_request)

You can use `RequestsOpenAPIResponse` as a Requests response factory:
You can use ``RequestsOpenAPIResponse`` as a Requests response factory:

.. code-block:: python

@@ -240,6 +240,35 @@ You can use `RequestsOpenAPIResponse` as a Requests response factory:
result = openapi_respose_validator.validate(spec, openapi_request, openapi_response)


Starlette
---------

This section describes integration with `Starlette <https://www.starlette.io>`__ ASGI framework.

Low level
~~~~~~~~~

You can use ``StarletteOpenAPIRequest`` as a Starlette request factory:

.. code-block:: python

from openapi_core.validation.request import openapi_request_validator
from openapi_core.contrib.starlette import StarletteOpenAPIRequest

openapi_request = StarletteOpenAPIRequest(starlette_request)
result = openapi_request_validator.validate(spec, openapi_request)

You can use ``StarletteOpenAPIResponse`` as a Starlette response factory:

.. code-block:: python

from openapi_core.validation.response import openapi_respose_validator
from openapi_core.contrib.starlette import StarletteOpenAPIResponse

openapi_response = StarletteOpenAPIResponse(starlette_response)
result = openapi_respose_validator.validate(spec, openapi_request, openapi_response)


Tornado
-------

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

You can use `WerkzeugOpenAPIRequest` as a Werkzeug request factory:
You can use ``WerkzeugOpenAPIRequest`` as a Werkzeug request factory:

.. code-block:: python

@@ -264,7 +293,7 @@ You can use `WerkzeugOpenAPIRequest` as a Werkzeug request factory:
openapi_request = WerkzeugOpenAPIRequest(werkzeug_request)
result = openapi_request_validator.validate(spec, openapi_request)

You can use `WerkzeugOpenAPIResponse` as a Werkzeug response factory:
You can use ``WerkzeugOpenAPIResponse`` as a Werkzeug response factory:

.. code-block:: python

7 changes: 7 additions & 0 deletions openapi_core/contrib/starlette/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest
from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse

__all__ = [
"StarletteOpenAPIRequest",
"StarletteOpenAPIResponse",
]
50 changes: 50 additions & 0 deletions openapi_core/contrib/starlette/requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""OpenAPI core contrib starlette requests module"""
from typing import Optional

from asgiref.sync import AsyncToSync
from starlette.requests import Request

from openapi_core.validation.request.datatypes import RequestParameters


class StarletteOpenAPIRequest:
def __init__(self, request: Request):
self.request = request

self.parameters = RequestParameters(
query=self.request.query_params,
header=self.request.headers,
cookie=self.request.cookies,
)

self._get_body = AsyncToSync(self.request.body, force_new_loop=True) # type: ignore

@property
def host_url(self) -> str:
return self.request.base_url._url

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

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

@property
def body(self) -> Optional[str]:
body = self._get_body()
if body is None:
return None
if isinstance(body, bytes):
return body.decode("utf-8")
assert isinstance(body, str)
return body

@property
def mimetype(self) -> str:
content_type = self.request.headers["Content-Type"]
if content_type:
return content_type.partition(";")[0]

return ""
27 changes: 27 additions & 0 deletions openapi_core/contrib/starlette/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""OpenAPI core contrib starlette responses module"""
from starlette.datastructures import Headers
from starlette.responses import Response


class StarletteOpenAPIResponse:
def __init__(self, response: Response):
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_code

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

@property
def headers(self) -> Headers:
return self.response.headers
8 changes: 3 additions & 5 deletions openapi_core/schema/parameters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import re
from typing import Any
from typing import Dict
from typing import Mapping
from typing import Optional
from typing import Union

from werkzeug.datastructures import Headers

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

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


def get_deep_object_value(
location: Union[Headers, Dict[str, Any]],
location: Mapping[str, Any],
name: Optional[str] = None,
) -> Dict[str, Any]:
values = {}
23 changes: 9 additions & 14 deletions openapi_core/validation/request/datatypes.py
Original file line number Diff line number Diff line change
@@ -4,8 +4,7 @@
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Dict
from typing import Optional
from typing import Mapping

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

query: ImmutableMultiDict[str, Any] = field(
default_factory=ImmutableMultiDict
)
header: Headers = field(default_factory=Headers)
cookie: ImmutableMultiDict[str, Any] = field(
default_factory=ImmutableMultiDict
)
path: dict[str, Any] = field(default_factory=dict)
query: Mapping[str, Any] = field(default_factory=ImmutableMultiDict)
header: Mapping[str, Any] = field(default_factory=Headers)
cookie: Mapping[str, Any] = field(default_factory=ImmutableMultiDict)
path: Mapping[str, Any] = field(default_factory=dict)

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


@dataclass
class Parameters:
query: dict[str, Any] = field(default_factory=dict)
header: dict[str, Any] = field(default_factory=dict)
cookie: dict[str, Any] = field(default_factory=dict)
path: dict[str, Any] = field(default_factory=dict)
query: Mapping[str, Any] = field(default_factory=dict)
header: Mapping[str, Any] = field(default_factory=dict)
cookie: Mapping[str, Any] = field(default_factory=dict)
path: Mapping[str, Any] = field(default_factory=dict)


@dataclass
6 changes: 3 additions & 3 deletions openapi_core/validation/response/protocols.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""OpenAPI core validation response protocols module"""
from typing import TYPE_CHECKING
from typing import Any
from typing import Mapping
from typing import Optional

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

from werkzeug.datastructures import Headers

from openapi_core.spec import Spec
from openapi_core.validation.request.protocols import Request
from openapi_core.validation.response.datatypes import ResponseValidationResult
@@ -48,7 +48,7 @@ def mimetype(self) -> str:
...

@property
def headers(self) -> Headers:
def headers(self) -> Mapping[str, Any]:
...


8 changes: 2 additions & 6 deletions openapi_core/validation/validators.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
"""OpenAPI core validation validators module"""
from typing import Any
from typing import Dict
from typing import Mapping
from typing import Optional
from typing import Union
from urllib.parse import urljoin

from werkzeug.datastructures import Headers

from openapi_core.casting.schemas import schema_casters_factory
from openapi_core.casting.schemas.factories import SchemaCastersFactory
@@ -82,7 +78,7 @@ def _unmarshal(self, schema: Spec, value: Any) -> Any:
def _get_param_or_header_value(
self,
param_or_header: Spec,
location: Union[Headers, Dict[str, Any]],
location: Mapping[str, Any],
name: Optional[str] = None,
) -> Any:
try:
111 changes: 110 additions & 1 deletion poetry.lock
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ strict = true

[[tool.mypy.overrides]]
module = [
"asgiref.*",
"django.*",
"falcon.*",
"isodate.*",
@@ -70,6 +71,7 @@ django = ["django"]
falcon = ["falcon"]
flask = ["flask"]
requests = ["requests"]
starlette = ["starlette", "httpx"]

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

[tool.pytest.ini_options]
addopts = """
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
openapi: "3.0.0"
info:
title: Basic OpenAPI specification used with starlette integration tests
version: "0.1"
servers:
- url: 'http://localhost'
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
120 changes: 120 additions & 0 deletions tests/integration/contrib/starlette/test_starlette_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from json import dumps

import pytest
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient

from openapi_core.contrib.starlette import StarletteOpenAPIRequest
from openapi_core.contrib.starlette import StarletteOpenAPIResponse
from openapi_core.validation.request import openapi_request_validator
from openapi_core.validation.response import openapi_response_validator


class TestStarletteOpenAPIValidation:
@pytest.fixture
def spec(self, factory):
specfile = "contrib/starlette/data/v3.0/starlette_factory.yaml"
return factory.spec_from_file(specfile)

@pytest.fixture
def app(self):
async def test_route(scope, receive, send):
request = Request(scope, receive)
if request.args.get("q") == "string":
response = JSONResponse(
dumps({"data": "data"}),
headers={"X-Rate-Limit": "12"},
mimetype="application/json",
status=200,
)
else:
response = PlainTextResponse("Not Found", status=404)
await response(scope, receive, send)

return Starlette(
routes=[
Route("/browse/12/", test_route),
],
)

@pytest.fixture
def client(self, app):
return TestClient(app, base_url="http://localhost")

def test_request_validator_path_pattern(self, client, spec):
response_data = {"data": "data"}

def test_route(request):
openapi_request = StarletteOpenAPIRequest(request)
result = openapi_request_validator.validate(spec, openapi_request)
assert not result.errors
return JSONResponse(
response_data,
headers={"X-Rate-Limit": "12"},
media_type="application/json",
status_code=200,
)

app = Starlette(
routes=[
Route("/browse/12/", test_route, methods=["POST"]),
],
)
client = TestClient(app, base_url="http://localhost")
query_string = {
"q": "string",
}
headers = {"content-type": "application/json"}
data = {"param1": 1}
response = client.post(
"/browse/12/",
params=query_string,
json=data,
headers=headers,
)

assert response.status_code == 200
assert response.json() == response_data

def test_response_validator_path_pattern(self, client, spec):
response_data = {"data": "data"}

def test_route(request):
response = JSONResponse(
response_data,
headers={"X-Rate-Limit": "12"},
media_type="application/json",
status_code=200,
)
openapi_request = StarletteOpenAPIRequest(request)
openapi_response = StarletteOpenAPIResponse(response)
result = openapi_response_validator.validate(
spec, openapi_request, openapi_response
)
assert not result.errors
return response

app = Starlette(
routes=[
Route("/browse/12/", test_route, methods=["POST"]),
],
)
client = TestClient(app, base_url="http://localhost")
query_string = {
"q": "string",
}
headers = {"content-type": "application/json"}
data = {"param1": 1}
response = client.post(
"/browse/12/",
params=query_string,
json=data,
headers=headers,
)

assert response.status_code == 200
assert response.json() == response_data