diff --git a/docs/integrations/fastapi.rst b/docs/integrations/fastapi.rst new file mode 100644 index 00000000..bd3abde1 --- /dev/null +++ b/docs/integrations/fastapi.rst @@ -0,0 +1,58 @@ +FastAPI +========= + +This section describes integration with `FastAPI `__ ASGI framework. + +.. note:: + + FastAPI also provides OpenAPI support. The main difference is that, unlike FastAPI's code-first approach, OpenAPI-core allows you to laverage your existing specification that alligns with API-First approach. You can read more about API-first vs. code-first in the [Guide to API-first](https://www.postman.com/api-first/). + +Middleware +---------- + +FastAPI can be integrated by `middleware `__ to apply OpenAPI validation to your entire application. + +Add ``FastAPIOpenAPIMiddleware`` with OpenAPI object to your ``middleware`` list. + +.. code-block:: python + :emphasize-lines: 2,5 + + from fastapi import FastAPI + from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware + + app = FastAPI() + app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi) + +After that all your requests and responses will be validated. + +Also you have access to unmarshal result object with all unmarshalled request data through ``openapi`` scope of request object. + +.. code-block:: python + + async def homepage(request): + # get parameters object with path, query, cookies and headers parameters + unmarshalled_params = request.scope["openapi"].parameters + # or specific location parameters + unmarshalled_path_params = request.scope["openapi"].parameters.path + + # get body + unmarshalled_body = request.scope["openapi"].body + + # get security data + unmarshalled_security = request.scope["openapi"].security + +Response validation +^^^^^^^^^^^^^^^^^^^ + +You can skip response validation process: by setting ``response_cls`` to ``None`` + +.. code-block:: python + :emphasize-lines: 2 + + app = FastAPI() + app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi, response_cls=None) + +Low level +--------- + +For low level integration see `Starlette `_ integration. diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst index cd046758..f48c8cc9 100644 --- a/docs/integrations/index.rst +++ b/docs/integrations/index.rst @@ -10,6 +10,7 @@ Openapi-core integrates with your popular libraries and frameworks. Each integra bottle django falcon + fastapi flask pyramid requests diff --git a/openapi_core/contrib/fastapi/__init__.py b/openapi_core/contrib/fastapi/__init__.py new file mode 100644 index 00000000..d658ddcf --- /dev/null +++ b/openapi_core/contrib/fastapi/__init__.py @@ -0,0 +1,9 @@ +from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware +from openapi_core.contrib.fastapi.requests import FastAPIOpenAPIRequest +from openapi_core.contrib.fastapi.responses import FastAPIOpenAPIResponse + +__all__ = [ + "FastAPIOpenAPIMiddleware", + "FastAPIOpenAPIRequest", + "FastAPIOpenAPIResponse", +] diff --git a/openapi_core/contrib/fastapi/middlewares.py b/openapi_core/contrib/fastapi/middlewares.py new file mode 100644 index 00000000..5aedf224 --- /dev/null +++ b/openapi_core/contrib/fastapi/middlewares.py @@ -0,0 +1,5 @@ +from openapi_core.contrib.starlette.middlewares import ( + StarletteOpenAPIMiddleware as FastAPIOpenAPIMiddleware, +) + +__all__ = ["FastAPIOpenAPIMiddleware"] diff --git a/openapi_core/contrib/fastapi/requests.py b/openapi_core/contrib/fastapi/requests.py new file mode 100644 index 00000000..c70d8c81 --- /dev/null +++ b/openapi_core/contrib/fastapi/requests.py @@ -0,0 +1,8 @@ +from fastapi import Request + +from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest + + +class FastAPIOpenAPIRequest(StarletteOpenAPIRequest): + def __init__(self, request: Request): + super().__init__(request) diff --git a/openapi_core/contrib/fastapi/responses.py b/openapi_core/contrib/fastapi/responses.py new file mode 100644 index 00000000..6ef7ea22 --- /dev/null +++ b/openapi_core/contrib/fastapi/responses.py @@ -0,0 +1,10 @@ +from typing import Optional + +from fastapi import Response + +from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse + + +class FastAPIOpenAPIResponse(StarletteOpenAPIResponse): + def __init__(self, response: Response, data: Optional[bytes] = None): + super().__init__(response, data=data) diff --git a/poetry.lock b/poetry.lock index dc4bba7a..77e2e7ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -151,24 +151,24 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "anyio" -version = "4.0.0" +version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "anyio-4.0.0-py3-none-any.whl", hash = "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f"}, - {file = "anyio-4.0.0.tar.gz", hash = "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.22)"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] [[package]] name = "appdirs" @@ -711,6 +711,25 @@ files = [ {file = "falcon-3.1.3.tar.gz", hash = "sha256:23335dbccd44f29e85ec55f2f35d5a0bc12bd7a509f641ab81f5c64b65626263"}, ] +[[package]] +name = "fastapi" +version = "0.108.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.108.0-py3-none-any.whl", hash = "sha256:8c7bc6d315da963ee4cdb605557827071a9a7f95aeb8fcdd3bde48cdc8764dd7"}, + {file = "fastapi-0.108.0.tar.gz", hash = "sha256:5056e504ac6395bf68493d71fcfc5352fdbd5fda6f88c21f6420d80d81163296"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.29.0,<0.33.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "filelock" version = "3.13.1" @@ -2292,13 +2311,13 @@ test = ["pytest", "pytest-cov"] [[package]] name = "starlette" -version = "0.37.1" +version = "0.32.0.post1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.37.1-py3-none-any.whl", hash = "sha256:92a816002d4e8c552477b089520e3085bb632e854eb32cef99acb6f6f7830b69"}, - {file = "starlette-0.37.1.tar.gz", hash = "sha256:345cfd562236b557e76a045715ac66fdc355a1e7e617b087834a76a87dcc6533"}, + {file = "starlette-0.32.0.post1-py3-none-any.whl", hash = "sha256:cd0cb10ddb49313f609cedfac62c8c12e56c7314b66d89bb077ba228bada1b09"}, + {file = "starlette-0.32.0.post1.tar.gz", hash = "sha256:e54e2b7e2fb06dff9eac40133583f10dfa05913f5a85bf26f427c7a40a9a3d02"}, ] [package.dependencies] @@ -2306,7 +2325,7 @@ 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 (>=0.0.7)", "pyyaml"] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] [[package]] name = "strict-rfc3339" @@ -2526,6 +2545,7 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p aiohttp = ["aiohttp", "multidict"] django = ["django"] falcon = ["falcon"] +fastapi = ["fastapi"] flask = ["flask"] requests = ["requests"] starlette = ["aioitertools", "starlette"] @@ -2533,4 +2553,4 @@ starlette = ["aioitertools", "starlette"] [metadata] lock-version = "2.0" python-versions = "^3.8.0" -content-hash = "09d51553f494e21e03261f7c996c978580e73472731c1b64ac17378bbfe70cb4" +content-hash = "a0c24b771433b05d6e5ee543c0529ecfeb361c871f974f2129a95c99df2326cb" diff --git a/pyproject.toml b/pyproject.toml index a3b1dfa2..2d0a536c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,10 +76,12 @@ jsonschema-path = "^0.3.1" jsonschema = "^4.18.0" multidict = {version = "^6.0.4", optional = true} aioitertools = {version = "^0.11.0", optional = true} +fastapi = {version = "^0.108.0", optional = true} [tool.poetry.extras] django = ["django"] falcon = ["falcon"] +fastapi = ["fastapi"] flask = ["flask"] requests = ["requests"] aiohttp = ["aiohttp", "multidict"] @@ -108,6 +110,7 @@ aiohttp = "^3.8.4" pytest-aiohttp = "^1.0.4" bump2version = "^1.0.1" pyflakes = "^3.1.0" +fastapi = "^0.108.0" [tool.poetry.group.docs.dependencies] sphinx = ">=5.3,<8.0" diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__init__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py new file mode 100644 index 00000000..916cd2cd --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from fastapiproject.openapi import openapi +from fastapiproject.routers import pets + +from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware + +app = FastAPI() +app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi) +app.include_router(pets.router) diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py new file mode 100644 index 00000000..4ca6d9fa --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py @@ -0,0 +1,9 @@ +from pathlib import Path + +import yaml + +from openapi_core import OpenAPI + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +openapi = OpenAPI.from_dict(spec_dict) diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/__init__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py new file mode 100644 index 00000000..d4b763b9 --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py @@ -0,0 +1,109 @@ +from base64 import b64decode + +from fastapi import APIRouter +from fastapi import Body +from fastapi import Request +from fastapi import Response +from fastapi import status + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" +) + + +router = APIRouter( + prefix="/v1/pets", + tags=["pets"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("") +async def list_pets(request: Request, response: Response): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + assert request.scope["openapi"].parameters.query == { + "page": 1, + "limit": 12, + "search": "", + } + data = [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ] + response.headers["X-Rate-Limit"] = "12" + return {"data": data} + + +@router.post("") +async def create_pet(request: Request): + assert request.scope["openapi"].parameters.cookie == { + "user": 1, + } + assert request.scope["openapi"].parameters.header == { + "api-key": "12345", + } + assert request.scope["openapi"].body.__class__.__name__ == "PetCreate" + assert request.scope["openapi"].body.name in ["Cat", "Bird"] + if request.scope["openapi"].body.name == "Cat": + assert request.scope["openapi"].body.ears.__class__.__name__ == "Ears" + assert request.scope["openapi"].body.ears.healthy is True + if request.scope["openapi"].body.name == "Bird": + assert ( + request.scope["openapi"].body.wings.__class__.__name__ == "Wings" + ) + assert request.scope["openapi"].body.wings.healthy is True + + headers = { + "X-Rate-Limit": "12", + } + return Response(status_code=status.HTTP_201_CREATED, headers=headers) + + +@router.get("/{petId}") +async def detail_pet(request: Request, response: Response): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + assert request.scope["openapi"].parameters.path == { + "petId": 12, + } + data = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + response.headers["X-Rate-Limit"] = "12" + return { + "data": data, + } + + +@router.get("/{petId}/photo") +async def download_pet_photo(): + return Response(content=OPENID_LOGO, media_type="image/gif") + + +@router.post("/{petId}/photo") +async def upload_pet_photo( + image: Annotated[bytes, Body(media_type="image/jpg")], +): + assert image == OPENID_LOGO + return Response(status_code=status.HTTP_201_CREATED) diff --git a/tests/integration/contrib/fastapi/test_fastapi_project.py b/tests/integration/contrib/fastapi/test_fastapi_project.py new file mode 100644 index 00000000..e8d795c6 --- /dev/null +++ b/tests/integration/contrib/fastapi/test_fastapi_project.py @@ -0,0 +1,383 @@ +import os +import sys +from base64 import b64encode + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True, scope="module") +def project_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, project_dir) + yield + sys.path.remove(project_dir) + + +@pytest.fixture +def app(): + from fastapiproject.__main__ import app + + return app + + +@pytest.fixture +def client(app): + return TestClient(app, base_url="http://petstore.swagger.io") + + +class BaseTestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetListEndpoint(BaseTestPetstore): + def test_get_no_required_param(self, client): + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get("/v1/pets", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required query parameter: limit", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_valid(self, client): + data_json = { + "limit": 12, + } + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get( + "/v1/pets", + params=data_json, + headers=headers, + ) + + expected_data = { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + assert response.status_code == 200 + assert response.json() == expected_data + + def test_post_server_invalid(self, client): + response = client.post("/v1/pets") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://petstore.swagger.io/v1/pets" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_required_header_param_missing(self, client): + client.cookies.set("user", "1") + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required header parameter: api-key", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_media_type_invalid(self, client): + client.cookies.set("user", "1") + content = "data" + content_type = "text/html" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + content=content, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. " + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json() == expected_data + + def test_post_required_cookie_param_missing(self, client): + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required cookie parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + @pytest.mark.parametrize( + "data_json", + [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + { + "id": 12, + "name": "Bird", + "wings": { + "healthy": True, + }, + }, + ], + ) + def test_post_valid(self, client, data_json): + client.cookies.set("user", "1") + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailEndpoint(BaseTestPetstore): + def test_get_server_invalid(self, client): + response = client.get("http://testserver/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " "http://testserver/v1/pets/12" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_unauthorized(self, client): + response = client.get("/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 403, + "title": ( + "Security not found. Schemes not valid for any " + "requirement: [['petstore_auth']]" + ), + } + ] + } + assert response.status_code == 403 + assert response.json() == expected_data + + def test_delete_method_invalid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.delete("/v1/pets/12", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 405, + "title": ( + "Operation delete not found for " + "http://petstore.swagger.io/v1/pets/12" + ), + } + ] + } + assert response.status_code == 405 + assert response.json() == expected_data + + def test_get_valid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.get("/v1/pets/12", headers=headers) + + expected_data = { + "data": { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + } + assert response.status_code == 200 + assert response.json() == expected_data + + +class TestPetPhotoEndpoint(BaseTestPetstore): + def test_get_valid(self, client, data_gif): + client.cookies.set("user", "1") + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + + response = client.get( + "/v1/pets/1/photo", + headers=headers, + ) + + assert response.content == data_gif + assert response.status_code == 200 + + def test_post_valid(self, client, data_gif): + client.cookies.set("user", "1") + content_type = "image/gif" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + + response = client.post( + "/v1/pets/1/photo", + headers=headers, + content=data_gif, + ) + + assert not response.text + assert response.status_code == 201