Skip to content

Commit b40cecb

Browse files
Fix OpenAPI Client Generation for multivalue query params (#2669)
* use custom django filter extension to get explode: true * simplify an assertion * clarify docstring * simplify filtering * regenerate spec
1 parent 9376396 commit b40cecb

File tree

7 files changed

+408
-450
lines changed

7 files changed

+408
-450
lines changed

frontends/api/src/generated/v1/api.ts

Lines changed: 212 additions & 281 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

learning_resources/filters.py

Lines changed: 9 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,18 @@ class LearningResourceFilter(FilterSet):
7878
topic = CharInFilter(
7979
label="Topics covered by the resources. Load the '/api/v1/topics' endpoint "
8080
"for a list of topics",
81-
method="filter_topic",
81+
field_name="topics__name__iexact",
8282
)
8383

8484
course_feature = CharInFilter(
8585
label="Content feature for the resources. Load the 'api/v1/course_features' "
8686
"endpoint for a list of course features",
87-
method="filter_course_feature",
87+
field_name="content_tags__name__iexact",
8888
)
8989

9090
readable_id = CharInFilter(
9191
label="A unique text identifier for the resources",
92-
method="filter_readable_id",
92+
field_name="readable_id",
9393
)
9494

9595
sortby = ChoiceFilter(
@@ -159,23 +159,11 @@ def filter_resource_category(self, queryset, _, value):
159159
)
160160
return queryset.filter(query_or_filters)
161161

162-
def filter_readable_id(self, queryset, _, value):
163-
"""Readable id filter for leaarning resources"""
164-
return multi_or_filter(queryset, "readable_id", value)
165-
166162
def filter_level(self, queryset, _, value):
167163
"""Level Filter for learning resources"""
168164
values = [[LevelType[val].name] for val in value]
169165
return multi_or_filter(queryset, "runs__level__contains", values)
170166

171-
def filter_topic(self, queryset, _, value):
172-
"""Topic Filter for learning resources"""
173-
return multi_or_filter(queryset, "topics__name__iexact", value)
174-
175-
def filter_course_feature(self, queryset, _, value):
176-
"""Course Filter for learning resources"""
177-
return multi_or_filter(queryset, "content_tags__name__iexact", value)
178-
179167
def filter_sortby(self, queryset, _, value):
180168
"""Sort the queryset in the order specified by the value"""
181169
sort_param = LEARNING_RESOURCE_SORTBY_OPTIONS[value]["sort"]
@@ -201,23 +189,23 @@ class ContentFileFilter(FilterSet):
201189

202190
run_id = NumberInFilter(
203191
label="The id of the learning resource run the content file belongs to",
204-
method="filter_run_id",
192+
field_name="run_id",
205193
)
206194

207195
resource_id = NumberInFilter(
208196
label="The id of the learning resource the content file belongs to",
209-
method="filter_resource_id",
197+
field_name="run__learning_resource__id",
210198
)
211199

212200
content_feature_type = CharInFilter(
213201
label="Content feature type for the content file. Load the "
214202
"'api/v1/course_features' endpoint for a list of course features",
215-
method="filter_content_feature_type",
203+
field_name="content_tags__name__iexact",
216204
)
217205

218206
edx_module_id = CharInFilter(
219207
label="The edx module id of the content file",
220-
method="filter_edx_module_id",
208+
field_name="edx_module_id__iexact",
221209
)
222210

223211
offered_by = MultipleChoiceFilter(
@@ -236,30 +224,6 @@ class ContentFileFilter(FilterSet):
236224
choices=([(platform.name, platform.value) for platform in PlatformType]),
237225
)
238226

239-
def filter_run_id(self, queryset, _, value):
240-
"""Run ID Filter for contentfiles"""
241-
return multi_or_filter(queryset, "run_id", value)
242-
243-
def filter_resource_id(self, queryset, _, value):
244-
"""Resource ID Filter for contentfiles"""
245-
return multi_or_filter(queryset, "run__learning_resource__id", value)
246-
247-
def filter_run_readable_id(self, queryset, _, value):
248-
"""Run Readable ID Filter for contentfiles"""
249-
return multi_or_filter(queryset, "run__run_id", value)
250-
251-
def filter_resource_readable_id(self, queryset, _, value):
252-
"""Resource Readable ID Filter for contentfiles"""
253-
return multi_or_filter(queryset, "run__learning_resource__readable_id", value)
254-
255-
def filter_content_feature_type(self, queryset, _, value):
256-
"""Content feature type filter for contentfiles"""
257-
return multi_or_filter(queryset, "content_tags__name__iexact", value)
258-
259-
def filter_edx_module_id(self, queryset, _, value):
260-
"""Edx module id Filter for contentfiles"""
261-
return multi_or_filter(queryset, "edx_module_id__iexact", value)
262-
263227
class Meta:
264228
model = ContentFile
265229
fields = []
@@ -270,25 +234,17 @@ class TopicFilter(FilterSet):
270234

271235
name = CharInFilter(
272236
label="Topic name",
273-
method="filter_name",
237+
field_name="name__iexact",
274238
)
275239
parent_topic_id = NumberInFilter(
276240
label="Parent topic ID",
277-
method="filter_parent_topic_id",
241+
field_name="parent_id",
278242
)
279243
is_toplevel = BooleanFilter(
280244
label="Filter top-level topics",
281245
method="filter_toplevel",
282246
)
283247

284-
def filter_name(self, queryset, _, values):
285-
"""Filter by topic name"""
286-
return multi_or_filter(queryset, "name__iexact", values)
287-
288248
def filter_toplevel(self, queryset, _, value):
289249
"""Filter by top-level (parent == null)"""
290250
return queryset.filter(parent__isnull=value)
291-
292-
def filter_parent_topic_id(self, queryset, _, values):
293-
"""Get direct children of a topic"""
294-
return queryset.filter(parent_id__in=values)

learning_resources/filters_test.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,9 @@ def test_learning_resource_sortby_new(client):
423423
def test_learning_resource_filter_topics(mock_courses, client):
424424
"""Test that the topic filter works"""
425425
assert (
426-
list(
427-
set(mock_courses.ocw_course.topics.all().values_list("name", flat=True))
428-
& set(mock_courses.mitx_course.topics.all().values_list("name", flat=True))
429-
)
430-
== []
426+
set(mock_courses.ocw_course.topics.all().values_list("name", flat=True))
427+
& set(mock_courses.mitx_course.topics.all().values_list("name", flat=True))
428+
== set()
431429
)
432430

433431
results = client.get(

main/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class MainConfig(AppConfig):
1111

1212
def ready(self):
1313
"""Initialize the app"""
14+
import main.schema # noqa: F401
1415
from main import features
1516

1617
features.configure()

main/filters.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,42 @@ def multi_or_filter(
3737
return queryset.filter(query_or_filters)
3838

3939

40-
class CharInFilter(BaseInFilter, CharFilter):
40+
class BaseInFilterExplode(BaseInFilter):
41+
"""BaseIn filter that provides better help text for OpenAPI docs."""
42+
43+
help_text: str = "Separate values as ?field=value1&field=value2..."
44+
45+
def __init__(self, *args, **kwargs):
46+
kwargs.setdefault("help_text", kwargs.get("label", self.help_text))
47+
super().__init__(*args, **kwargs)
48+
49+
50+
class CharInFilter(BaseInFilterExplode, CharFilter):
4151
"""Filter that allows for multiple character values"""
4252

53+
def filter(self, qs, value):
54+
"""Apply multi-OR filter logic automatically"""
55+
if value:
56+
return multi_or_filter(qs, self.field_name, value)
57+
return qs
58+
4359

44-
class NumberInFilter(BaseInFilter, NumberFilter):
60+
class NumberInFilter(BaseInFilterExplode, NumberFilter):
4561
"""Filter that allows for multiple numeric values"""
4662

63+
def filter(self, qs, value):
64+
"""Apply multi-OR filter logic automatically"""
65+
if value:
66+
return multi_or_filter(qs, self.field_name, value)
67+
return qs
68+
4769

4870
class MultipleOptionsFilterBackend(DjangoFilterBackend):
4971
"""
5072
Custom filter backend that handles multiple values for the same key
5173
in various formats
74+
- MultipleChoiceFilter supports repeated values ("explode") or commas
75+
- CharInFilter and NumberInFilter supports only repeated values ("explode")
5276
"""
5377

5478
def get_filterset_kwargs(self, request, queryset, view): # noqa: ARG002

main/schema.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django_filters import BaseCSVFilter
2+
from drf_spectacular.contrib.django_filters import DjangoFilterExtension
3+
4+
5+
class CustomDjangoFilterExtension(DjangoFilterExtension):
6+
priority = DjangoFilterExtension.priority + 1
7+
8+
def resolve_filter_field(self, *args, **kwargs):
9+
resolved = super().resolve_filter_field(*args, **kwargs)
10+
filter_field = args[4]
11+
if isinstance(filter_field, BaseCSVFilter):
12+
if len(resolved) != 1:
13+
msg = "Expected a single resolved field for BaseCSVFilter"
14+
raise ValueError(msg)
15+
resolved[0]["explode"] = True
16+
return resolved

0 commit comments

Comments
 (0)