diff --git a/docs/integrations/django.md b/docs/integrations/django.md index 8369ea95..00c6fef4 100644 --- a/docs/integrations/django.md +++ b/docs/integrations/django.md @@ -57,6 +57,46 @@ OPENAPI = OpenAPI.from_dict(spec_dict) OPENAPI_RESPONSE_CLS = None ``` +## Decorator + +Django can be integrated using [view decorators](https://docs.djangoproject.com/en/5.1/topics/http/decorators/) to apply OpenAPI validation to your application's specific views. + +Use `DjangoOpenAPIViewDecorator` with the OpenAPI object to create the decorator. + +``` python hl_lines="1 3 6" +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +openapi_validated = FlaskOpenAPIViewDecorator(openapi) + + +@openapi_validated +def home(): + return "Welcome home" +``` + +You can skip the response validation process by setting `response_cls` to `None`. + +``` python hl_lines="5" +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +openapi_validated = DjangoOpenAPIViewDecorator( + openapi, + response_cls=None, +) +``` + +If you want to decorate a class-based view, you can use the `method_decorator` decorator: + +``` python hl_lines="3" +from django.utils.decorators import method_decorator + +@method_decorator(openapi_validated, name='dispatch') +class MyView(View): + + def get(self, request, *args, **kwargs): + return "Welcome home" +``` + ## Low level The integration defines classes useful for low-level integration. diff --git a/openapi_core/contrib/django/decorators.py b/openapi_core/contrib/django/decorators.py new file mode 100644 index 00000000..f6be3cbf --- /dev/null +++ b/openapi_core/contrib/django/decorators.py @@ -0,0 +1,102 @@ +"""OpenAPI core contrib django decorators module""" + +from typing import Any +from typing import Callable +from typing import Optional +from typing import Type + +from django.conf import settings +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from jsonschema_path import SchemaPath + +from openapi_core import OpenAPI +from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler +from openapi_core.contrib.django.handlers import ( + DjangoOpenAPIValidRequestHandler, +) +from openapi_core.contrib.django.integrations import DjangoIntegration +from openapi_core.contrib.django.providers import get_default_openapi_instance +from openapi_core.contrib.django.requests import DjangoOpenAPIRequest +from openapi_core.contrib.django.responses import DjangoOpenAPIResponse + + +class DjangoOpenAPIViewDecorator(DjangoIntegration): + valid_request_handler_cls = DjangoOpenAPIValidRequestHandler + errors_handler_cls: Type[DjangoOpenAPIErrorsHandler] = ( + DjangoOpenAPIErrorsHandler + ) + + def __init__( + self, + openapi: Optional[OpenAPI] = None, + request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest, + response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse, + errors_handler_cls: Type[ + DjangoOpenAPIErrorsHandler + ] = DjangoOpenAPIErrorsHandler, + ): + if openapi is None: + openapi = get_default_openapi_instance() + + super().__init__(openapi) + + # If OPENAPI_RESPONSE_CLS is defined in settings.py (for custom response classes), + # set the response_cls accordingly. + if hasattr(settings, "OPENAPI_RESPONSE_CLS"): + response_cls = settings.OPENAPI_RESPONSE_CLS + + self.request_cls = request_cls + self.response_cls = response_cls + + def __call__(self, view_func: Callable[..., Any]) -> Callable[..., Any]: + """ + Thanks to this method, the class acts as a decorator. + Example usage: + + @DjangoOpenAPIViewDecorator() + def my_view(request): ... + + """ + + def _wrapped_view( + request: HttpRequest, *args: Any, **kwargs: Any + ) -> HttpResponse: + # get_response is the function that we treats + # as the "next step" in the chain (i.e., our original view). + def get_response(r: HttpRequest) -> HttpResponse: + return view_func(r, *args, **kwargs) + + # Create a handler that will validate the request. + valid_request_handler = self.valid_request_handler_cls( + request, get_response + ) + + # Validate the request (before running the view). + errors_handler = self.errors_handler_cls() + response = self.handle_request( + request, valid_request_handler, errors_handler + ) + + # Validate the response (after the view) if should_validate_response() returns True. + return self.handle_response(request, response, errors_handler) + + return _wrapped_view + + @classmethod + def from_spec( + cls, + spec: SchemaPath, + request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest, + response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse, + errors_handler_cls: Type[ + DjangoOpenAPIErrorsHandler + ] = DjangoOpenAPIErrorsHandler, + ) -> "DjangoOpenAPIViewDecorator": + openapi = OpenAPI(spec) + return cls( + openapi, + request_cls=request_cls, + response_cls=response_cls, + errors_handler_cls=errors_handler_cls, + ) diff --git a/openapi_core/contrib/django/middlewares.py b/openapi_core/contrib/django/middlewares.py index 35b865bd..34ffe273 100644 --- a/openapi_core/contrib/django/middlewares.py +++ b/openapi_core/contrib/django/middlewares.py @@ -1,19 +1,17 @@ """OpenAPI core contrib django middlewares module""" -import warnings from typing import Callable from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from django.http.request import HttpRequest from django.http.response import HttpResponse -from openapi_core import OpenAPI from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler from openapi_core.contrib.django.handlers import ( DjangoOpenAPIValidRequestHandler, ) from openapi_core.contrib.django.integrations import DjangoIntegration +from openapi_core.contrib.django.providers import get_default_openapi_instance class DjangoOpenAPIMiddleware(DjangoIntegration): @@ -26,19 +24,7 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): if hasattr(settings, "OPENAPI_RESPONSE_CLS"): self.response_cls = settings.OPENAPI_RESPONSE_CLS - if not hasattr(settings, "OPENAPI"): - if not hasattr(settings, "OPENAPI_SPEC"): - raise ImproperlyConfigured( - "OPENAPI_SPEC not defined in settings" - ) - else: - warnings.warn( - "OPENAPI_SPEC is deprecated. Use OPENAPI instead.", - DeprecationWarning, - ) - openapi = OpenAPI(settings.OPENAPI_SPEC) - else: - openapi = settings.OPENAPI + openapi = get_default_openapi_instance() super().__init__(openapi) diff --git a/openapi_core/contrib/django/providers.py b/openapi_core/contrib/django/providers.py new file mode 100644 index 00000000..cb4f2a73 --- /dev/null +++ b/openapi_core/contrib/django/providers.py @@ -0,0 +1,31 @@ +"""OpenAPI core contrib django providers module""" + +import warnings +from typing import cast + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from openapi_core import OpenAPI + + +def get_default_openapi_instance() -> OpenAPI: + """ + Retrieves or initializes the OpenAPI instance based on Django settings + (either OPENAPI or OPENAPI_SPEC). + This function ensures the spec is only loaded once. + """ + if hasattr(settings, "OPENAPI"): + # Recommended (newer) approach + return cast(OpenAPI, settings.OPENAPI) + elif hasattr(settings, "OPENAPI_SPEC"): + # Backward compatibility + warnings.warn( + "OPENAPI_SPEC is deprecated. Use OPENAPI in your settings instead.", + DeprecationWarning, + ) + return OpenAPI(settings.OPENAPI_SPEC) + else: + raise ImproperlyConfigured( + "Neither OPENAPI nor OPENAPI_SPEC is defined in Django settings." + ) diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/status/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/status/migrations/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py new file mode 100644 index 00000000..10d87749 --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from django.http import HttpResponse +from jsonschema_path import SchemaPath + +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +check_minimal_spec = DjangoOpenAPIViewDecorator.from_spec( + SchemaPath.from_file_path( + Path("tests/integration/data/v3.0/minimal_with_servers.yaml") + ) +) + + +@check_minimal_spec +def get_status(request): + return HttpResponse("OK") diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py index ff987972..be4e9781 100644 --- a/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py @@ -20,6 +20,7 @@ from djangoproject.pets.views import PetDetailView from djangoproject.pets.views import PetListView from djangoproject.pets.views import PetPhotoView +from djangoproject.status.views import get_status from djangoproject.tags.views import TagListView urlpatterns = [ @@ -48,4 +49,9 @@ TagListView.as_view(), name="tag_list_view", ), + path( + "status", + get_status, + name="get_status_view", + ), ] diff --git a/tests/integration/contrib/django/test_django_project.py b/tests/integration/contrib/django/test_django_project.py index 6614eeaf..4604db52 100644 --- a/tests/integration/contrib/django/test_django_project.py +++ b/tests/integration/contrib/django/test_django_project.py @@ -422,3 +422,41 @@ def test_post_valid(self, client, data_gif): assert response.status_code == 201 assert not response.content + + +class TestStatusView(BaseTestDjangoProject): + + def test_get_valid(self, client, data_gif): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + from django.conf import settings + + MIDDLEWARE = [ + v for v in settings.MIDDLEWARE if "openapi_core" not in v + ] + with override_settings(MIDDLEWARE=MIDDLEWARE): + response = client.get("/status", **headers) + + assert response.status_code == 200 + assert response.content.decode() == "OK" + + def test_post_valid(self, client): + data = {"key": "value"} + content_type = "application/json" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + from django.conf import settings + + MIDDLEWARE = [ + v for v in settings.MIDDLEWARE if "openapi_core" not in v + ] + with override_settings(MIDDLEWARE=MIDDLEWARE): + response = client.post( + "/status", data=data, content_type=content_type, **headers + ) + + assert response.status_code == 405 # Method Not Allowed