diff --git a/packagedb/api.py b/packagedb/api.py index eb8986bb..a04e3800 100644 --- a/packagedb/api.py +++ b/packagedb/api.py @@ -49,6 +49,7 @@ from minecode.models import PriorityResourceURI from minecode.route import NoRouteAvailable from packagedb.filters import PackageSearchFilter +from packagedb.models import DependentPackage from packagedb.models import Package from packagedb.models import PackageActivity from packagedb.models import PackageContentType @@ -414,6 +415,65 @@ def get_enhanced_package_data(self, request, *args, **kwargs): package_data = get_enhanced_package(package) return Response(package_data) + @action(detail=True, methods=["get"]) + def dependents(self, request, *args, **kwargs): + """ + Return Packages that depend on the current Package. + + This finds all DependentPackage entries whose ``purl`` references + the current Package (matched by type, namespace, and name), and + returns the parent packages that declare those dependencies. + + Optional query parameters for filtering: + + - ``scope``: filter by dependency scope (e.g., ``runtime``, + ``install``, ``develop``). + - ``is_runtime``: filter by runtime dependency flag + (``true`` or ``false``). + - ``is_optional``: filter by optional dependency flag + (``true`` or ``false``). + """ + package = self.get_object() + + # Build a versionless PURL string to match against DependentPackage.purl. + # Dependencies often store version ranges or no version, so we match + # on the package type, namespace, and name. + # A PURL after the name can only have "@version" or nothing, so we + # match the exact name with either end-of-string or "@" following it. + purl_prefix = f"pkg:{package.type}" + if package.namespace: + purl_prefix += f"/{package.namespace}" + purl_prefix += f"/{package.name}" + + dep_qs = DependentPackage.objects.filter( + Q(purl=purl_prefix) | Q(purl__startswith=f"{purl_prefix}@") + ) + + # Apply optional filters. + scope = request.query_params.get("scope") + if scope: + dep_qs = dep_qs.filter(scope=scope) + + is_runtime = request.query_params.get("is_runtime") + if is_runtime is not None: + dep_qs = dep_qs.filter(is_runtime=is_runtime.lower() == "true") + + is_optional = request.query_params.get("is_optional") + if is_optional is not None: + dep_qs = dep_qs.filter(is_optional=is_optional.lower() == "true") + + # Get the distinct parent packages that declare these dependencies. + package_ids = dep_qs.values_list("package_id", flat=True).distinct() + qs = Package.objects.filter(id__in=package_ids).prefetch_related( + "dependencies", "parties" + ) + + paginated_qs = self.paginate_queryset(qs) + serializer = PackageAPISerializer( + paginated_qs, many=True, context={"request": request} + ) + return self.get_paginated_response(serializer.data) + @action(detail=False, methods=["post"]) def filter_by_checksums(self, request, *args, **kwargs): """ diff --git a/packagedb/tests/test_api.py b/packagedb/tests/test_api.py index 7d7fe7ae..5442c367 100644 --- a/packagedb/tests/test_api.py +++ b/packagedb/tests/test_api.py @@ -540,6 +540,181 @@ def test_package_api_filter_by_checksums(self): self.assertEqual(expected_status, response.data["status"]) +class PackageApiDependentsTestCase(TestCase): + def setUp(self): + self.client = APIClient() + + # Target package: the package we want to find dependents of. + self.target_package = Package.objects.create( + download_url="https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + type="npm", + name="lodash", + version="4.17.21", + ) + self.target_package.refresh_from_db() + + # Package A depends on the target package. + self.package_a = Package.objects.create( + download_url="https://registry.npmjs.org/express/-/express-4.18.2.tgz", + type="npm", + name="express", + version="4.18.2", + ) + self.package_a.refresh_from_db() + from packagedb.models import DependentPackage + + DependentPackage.objects.create( + package=self.package_a, + purl="pkg:npm/lodash@>=4.0.0", + scope="runtime", + is_runtime=True, + is_optional=False, + ) + + # Package B also depends on the target package (as optional). + self.package_b = Package.objects.create( + download_url="https://registry.npmjs.org/webpack/-/webpack-5.88.0.tgz", + type="npm", + name="webpack", + version="5.88.0", + ) + self.package_b.refresh_from_db() + DependentPackage.objects.create( + package=self.package_b, + purl="pkg:npm/lodash", + scope="develop", + is_runtime=False, + is_optional=True, + ) + + # Package C does NOT depend on the target package. + self.package_c = Package.objects.create( + download_url="https://registry.npmjs.org/react/-/react-18.2.0.tgz", + type="npm", + name="react", + version="18.2.0", + ) + self.package_c.refresh_from_db() + DependentPackage.objects.create( + package=self.package_c, + purl="pkg:npm/scheduler@>=0.20.0", + scope="runtime", + is_runtime=True, + is_optional=False, + ) + + def test_api_package_dependents_action(self): + response = self.client.get( + reverse("api:package-dependents", args=[self.target_package.uuid]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, response.data["count"]) + result_purls = {r["purl"] for r in response.data["results"]} + self.assertIn(self.package_a.purl, result_purls) + self.assertIn(self.package_b.purl, result_purls) + self.assertNotIn(self.package_c.purl, result_purls) + + def test_api_package_dependents_filter_by_scope(self): + response = self.client.get( + reverse("api:package-dependents", args=[self.target_package.uuid]), + {"scope": "runtime"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, response.data["count"]) + self.assertEqual(self.package_a.purl, response.data["results"][0]["purl"]) + + def test_api_package_dependents_filter_by_is_runtime(self): + response = self.client.get( + reverse("api:package-dependents", args=[self.target_package.uuid]), + {"is_runtime": "false"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, response.data["count"]) + self.assertEqual(self.package_b.purl, response.data["results"][0]["purl"]) + + def test_api_package_dependents_filter_by_is_optional(self): + response = self.client.get( + reverse("api:package-dependents", args=[self.target_package.uuid]), + {"is_optional": "true"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, response.data["count"]) + self.assertEqual(self.package_b.purl, response.data["results"][0]["purl"]) + + def test_api_package_dependents_empty_results(self): + # react has no dependents + response = self.client.get( + reverse("api:package-dependents", args=[self.package_c.uuid]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, response.data["count"]) + + def test_api_package_dependents_with_namespace(self): + """Test that dependents lookup works correctly for packages with namespaces.""" + namespaced_package = Package.objects.create( + download_url="https://repo1.maven.org/org/apache/commons/commons-lang3-3.12.jar", + type="maven", + namespace="org.apache.commons", + name="commons-lang3", + version="3.12.0", + ) + namespaced_package.refresh_from_db() + + dependent = Package.objects.create( + download_url="https://repo1.maven.org/org/example/myapp-1.0.jar", + type="maven", + namespace="org.example", + name="myapp", + version="1.0", + ) + dependent.refresh_from_db() + from packagedb.models import DependentPackage + + DependentPackage.objects.create( + package=dependent, + purl="pkg:maven/org.apache.commons/commons-lang3@>=3.0", + scope="compile", + is_runtime=True, + is_optional=False, + ) + + response = self.client.get( + reverse("api:package-dependents", args=[namespaced_package.uuid]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, response.data["count"]) + self.assertEqual(dependent.purl, response.data["results"][0]["purl"]) + + def test_api_package_dependents_no_false_positive_on_similar_names(self): + """Test that 'lodash' does not match 'lodash-es' dependencies.""" + from packagedb.models import DependentPackage + + # Package D depends on lodash-es (not lodash). + package_d = Package.objects.create( + download_url="https://registry.npmjs.org/some-pkg/-/some-pkg-1.0.0.tgz", + type="npm", + name="some-pkg", + version="1.0.0", + ) + DependentPackage.objects.create( + package=package_d, + purl="pkg:npm/lodash-es@4.17.21", + scope="runtime", + is_runtime=True, + is_optional=False, + ) + + response = self.client.get( + reverse("api:package-dependents", args=[self.target_package.uuid]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_purls = {r["purl"] for r in response.data["results"]} + # Should NOT include package_d which depends on lodash-es. + self.assertNotIn(package_d.purl, result_purls) + # Should still include the two legitimate dependents. + self.assertEqual(2, response.data["count"]) + + class PackageApiReindexingTestCase(JsonBasedTesting, TestCase): test_data_dir = os.path.join(os.path.dirname(__file__), "testfiles")