diff --git a/docs/index.rst b/docs/index.rst index f5decbf1..7e9c41a7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,9 +31,9 @@ Table of contents installation usage - extensions - customizations integrations + customizations + extensions Related projects diff --git a/docs/integrations.rst b/docs/integrations.rst index a983c459..cd4ebffd 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -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 `__ 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 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 "" diff --git a/openapi_core/contrib/starlette/responses.py b/openapi_core/contrib/starlette/responses.py new file mode 100644 index 00000000..8d042e8d --- /dev/null +++ b/openapi_core/contrib/starlette/responses.py @@ -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 diff --git a/openapi_core/schema/parameters.py b/openapi_core/schema/parameters.py index 45baf229..c8f2fa33 100644 --- a/openapi_core/schema/parameters.py +++ b/openapi_core/schema/parameters.py @@ -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 = {} diff --git a/openapi_core/validation/request/datatypes.py b/openapi_core/validation/request/datatypes.py index 52fcbf67..c359ad38 100644 --- a/openapi_core/validation/request/datatypes.py +++ b/openapi_core/validation/request/datatypes.py @@ -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,14 +27,10 @@ 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) @@ -43,10 +38,10 @@ def __getitem__(self, location: str) -> Any: @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 diff --git a/openapi_core/validation/response/protocols.py b/openapi_core/validation/response/protocols.py index 7a66ea8f..6e42dce5 100644 --- a/openapi_core/validation/response/protocols.py +++ b/openapi_core/validation/response/protocols.py @@ -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]: ... diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 5a944e6b..8689a181 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -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: diff --git a/poetry.lock b/poetry.lock index 74a91608..81fa9eb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,6 +6,24 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "anyio" +version = "3.6.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + [[package]] name = "asgiref" version = "3.5.2" @@ -235,6 +253,52 @@ Werkzeug = ">=2.2.2" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "httpcore" +version = "0.15.0" +description = "A minimal low-level HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0.0,<4.0.0" +certifi = "*" +h11 = ">=0.11,<0.13" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.23.0" +description = "The next generation HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.16.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotlicffi", "brotli"] +cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10,<13)", "pygments (>=2.0.0,<3.0.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "identify" version = "2.5.3" @@ -726,6 +790,20 @@ urllib3 = ">=1.25.10" [package.extras] tests = ["pytest (>=7.0.0)", "coverage (>=6.0.0)", "pytest-cov", "pytest-asyncio", "pytest-localserver", "flake8", "types-mock", "types-requests", "mypy"] +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + [[package]] name = "six" version = "1.16.0" @@ -734,6 +812,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -868,6 +954,21 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "starlette" +version = "0.21.0" +description = "The little ASGI library that shines." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + [[package]] name = "strict-rfc3339" version = "0.7" @@ -982,14 +1083,16 @@ django = ["django"] falcon = ["falcon"] flask = ["flask"] requests = ["requests"] +starlette = [] [metadata] lock-version = "1.1" python-versions = "^3.7.0" -content-hash = "5d1e37431d372cde35f18fea5c93c61703d9c6e85f870af6427e7be1f5564ce3" +content-hash = "25d23ad11b888728528627234a4d5f017d744c9a96e2a1a953a6129595464e9e" [metadata.files] alabaster = [] +anyio = [] asgiref = [] atomicwrites = [] attrs = [] @@ -1009,6 +1112,9 @@ falcon = [] filelock = [] flake8 = [] flask = [] +h11 = [] +httpcore = [] +httpx = [] identify = [] idna = [] imagesize = [] @@ -1051,7 +1157,9 @@ pytz = [] pyyaml = [] requests = [] responses = [] +rfc3986 = [] six = [] +sniffio = [] snowballstemmer = [] sphinx = [] sphinx-rtd-theme = [] @@ -1062,6 +1170,7 @@ sphinxcontrib-jsmath = [] sphinxcontrib-qthelp = [] sphinxcontrib-serializinghtml = [] sqlparse = [] +starlette = [] strict-rfc3339 = [] toml = [] tomli = [] diff --git a/pyproject.toml b/pyproject.toml index dfd84fb1..0f5ca675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = """ diff --git a/tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml b/tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml new file mode 100644 index 00000000..a01168f2 --- /dev/null +++ b/tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml @@ -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 diff --git a/tests/integration/contrib/starlette/test_starlette_validation.py b/tests/integration/contrib/starlette/test_starlette_validation.py new file mode 100644 index 00000000..87d444af --- /dev/null +++ b/tests/integration/contrib/starlette/test_starlette_validation.py @@ -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