Skip to content

Commit ff54722

Browse files
Valerie YuanValerie Yuan
authored andcommitted
Add GET /api/packages/{uuid}/dependents/ endpoint
Signed-off-by: vyuan2037 <vyuan2037@noreply.github.com>
1 parent 469c506 commit ff54722

File tree

2 files changed

+235
-0
lines changed

2 files changed

+235
-0
lines changed

packagedb/api.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from minecode.models import PriorityResourceURI
5050
from minecode.route import NoRouteAvailable
5151
from packagedb.filters import PackageSearchFilter
52+
from packagedb.models import DependentPackage
5253
from packagedb.models import Package
5354
from packagedb.models import PackageActivity
5455
from packagedb.models import PackageContentType
@@ -414,6 +415,65 @@ def get_enhanced_package_data(self, request, *args, **kwargs):
414415
package_data = get_enhanced_package(package)
415416
return Response(package_data)
416417

418+
@action(detail=True, methods=["get"])
419+
def dependents(self, request, *args, **kwargs):
420+
"""
421+
Return Packages that depend on the current Package.
422+
423+
This finds all DependentPackage entries whose ``purl`` references
424+
the current Package (matched by type, namespace, and name), and
425+
returns the parent packages that declare those dependencies.
426+
427+
Optional query parameters for filtering:
428+
429+
- ``scope``: filter by dependency scope (e.g., ``runtime``,
430+
``install``, ``develop``).
431+
- ``is_runtime``: filter by runtime dependency flag
432+
(``true`` or ``false``).
433+
- ``is_optional``: filter by optional dependency flag
434+
(``true`` or ``false``).
435+
"""
436+
package = self.get_object()
437+
438+
# Build a versionless PURL string to match against DependentPackage.purl.
439+
# Dependencies often store version ranges or no version, so we match
440+
# on the package type, namespace, and name.
441+
# A PURL after the name can only have "@version" or nothing, so we
442+
# match the exact name with either end-of-string or "@" following it.
443+
purl_prefix = f"pkg:{package.type}"
444+
if package.namespace:
445+
purl_prefix += f"/{package.namespace}"
446+
purl_prefix += f"/{package.name}"
447+
448+
dep_qs = DependentPackage.objects.filter(
449+
Q(purl=purl_prefix) | Q(purl__startswith=f"{purl_prefix}@")
450+
)
451+
452+
# Apply optional filters.
453+
scope = request.query_params.get("scope")
454+
if scope:
455+
dep_qs = dep_qs.filter(scope=scope)
456+
457+
is_runtime = request.query_params.get("is_runtime")
458+
if is_runtime is not None:
459+
dep_qs = dep_qs.filter(is_runtime=is_runtime.lower() == "true")
460+
461+
is_optional = request.query_params.get("is_optional")
462+
if is_optional is not None:
463+
dep_qs = dep_qs.filter(is_optional=is_optional.lower() == "true")
464+
465+
# Get the distinct parent packages that declare these dependencies.
466+
package_ids = dep_qs.values_list("package_id", flat=True).distinct()
467+
qs = Package.objects.filter(id__in=package_ids).prefetch_related(
468+
"dependencies", "parties"
469+
)
470+
471+
paginated_qs = self.paginate_queryset(qs)
472+
serializer = PackageAPISerializer(
473+
paginated_qs, many=True, context={"request": request}
474+
)
475+
return self.get_paginated_response(serializer.data)
476+
417477
@action(detail=False, methods=["post"])
418478
def filter_by_checksums(self, request, *args, **kwargs):
419479
"""

packagedb/tests/test_api.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,181 @@ def test_package_api_filter_by_checksums(self):
540540
self.assertEqual(expected_status, response.data["status"])
541541

542542

543+
class PackageApiDependentsTestCase(TestCase):
544+
def setUp(self):
545+
self.client = APIClient()
546+
547+
# Target package: the package we want to find dependents of.
548+
self.target_package = Package.objects.create(
549+
download_url="https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
550+
type="npm",
551+
name="lodash",
552+
version="4.17.21",
553+
)
554+
self.target_package.refresh_from_db()
555+
556+
# Package A depends on the target package.
557+
self.package_a = Package.objects.create(
558+
download_url="https://registry.npmjs.org/express/-/express-4.18.2.tgz",
559+
type="npm",
560+
name="express",
561+
version="4.18.2",
562+
)
563+
self.package_a.refresh_from_db()
564+
from packagedb.models import DependentPackage
565+
566+
DependentPackage.objects.create(
567+
package=self.package_a,
568+
purl="pkg:npm/lodash@>=4.0.0",
569+
scope="runtime",
570+
is_runtime=True,
571+
is_optional=False,
572+
)
573+
574+
# Package B also depends on the target package (as optional).
575+
self.package_b = Package.objects.create(
576+
download_url="https://registry.npmjs.org/webpack/-/webpack-5.88.0.tgz",
577+
type="npm",
578+
name="webpack",
579+
version="5.88.0",
580+
)
581+
self.package_b.refresh_from_db()
582+
DependentPackage.objects.create(
583+
package=self.package_b,
584+
purl="pkg:npm/lodash",
585+
scope="develop",
586+
is_runtime=False,
587+
is_optional=True,
588+
)
589+
590+
# Package C does NOT depend on the target package.
591+
self.package_c = Package.objects.create(
592+
download_url="https://registry.npmjs.org/react/-/react-18.2.0.tgz",
593+
type="npm",
594+
name="react",
595+
version="18.2.0",
596+
)
597+
self.package_c.refresh_from_db()
598+
DependentPackage.objects.create(
599+
package=self.package_c,
600+
purl="pkg:npm/scheduler@>=0.20.0",
601+
scope="runtime",
602+
is_runtime=True,
603+
is_optional=False,
604+
)
605+
606+
def test_api_package_dependents_action(self):
607+
response = self.client.get(
608+
reverse("api:package-dependents", args=[self.target_package.uuid])
609+
)
610+
self.assertEqual(response.status_code, status.HTTP_200_OK)
611+
self.assertEqual(2, response.data["count"])
612+
result_purls = {r["purl"] for r in response.data["results"]}
613+
self.assertIn(self.package_a.purl, result_purls)
614+
self.assertIn(self.package_b.purl, result_purls)
615+
self.assertNotIn(self.package_c.purl, result_purls)
616+
617+
def test_api_package_dependents_filter_by_scope(self):
618+
response = self.client.get(
619+
reverse("api:package-dependents", args=[self.target_package.uuid]),
620+
{"scope": "runtime"},
621+
)
622+
self.assertEqual(response.status_code, status.HTTP_200_OK)
623+
self.assertEqual(1, response.data["count"])
624+
self.assertEqual(self.package_a.purl, response.data["results"][0]["purl"])
625+
626+
def test_api_package_dependents_filter_by_is_runtime(self):
627+
response = self.client.get(
628+
reverse("api:package-dependents", args=[self.target_package.uuid]),
629+
{"is_runtime": "false"},
630+
)
631+
self.assertEqual(response.status_code, status.HTTP_200_OK)
632+
self.assertEqual(1, response.data["count"])
633+
self.assertEqual(self.package_b.purl, response.data["results"][0]["purl"])
634+
635+
def test_api_package_dependents_filter_by_is_optional(self):
636+
response = self.client.get(
637+
reverse("api:package-dependents", args=[self.target_package.uuid]),
638+
{"is_optional": "true"},
639+
)
640+
self.assertEqual(response.status_code, status.HTTP_200_OK)
641+
self.assertEqual(1, response.data["count"])
642+
self.assertEqual(self.package_b.purl, response.data["results"][0]["purl"])
643+
644+
def test_api_package_dependents_empty_results(self):
645+
# react has no dependents
646+
response = self.client.get(
647+
reverse("api:package-dependents", args=[self.package_c.uuid])
648+
)
649+
self.assertEqual(response.status_code, status.HTTP_200_OK)
650+
self.assertEqual(0, response.data["count"])
651+
652+
def test_api_package_dependents_with_namespace(self):
653+
"""Test that dependents lookup works correctly for packages with namespaces."""
654+
namespaced_package = Package.objects.create(
655+
download_url="https://repo1.maven.org/org/apache/commons/commons-lang3-3.12.jar",
656+
type="maven",
657+
namespace="org.apache.commons",
658+
name="commons-lang3",
659+
version="3.12.0",
660+
)
661+
namespaced_package.refresh_from_db()
662+
663+
dependent = Package.objects.create(
664+
download_url="https://repo1.maven.org/org/example/myapp-1.0.jar",
665+
type="maven",
666+
namespace="org.example",
667+
name="myapp",
668+
version="1.0",
669+
)
670+
dependent.refresh_from_db()
671+
from packagedb.models import DependentPackage
672+
673+
DependentPackage.objects.create(
674+
package=dependent,
675+
purl="pkg:maven/org.apache.commons/commons-lang3@>=3.0",
676+
scope="compile",
677+
is_runtime=True,
678+
is_optional=False,
679+
)
680+
681+
response = self.client.get(
682+
reverse("api:package-dependents", args=[namespaced_package.uuid])
683+
)
684+
self.assertEqual(response.status_code, status.HTTP_200_OK)
685+
self.assertEqual(1, response.data["count"])
686+
self.assertEqual(dependent.purl, response.data["results"][0]["purl"])
687+
688+
def test_api_package_dependents_no_false_positive_on_similar_names(self):
689+
"""Test that 'lodash' does not match 'lodash-es' dependencies."""
690+
from packagedb.models import DependentPackage
691+
692+
# Package D depends on lodash-es (not lodash).
693+
package_d = Package.objects.create(
694+
download_url="https://registry.npmjs.org/some-pkg/-/some-pkg-1.0.0.tgz",
695+
type="npm",
696+
name="some-pkg",
697+
version="1.0.0",
698+
)
699+
DependentPackage.objects.create(
700+
package=package_d,
701+
purl="pkg:npm/lodash-es@4.17.21",
702+
scope="runtime",
703+
is_runtime=True,
704+
is_optional=False,
705+
)
706+
707+
response = self.client.get(
708+
reverse("api:package-dependents", args=[self.target_package.uuid])
709+
)
710+
self.assertEqual(response.status_code, status.HTTP_200_OK)
711+
result_purls = {r["purl"] for r in response.data["results"]}
712+
# Should NOT include package_d which depends on lodash-es.
713+
self.assertNotIn(package_d.purl, result_purls)
714+
# Should still include the two legitimate dependents.
715+
self.assertEqual(2, response.data["count"])
716+
717+
543718
class PackageApiReindexingTestCase(JsonBasedTesting, TestCase):
544719
test_data_dir = os.path.join(os.path.dirname(__file__), "testfiles")
545720

0 commit comments

Comments
 (0)