Skip to content

Commit 32345fd

Browse files
authored
Merge pull request #976 from mik-laj/django-decorator
Add django decorator
2 parents f669ce9 + ca6044e commit 32345fd

File tree

9 files changed

+236
-16
lines changed

9 files changed

+236
-16
lines changed

Diff for: docs/integrations/django.md

+40
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,46 @@ OPENAPI = OpenAPI.from_dict(spec_dict)
5757
OPENAPI_RESPONSE_CLS = None
5858
```
5959

60+
## Decorator
61+
62+
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.
63+
64+
Use `DjangoOpenAPIViewDecorator` with the OpenAPI object to create the decorator.
65+
66+
``` python hl_lines="1 3 6"
67+
from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator
68+
69+
openapi_validated = FlaskOpenAPIViewDecorator(openapi)
70+
71+
72+
@openapi_validated
73+
def home():
74+
return "Welcome home"
75+
```
76+
77+
You can skip the response validation process by setting `response_cls` to `None`.
78+
79+
``` python hl_lines="5"
80+
from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator
81+
82+
openapi_validated = DjangoOpenAPIViewDecorator(
83+
openapi,
84+
response_cls=None,
85+
)
86+
```
87+
88+
If you want to decorate a class-based view, you can use the `method_decorator` decorator:
89+
90+
``` python hl_lines="3"
91+
from django.utils.decorators import method_decorator
92+
93+
@method_decorator(openapi_validated, name='dispatch')
94+
class MyView(View):
95+
96+
def get(self, request, *args, **kwargs):
97+
return "Welcome home"
98+
```
99+
60100
## Low level
61101

62102
The integration defines classes useful for low-level integration.

Diff for: openapi_core/contrib/django/decorators.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""OpenAPI core contrib django decorators module"""
2+
3+
from typing import Any
4+
from typing import Callable
5+
from typing import Optional
6+
from typing import Type
7+
8+
from django.conf import settings
9+
from django.http.request import HttpRequest
10+
from django.http.response import HttpResponse
11+
from jsonschema_path import SchemaPath
12+
13+
from openapi_core import OpenAPI
14+
from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler
15+
from openapi_core.contrib.django.handlers import (
16+
DjangoOpenAPIValidRequestHandler,
17+
)
18+
from openapi_core.contrib.django.integrations import DjangoIntegration
19+
from openapi_core.contrib.django.providers import get_default_openapi_instance
20+
from openapi_core.contrib.django.requests import DjangoOpenAPIRequest
21+
from openapi_core.contrib.django.responses import DjangoOpenAPIResponse
22+
23+
24+
class DjangoOpenAPIViewDecorator(DjangoIntegration):
25+
valid_request_handler_cls = DjangoOpenAPIValidRequestHandler
26+
errors_handler_cls: Type[DjangoOpenAPIErrorsHandler] = (
27+
DjangoOpenAPIErrorsHandler
28+
)
29+
30+
def __init__(
31+
self,
32+
openapi: Optional[OpenAPI] = None,
33+
request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest,
34+
response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse,
35+
errors_handler_cls: Type[
36+
DjangoOpenAPIErrorsHandler
37+
] = DjangoOpenAPIErrorsHandler,
38+
):
39+
if openapi is None:
40+
openapi = get_default_openapi_instance()
41+
42+
super().__init__(openapi)
43+
44+
# If OPENAPI_RESPONSE_CLS is defined in settings.py (for custom response classes),
45+
# set the response_cls accordingly.
46+
if hasattr(settings, "OPENAPI_RESPONSE_CLS"):
47+
response_cls = settings.OPENAPI_RESPONSE_CLS
48+
49+
self.request_cls = request_cls
50+
self.response_cls = response_cls
51+
52+
def __call__(self, view_func: Callable[..., Any]) -> Callable[..., Any]:
53+
"""
54+
Thanks to this method, the class acts as a decorator.
55+
Example usage:
56+
57+
@DjangoOpenAPIViewDecorator()
58+
def my_view(request): ...
59+
60+
"""
61+
62+
def _wrapped_view(
63+
request: HttpRequest, *args: Any, **kwargs: Any
64+
) -> HttpResponse:
65+
# get_response is the function that we treats
66+
# as the "next step" in the chain (i.e., our original view).
67+
def get_response(r: HttpRequest) -> HttpResponse:
68+
return view_func(r, *args, **kwargs)
69+
70+
# Create a handler that will validate the request.
71+
valid_request_handler = self.valid_request_handler_cls(
72+
request, get_response
73+
)
74+
75+
# Validate the request (before running the view).
76+
errors_handler = self.errors_handler_cls()
77+
response = self.handle_request(
78+
request, valid_request_handler, errors_handler
79+
)
80+
81+
# Validate the response (after the view) if should_validate_response() returns True.
82+
return self.handle_response(request, response, errors_handler)
83+
84+
return _wrapped_view
85+
86+
@classmethod
87+
def from_spec(
88+
cls,
89+
spec: SchemaPath,
90+
request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest,
91+
response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse,
92+
errors_handler_cls: Type[
93+
DjangoOpenAPIErrorsHandler
94+
] = DjangoOpenAPIErrorsHandler,
95+
) -> "DjangoOpenAPIViewDecorator":
96+
openapi = OpenAPI(spec)
97+
return cls(
98+
openapi,
99+
request_cls=request_cls,
100+
response_cls=response_cls,
101+
errors_handler_cls=errors_handler_cls,
102+
)

Diff for: openapi_core/contrib/django/middlewares.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
"""OpenAPI core contrib django middlewares module"""
22

3-
import warnings
43
from typing import Callable
54

65
from django.conf import settings
7-
from django.core.exceptions import ImproperlyConfigured
86
from django.http.request import HttpRequest
97
from django.http.response import HttpResponse
108

11-
from openapi_core import OpenAPI
129
from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler
1310
from openapi_core.contrib.django.handlers import (
1411
DjangoOpenAPIValidRequestHandler,
1512
)
1613
from openapi_core.contrib.django.integrations import DjangoIntegration
14+
from openapi_core.contrib.django.providers import get_default_openapi_instance
1715

1816

1917
class DjangoOpenAPIMiddleware(DjangoIntegration):
@@ -26,19 +24,7 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
2624
if hasattr(settings, "OPENAPI_RESPONSE_CLS"):
2725
self.response_cls = settings.OPENAPI_RESPONSE_CLS
2826

29-
if not hasattr(settings, "OPENAPI"):
30-
if not hasattr(settings, "OPENAPI_SPEC"):
31-
raise ImproperlyConfigured(
32-
"OPENAPI_SPEC not defined in settings"
33-
)
34-
else:
35-
warnings.warn(
36-
"OPENAPI_SPEC is deprecated. Use OPENAPI instead.",
37-
DeprecationWarning,
38-
)
39-
openapi = OpenAPI(settings.OPENAPI_SPEC)
40-
else:
41-
openapi = settings.OPENAPI
27+
openapi = get_default_openapi_instance()
4228

4329
super().__init__(openapi)
4430

Diff for: openapi_core/contrib/django/providers.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""OpenAPI core contrib django providers module"""
2+
3+
import warnings
4+
from typing import cast
5+
6+
from django.conf import settings
7+
from django.core.exceptions import ImproperlyConfigured
8+
9+
from openapi_core import OpenAPI
10+
11+
12+
def get_default_openapi_instance() -> OpenAPI:
13+
"""
14+
Retrieves or initializes the OpenAPI instance based on Django settings
15+
(either OPENAPI or OPENAPI_SPEC).
16+
This function ensures the spec is only loaded once.
17+
"""
18+
if hasattr(settings, "OPENAPI"):
19+
# Recommended (newer) approach
20+
return cast(OpenAPI, settings.OPENAPI)
21+
elif hasattr(settings, "OPENAPI_SPEC"):
22+
# Backward compatibility
23+
warnings.warn(
24+
"OPENAPI_SPEC is deprecated. Use OPENAPI in your settings instead.",
25+
DeprecationWarning,
26+
)
27+
return OpenAPI(settings.OPENAPI_SPEC)
28+
else:
29+
raise ImproperlyConfigured(
30+
"Neither OPENAPI nor OPENAPI_SPEC is defined in Django settings."
31+
)

Diff for: tests/integration/contrib/django/data/v3.0/djangoproject/status/__init__.py

Whitespace-only changes.

Diff for: tests/integration/contrib/django/data/v3.0/djangoproject/status/migrations/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
3+
from django.http import HttpResponse
4+
from jsonschema_path import SchemaPath
5+
6+
from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator
7+
8+
check_minimal_spec = DjangoOpenAPIViewDecorator.from_spec(
9+
SchemaPath.from_file_path(
10+
Path("tests/integration/data/v3.0/minimal_with_servers.yaml")
11+
)
12+
)
13+
14+
15+
@check_minimal_spec
16+
def get_status(request):
17+
return HttpResponse("OK")

Diff for: tests/integration/contrib/django/data/v3.0/djangoproject/urls.py

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from djangoproject.pets.views import PetDetailView
2121
from djangoproject.pets.views import PetListView
2222
from djangoproject.pets.views import PetPhotoView
23+
from djangoproject.status.views import get_status
2324
from djangoproject.tags.views import TagListView
2425

2526
urlpatterns = [
@@ -48,4 +49,9 @@
4849
TagListView.as_view(),
4950
name="tag_list_view",
5051
),
52+
path(
53+
"status",
54+
get_status,
55+
name="get_status_view",
56+
),
5157
]

Diff for: tests/integration/contrib/django/test_django_project.py

+38
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,41 @@ def test_post_valid(self, client, data_gif):
422422

423423
assert response.status_code == 201
424424
assert not response.content
425+
426+
427+
class TestStatusView(BaseTestDjangoProject):
428+
429+
def test_get_valid(self, client, data_gif):
430+
headers = {
431+
"HTTP_AUTHORIZATION": "Basic testuser",
432+
"HTTP_HOST": "petstore.swagger.io",
433+
}
434+
from django.conf import settings
435+
436+
MIDDLEWARE = [
437+
v for v in settings.MIDDLEWARE if "openapi_core" not in v
438+
]
439+
with override_settings(MIDDLEWARE=MIDDLEWARE):
440+
response = client.get("/status", **headers)
441+
442+
assert response.status_code == 200
443+
assert response.content.decode() == "OK"
444+
445+
def test_post_valid(self, client):
446+
data = {"key": "value"}
447+
content_type = "application/json"
448+
headers = {
449+
"HTTP_AUTHORIZATION": "Basic testuser",
450+
"HTTP_HOST": "petstore.swagger.io",
451+
}
452+
from django.conf import settings
453+
454+
MIDDLEWARE = [
455+
v for v in settings.MIDDLEWARE if "openapi_core" not in v
456+
]
457+
with override_settings(MIDDLEWARE=MIDDLEWARE):
458+
response = client.post(
459+
"/status", data=data, content_type=content_type, **headers
460+
)
461+
462+
assert response.status_code == 405 # Method Not Allowed

0 commit comments

Comments
 (0)