Skip to content

Commit 844169f

Browse files
authored
Enable filtering on Project API actions #1449 (#1450)
* Enable filtering on Project API actions #1449 Signed-off-by: tdruez <tdruez@nexb.com> * Refactor the API action filtering to remove duplication #1449 Signed-off-by: tdruez <tdruez@nexb.com> * Add documentation #1449 Signed-off-by: tdruez <tdruez@nexb.com> --------- Signed-off-by: tdruez <tdruez@nexb.com>
1 parent debaf7c commit 844169f

File tree

5 files changed

+148
-31
lines changed

5 files changed

+148
-31
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog
22
=========
33

4+
v34.9.1 (unreleased)
5+
--------------------
6+
7+
- Add the ability to filter on Project endpoint API actions.
8+
The list of ``resources``, ``packages``, ``dependencies``, ``relations``, and
9+
``messages`` can be filtered providing the ``?field_name=value`` in the URL
10+
parameters.
11+
https://github.com/aboutcode-org/scancode.io/issues/1449
12+
413
v34.9.0 (2024-11-14)
514
--------------------
615

docs/rest-api.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,9 @@ Lists all ``packages`` of a given ``project``.
433433
}
434434
]
435435
436+
The list supports filtering by most fields using the ``?field_name=value`` query
437+
parameter syntax.
438+
436439
Resources
437440
^^^^^^^^^
438441

@@ -453,6 +456,40 @@ This action lists all ``resources`` of a given ``project``.
453456
}
454457
]
455458
459+
The list supports filtering by most fields using the ``?field_name=value`` query
460+
parameter syntax.
461+
462+
Dependencies
463+
^^^^^^^^^^^^
464+
465+
Lists all ``dependencies`` of a given ``project``.
466+
467+
``GET /api/projects/d4ed9405-5568-45ad-99f6-782a9b82d1d2/dependencies/``
468+
469+
.. code-block:: json
470+
471+
[
472+
{
473+
"purl": "pkg:pypi/appdirs@1.4.4",
474+
"extracted_requirement": "==1.4.4",
475+
"scope": "test",
476+
"is_runtime": true,
477+
"is_optional": true,
478+
"is_pinned": true,
479+
"is_direct": true,
480+
"dependency_uid": "pkg:pypi/appdirs@1.4.4?uuid=0033a678-2003-420d-8c83-c4071e646f4e",
481+
"for_package_uid": "pkg:pypi/platformdirs@4.2.1?uuid=6d90ce5b-a3f8-4110-a4bd-2da5a8930d29",
482+
"resolved_to_package_uid": null,
483+
"datafile_path": "platformdirs-4.2.1.dist-info/METADATA",
484+
"datasource_id": "pypi_wheel_metadata",
485+
"package_type": "pypi",
486+
"[...]": "[...]"
487+
},
488+
]
489+
490+
The list supports filtering by most fields using the ``?field_name=value`` query
491+
parameter syntax.
492+
456493
Results
457494
^^^^^^^
458495

scanpipe/api/views.py

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
from scanpipe.api.serializers import ProjectMessageSerializer
4545
from scanpipe.api.serializers import ProjectSerializer
4646
from scanpipe.api.serializers import RunSerializer
47+
from scanpipe.filters import DependencyFilterSet
48+
from scanpipe.filters import PackageFilterSet
49+
from scanpipe.filters import ProjectMessageFilterSet
50+
from scanpipe.filters import RelationFilterSet
51+
from scanpipe.filters import ResourceFilterSet
4752
from scanpipe.models import Project
4853
from scanpipe.models import Run
4954
from scanpipe.models import RunInProgressError
@@ -190,55 +195,63 @@ def pipelines(self, request, *args, **kwargs):
190195
]
191196
return Response(pipeline_data)
192197

193-
@action(detail=True)
194-
def resources(self, request, *args, **kwargs):
195-
project = self.get_object()
196-
queryset = project.codebaseresources.prefetch_related("discovered_packages")
198+
def get_filtered_response(
199+
self, request, queryset, filterset_class, serializer_class
200+
):
201+
"""
202+
Handle filtering, pagination, and serialization of a "detail" action.
203+
This requires to set filterset_class=None in the @action decorator parameter
204+
to bypass the Project filterset.
205+
"""
206+
filterset = filterset_class(data=request.GET, queryset=queryset)
207+
if not filterset.is_valid():
208+
message = {"errors": filterset.errors}
209+
return Response(message, status=status.HTTP_400_BAD_REQUEST)
197210

211+
queryset = filterset.qs
198212
paginated_qs = self.paginate_queryset(queryset)
199-
serializer = CodebaseResourceSerializer(paginated_qs, many=True)
200-
213+
serializer = serializer_class(paginated_qs, many=True)
201214
return self.get_paginated_response(serializer.data)
202215

203-
@action(detail=True)
216+
@action(detail=True, filterset_class=None)
217+
def resources(self, request, *args, **kwargs):
218+
project = self.get_object()
219+
queryset = project.codebaseresources.prefetch_related("discovered_packages")
220+
return self.get_filtered_response(
221+
request, queryset, ResourceFilterSet, CodebaseResourceSerializer
222+
)
223+
224+
@action(detail=True, filterset_class=None)
204225
def packages(self, request, *args, **kwargs):
205226
project = self.get_object()
206227
queryset = project.discoveredpackages.all()
228+
return self.get_filtered_response(
229+
request, queryset, PackageFilterSet, DiscoveredPackageSerializer
230+
)
207231

208-
paginated_qs = self.paginate_queryset(queryset)
209-
serializer = DiscoveredPackageSerializer(paginated_qs, many=True)
210-
211-
return self.get_paginated_response(serializer.data)
212-
213-
@action(detail=True)
232+
@action(detail=True, filterset_class=None)
214233
def dependencies(self, request, *args, **kwargs):
215234
project = self.get_object()
216235
queryset = project.discovereddependencies.all()
236+
return self.get_filtered_response(
237+
request, queryset, DependencyFilterSet, DiscoveredDependencySerializer
238+
)
217239

218-
paginated_qs = self.paginate_queryset(queryset)
219-
serializer = DiscoveredDependencySerializer(paginated_qs, many=True)
220-
221-
return self.get_paginated_response(serializer.data)
222-
223-
@action(detail=True)
240+
@action(detail=True, filterset_class=None)
224241
def relations(self, request, *args, **kwargs):
225242
project = self.get_object()
226243
queryset = project.codebaserelations.all()
244+
return self.get_filtered_response(
245+
request, queryset, RelationFilterSet, CodebaseRelationSerializer
246+
)
227247

228-
paginated_qs = self.paginate_queryset(queryset)
229-
serializer = CodebaseRelationSerializer(paginated_qs, many=True)
230-
231-
return self.get_paginated_response(serializer.data)
232-
233-
@action(detail=True)
248+
@action(detail=True, filterset_class=None)
234249
def messages(self, request, *args, **kwargs):
235250
project = self.get_object()
236251
queryset = project.projectmessages.all()
237-
238-
paginated_qs = self.paginate_queryset(queryset)
239-
serializer = ProjectMessageSerializer(paginated_qs, many=True)
240-
241-
return self.get_paginated_response(serializer.data)
252+
return self.get_filtered_response(
253+
request, queryset, ProjectMessageFilterSet, ProjectMessageSerializer
254+
)
242255

243256
@action(detail=True, methods=["get"])
244257
def file_content(self, request, *args, **kwargs):

scanpipe/filters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ class Meta:
880880
]
881881

882882
def __init__(self, *args, **kwargs):
883-
project = kwargs.pop("project")
883+
project = kwargs.pop("project", None)
884884
super().__init__(*args, **kwargs)
885885
if project:
886886
qs = CodebaseResource.objects.filter(project=project)

scanpipe/tests/test_api.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,37 @@ def test_scanpipe_api_project_action_resources(self):
685685
response = self.csrf_client.get(url)
686686
self.assertEqual("error", response.data["results"][0]["compliance_alert"])
687687

688+
def test_scanpipe_api_project_action_resources_filterset(self):
689+
make_resource_file(
690+
self.project1,
691+
path="path/",
692+
)
693+
url = reverse("project-resources", args=[self.project1.uuid])
694+
response = self.csrf_client.get(url)
695+
self.assertEqual(2, response.data["count"])
696+
697+
response = self.csrf_client.get(url + "?path=path/")
698+
self.assertEqual(1, response.data["count"])
699+
package = response.data["results"][0]
700+
self.assertEqual("path/", package["path"])
701+
702+
response = self.csrf_client.get(url + "?path=unknown")
703+
self.assertEqual(0, response.data["count"])
704+
705+
response = self.csrf_client.get(url + "?compliance_alert=a")
706+
self.assertEqual(400, response.status_code)
707+
expected = {
708+
"compliance_alert": [
709+
"Select a valid choice. a is not one of the available choices."
710+
]
711+
}
712+
self.assertEqual(expected, response.data["errors"])
713+
714+
# Using a field name available on the Project model to make sure the
715+
# ProjectFilterSet is bypassed.
716+
response = self.csrf_client.get(url + "?slug=aaa")
717+
self.assertEqual(2, response.data["count"])
718+
688719
def test_scanpipe_api_project_action_packages(self):
689720
url = reverse("project-packages", args=[self.project1.uuid])
690721
response = self.csrf_client.get(url)
@@ -697,6 +728,24 @@ def test_scanpipe_api_project_action_packages(self):
697728
self.assertEqual("pkg:deb/debian/adduser@3.118?arch=all", package["purl"])
698729
self.assertEqual("adduser", package["name"])
699730

731+
def test_scanpipe_api_project_action_packages_filterset(self):
732+
make_package(self.project1, package_url="pkg:generic/name@1.0")
733+
url = reverse("project-packages", args=[self.project1.uuid])
734+
response = self.csrf_client.get(url)
735+
self.assertEqual(2, response.data["count"])
736+
737+
response = self.csrf_client.get(url + "?version=1.0")
738+
self.assertEqual(1, response.data["count"])
739+
package = response.data["results"][0]
740+
self.assertEqual("pkg:generic/name@1.0", package["purl"])
741+
742+
response = self.csrf_client.get(url + "?version=2.0")
743+
self.assertEqual(0, response.data["count"])
744+
745+
response = self.csrf_client.get(url + "?size=a")
746+
self.assertEqual(400, response.status_code)
747+
self.assertEqual({"size": ["Enter a number."]}, response.data["errors"])
748+
700749
def test_scanpipe_api_project_action_dependencies(self):
701750
url = reverse("project-dependencies", args=[self.project1.uuid])
702751
response = self.csrf_client.get(url)
@@ -731,6 +780,15 @@ def test_scanpipe_api_project_action_relations(self):
731780
}
732781
self.assertEqual(expected, relation)
733782

783+
def test_scanpipe_api_project_action_relations_filterset(self):
784+
url = reverse("project-relations", args=[self.project1.uuid])
785+
response = self.csrf_client.get(url + "?map_type=about_file")
786+
self.assertEqual(0, response.data["count"])
787+
788+
map_type = self.codebase_relation1.map_type
789+
response = self.csrf_client.get(url + f"?map_type={map_type}")
790+
self.assertEqual(1, response.data["count"])
791+
734792
def test_scanpipe_api_project_action_messages(self):
735793
url = reverse("project-messages", args=[self.project1.uuid])
736794
ProjectMessage.objects.create(

0 commit comments

Comments
 (0)