Skip to content

Commit b31290a

Browse files
authored
refactor: add distinct operationId in openapi schema for menu endpoin… (#80)
* refactor: add distinct operationId in openapi schema for menu endpoints, separate schemas from views * refactor: remove pragma cov * refactor: simplify return * test: add tests for MenuSchema operation ID and schema fallbacks when drf-spectacular is unavailable * fix: update import for MenuSchema * test: add unit tests for MenuSchema operationId fallback * test: add unit test for method_schema_decorator * refactor: simplify and cleanup schema tests * fix: non-standard exception runtime error
1 parent 569c302 commit b31290a

File tree

5 files changed

+250
-108
lines changed

5 files changed

+250
-108
lines changed

djangocms_rest/schemas.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""OpenAPI schema generation utilities for djangocms-rest."""
2+
3+
try:
4+
from drf_spectacular.openapi import AutoSchema
5+
from drf_spectacular.types import OpenApiTypes
6+
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
7+
8+
from djangocms_rest.serializers.menus import NavigationNodeSerializer
9+
10+
class MenuSchema(AutoSchema):
11+
"""
12+
Custom schema generator that sets operation_id from URL name stored in view.
13+
This is needed to create distinct operation_ids for each menu endpoint.
14+
Adds _retrieve to the operation_id to match drf-spectacular's default pattern.
15+
"""
16+
17+
def get_operation_id(self):
18+
"""Override to use URL name stored in view as operation_id."""
19+
try:
20+
url_name = getattr(self.view, "_url_name", None)
21+
if not url_name and hasattr(self.view, "__class__"):
22+
url_name = getattr(self.view.__class__, "_url_name", None)
23+
24+
if url_name:
25+
return url_name.replace("-", "_") + "_retrieve"
26+
27+
# Fallback to default
28+
return super().get_operation_id()
29+
except Exception:
30+
return super().get_operation_id()
31+
32+
def create_view_with_url_name(view_class, url_name):
33+
"""Create a view instance with URL name stored for schema generation."""
34+
35+
class ViewWithUrlName(view_class):
36+
_url_name = url_name
37+
38+
return ViewWithUrlName.as_view()
39+
40+
def menu_schema_class(view_class):
41+
"""Decorator to apply MenuSchema to a view class."""
42+
view_class.schema = MenuSchema()
43+
return view_class
44+
45+
def method_schema_decorator(method):
46+
"""
47+
Decorator for adding OpenAPI schema to a method.
48+
Needed to force the schema to use many=True for NavigationNodeSerializer.
49+
"""
50+
return extend_schema(responses=OpenApiResponse(response=NavigationNodeSerializer(many=True)))(method)
51+
52+
extend_placeholder_schema = extend_schema(
53+
parameters=[
54+
OpenApiParameter(
55+
name="html",
56+
type=OpenApiTypes.INT,
57+
location="query",
58+
description="Set to 1 to include HTML rendering in response",
59+
required=False,
60+
enum=[1],
61+
),
62+
OpenApiParameter(
63+
name="preview",
64+
type=OpenApiTypes.BOOL,
65+
location="query",
66+
description="Set to true to preview unpublished content (admin access required)",
67+
required=False,
68+
),
69+
]
70+
)
71+
72+
except ImportError:
73+
74+
def method_schema_decorator(method):
75+
return method
76+
77+
def menu_schema_class(view_class):
78+
return view_class
79+
80+
def create_view_with_url_name(view_class, url_name):
81+
"""No-op when drf-spectacular is not available."""
82+
return view_class.as_view()
83+
84+
def extend_placeholder_schema(func):
85+
"""No-op when drf-spectacular is not available."""
86+
return func

djangocms_rest/urls.py

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.urls import path
22

33
from . import views
4+
from .schemas import create_view_with_url_name
45

56

67
urlpatterns = [
@@ -37,85 +38,85 @@
3738
),
3839
path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"),
3940
# Menu endpoints
40-
path("<slug:language>/menu/", views.MenuView.as_view(), name="menu"),
41+
path("<slug:language>/menu/", create_view_with_url_name(views.MenuView, "menu"), name="menu"),
4142
path(
4243
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
43-
views.MenuView.as_view(),
44-
name="menu",
44+
create_view_with_url_name(views.MenuView, "menu-levels"),
45+
name="menu-levels",
4546
),
4647
path(
4748
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
48-
views.MenuView.as_view(),
49-
name="menu",
49+
create_view_with_url_name(views.MenuView, "menu-levels-path"),
50+
name="menu-levels-path",
5051
),
5152
path(
5253
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
53-
views.MenuView.as_view(),
54-
name="menu",
54+
create_view_with_url_name(views.MenuView, "menu-root-levels"),
55+
name="menu-root-levels",
5556
),
5657
path(
5758
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
58-
views.MenuView.as_view(),
59-
name="menu",
59+
create_view_with_url_name(views.MenuView, "menu-root-levels-path"),
60+
name="menu-root-levels-path",
6061
),
6162
path(
6263
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/<path:path>/",
63-
views.SubMenuView.as_view(),
64-
name="submenu",
64+
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-nephews-path"),
65+
name="submenu-levels-root-nephews-path",
6566
),
6667
path(
6768
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/",
68-
views.SubMenuView.as_view(),
69-
name="submenu",
69+
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-nephews"),
70+
name="submenu-levels-root-nephews",
7071
),
7172
path(
7273
"<slug:language>/submenu/<int:levels>/<int:root_level>/<path:path>/",
73-
views.SubMenuView.as_view(),
74-
name="submenu",
74+
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-path"),
75+
name="submenu-levels-root-path",
7576
),
7677
path(
7778
"<slug:language>/submenu/<int:levels>/<int:root_level>/",
78-
views.SubMenuView.as_view(),
79-
name="submenu",
79+
create_view_with_url_name(views.SubMenuView, "submenu-levels-root"),
80+
name="submenu-levels-root",
8081
),
8182
path(
8283
"<slug:language>/submenu/<int:levels>/<path:path>/",
83-
views.SubMenuView.as_view(),
84-
name="submenu",
84+
create_view_with_url_name(views.SubMenuView, "submenu-levels-path"),
85+
name="submenu-levels-path",
8586
),
8687
path(
8788
"<slug:language>/submenu/<int:levels>/",
88-
views.SubMenuView.as_view(),
89-
name="submenu",
89+
create_view_with_url_name(views.SubMenuView, "submenu-levels"),
90+
name="submenu-levels",
9091
),
9192
path(
9293
"<slug:language>/submenu/<path:path>/",
93-
views.SubMenuView.as_view(),
94-
name="submenu",
94+
create_view_with_url_name(views.SubMenuView, "submenu-path"),
95+
name="submenu-path",
9596
),
9697
path(
9798
"<slug:language>/submenu/",
98-
views.SubMenuView.as_view(),
99+
create_view_with_url_name(views.SubMenuView, "submenu"),
99100
name="submenu",
100101
),
101102
path(
102103
"<slug:language>/breadcrumbs/<int:start_level>/<path:path>/",
103-
views.BreadcrumbView.as_view(),
104-
name="breadcrumbs",
104+
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-level-path"),
105+
name="breadcrumbs-level-path",
105106
),
106107
path(
107108
"<slug:language>/breadcrumbs/<int:start_level>/",
108-
views.BreadcrumbView.as_view(),
109-
name="breadcrumbs",
109+
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-level"),
110+
name="breadcrumbs-level",
110111
),
111112
path(
112113
"<slug:language>/breadcrumbs/<path:path>/",
113-
views.BreadcrumbView.as_view(),
114-
name="breadcrumbs",
114+
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-path"),
115+
name="breadcrumbs-path",
115116
),
116117
path(
117118
"<slug:language>/breadcrumbs/",
118-
views.BreadcrumbView.as_view(),
119+
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs"),
119120
name="breadcrumbs",
120121
),
121122
]

djangocms_rest/views.py

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, ParamSpec, TypeVar
4-
from collections.abc import Callable
3+
from typing import Any
54
from django.contrib.sites.shortcuts import get_current_site
65
from django.urls import reverse
76
from django.utils.functional import lazy
@@ -33,57 +32,7 @@
3332
get_site_filtered_queryset,
3433
)
3534
from djangocms_rest.views_base import BaseAPIView, BaseListAPIView
36-
37-
P = ParamSpec("P")
38-
T = TypeVar("T")
39-
40-
try:
41-
from drf_spectacular.types import OpenApiTypes
42-
from drf_spectacular.utils import OpenApiParameter, extend_schema
43-
44-
extend_placeholder_schema = extend_schema(
45-
parameters=[
46-
OpenApiParameter(
47-
name="html",
48-
type=OpenApiTypes.INT,
49-
location="query",
50-
description="Set to 1 to include HTML rendering in response",
51-
required=False,
52-
enum=[1],
53-
),
54-
OpenApiParameter(
55-
name="preview",
56-
type=OpenApiTypes.BOOL,
57-
location="query",
58-
description="Set to true to preview unpublished content (admin access required)",
59-
required=False,
60-
),
61-
]
62-
)
63-
64-
except ImportError: # pragma: no cover
65-
66-
class OpenApiTypes:
67-
BOOL = "boolean"
68-
INT = "integer"
69-
70-
class OpenApiParameter: # pragma: no cover
71-
QUERY = "query"
72-
PATH = "path"
73-
HEADER = "header"
74-
COOKIE = "cookie"
75-
76-
def __init__(self, *args, **kwargs):
77-
pass
78-
79-
def extend_schema(*_args, **_kwargs): # pragma: no cover
80-
def _decorator(obj: T) -> T:
81-
return obj
82-
83-
return _decorator
84-
85-
def extend_placeholder_schema(func: Callable[P, T]) -> Callable[P, T]:
86-
return func
35+
from djangocms_rest.schemas import extend_placeholder_schema, menu_schema_class
8736

8837

8938
# Generate the plugin definitions once at module load time
@@ -258,30 +207,14 @@ def get(self, request: Request) -> Response:
258207
return Response(definitions)
259208

260209

261-
try:
262-
from drf_spectacular.utils import extend_schema, OpenApiResponse
263-
264-
def method_schema_decorator(method):
265-
"""
266-
Decorator for adding OpenAPI schema to a method.
267-
Needed to force the schema to use many=True for NavigationNodeSerializer.
268-
"""
269-
return extend_schema(responses=OpenApiResponse(response=NavigationNodeSerializer(many=True)))(method)
270-
271-
except ImportError: # pragma: no cover
272-
273-
def method_schema_decorator(method): # pragma: no cover
274-
return method # pragma: no cover
275-
276-
210+
@menu_schema_class
277211
class MenuView(BaseAPIView):
278212
permission_classes = [IsAllowedPublicLanguage]
279213
serializer_class = NavigationNodeSerializer
280214

281215
tag = ShowMenu
282216
return_key = "children"
283217

284-
@method_schema_decorator
285218
def get(
286219
self,
287220
request: Request,
@@ -352,6 +285,7 @@ def get_menu_structure(
352285
return result
353286

354287

288+
@menu_schema_class
355289
class SubMenuView(MenuView):
356290
tag = ShowSubMenu
357291

@@ -361,6 +295,7 @@ def populate_defaults(self, kwargs: dict[str, Any]) -> None:
361295
kwargs.setdefault("nephews", 100)
362296

363297

298+
@menu_schema_class
364299
class BreadcrumbView(MenuView):
365300
tag = ShowBreadcrumb
366301
return_key = "ancestors"

tests/endpoints/test_menu.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_get_menu_no_children(self):
2626

2727
# GET
2828
url = reverse(
29-
"menu",
29+
"menu-levels",
3030
kwargs={
3131
"language": "en",
3232
"from_level": 0,
@@ -68,7 +68,7 @@ def test_get_menu_with_children(self):
6868

6969
# GET
7070
url = reverse(
71-
"menu",
71+
"menu-levels-path",
7272
kwargs={
7373
"language": "en",
7474
"path": "page-2",
@@ -94,7 +94,7 @@ def test_default_levels(self):
9494
kwargs={"language": "en"},
9595
)
9696
url2 = reverse(
97-
"menu",
97+
"menu-levels",
9898
kwargs={
9999
"language": "en",
100100
"from_level": 0,
@@ -111,14 +111,14 @@ def test_default_levels(self):
111111

112112
def test_non_existing_root_id(self):
113113
url = reverse(
114-
"menu",
114+
"menu-root-levels",
115115
kwargs={
116116
"language": "en",
117+
"root_id": "I_DO_NOT_EXIST",
117118
"from_level": 0,
118119
"to_level": 100,
119120
"extra_inactive": 0,
120121
"extra_active": 100,
121-
"root_id": "I_DO_NOT_EXIST",
122122
},
123123
)
124124
response = self.client.get(url)
@@ -127,7 +127,7 @@ def test_non_existing_root_id(self):
127127
def test_submenu(self):
128128
# GET
129129
url = reverse(
130-
"submenu",
130+
"submenu-path",
131131
kwargs={
132132
"language": "en",
133133
"path": "page-2",
@@ -145,7 +145,7 @@ def test_submenu(self):
145145
def test_breadcrumbs(self):
146146
# GET
147147
url = reverse(
148-
"breadcrumbs",
148+
"breadcrumbs-path",
149149
kwargs={
150150
"language": "en",
151151
"path": "page-2/page-0",

0 commit comments

Comments
 (0)