Skip to content

Commit

Permalink
Add REST API endpoint to download ABOUT files and SPDX document #60 (#82
Browse files Browse the repository at this point in the history
)

Signed-off-by: tdruez <tdruez@nexb.com>
  • Loading branch information
tdruez authored Apr 24, 2024
1 parent 98eac00 commit f03e7b7
Show file tree
Hide file tree
Showing 11 changed files with 370 additions and 142 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Release notes
"Pull ScanCode.io project data" feature as Product action in the REST API.
https://github.com/nexB/dejacode/issues/59

- Add REST API endpoints to download SBOMs as CycloneDX and SPDX.
https://github.com/nexB/dejacode/issues/60

- Refactor the "Import manifest" feature as "Load SBOMs".
https://github.com/nexB/dejacode/issues/61

Expand Down
4 changes: 4 additions & 0 deletions dejacode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@

import os
import sys
import warnings

VERSION = "5.1.0-dev"
__version__ = VERSION

# Turn off the warnings for the following modules.
warnings.filterwarnings("ignore", module="cyclonedx")


def command_line():
"""Command line entry point."""
Expand Down
137 changes: 137 additions & 0 deletions dje/outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/nexB/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import re

from django.http import FileResponse
from django.http import Http404

from cyclonedx import output as cyclonedx_output
from cyclonedx.model import bom as cyclonedx_bom

from dejacode import __version__ as dejacode_version
from dejacode_toolkit import spdx


def safe_filename(filename):
"""Convert the provided `filename` to a safe filename."""
return re.sub("[^A-Za-z0-9.-]+", "_", filename).lower()


def get_attachment_response(file_content, filename, content_type):
if not file_content or not filename:
raise Http404

response = FileResponse(
file_content,
filename=filename,
content_type=content_type,
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'

return response


def get_spdx_extracted_licenses(spdx_packages):
"""
Return all the licenses to be included in the SPDX extracted_licenses.
Those include the `LicenseRef-` licenses, ie: licenses not available in the
SPDX list.
In the case of Product relationships, ProductComponent and ProductPackage,
the set of licenses of the related object, Component or Package, is used
as the licenses of the relationship is always a subset of the ones of the
related object.
This ensures that we have all the license required for a valid SPDX document.
"""
from product_portfolio.models import ProductRelationshipMixin

all_licenses = set()
for entry in spdx_packages:
if isinstance(entry, ProductRelationshipMixin):
all_licenses.update(entry.related_component_or_package.licenses.all())
else:
all_licenses.update(entry.licenses.all())

return [
license.as_spdx() for license in all_licenses if license.spdx_id.startswith("LicenseRef")
]


def get_spdx_document(instance, user):
spdx_packages = instance.get_spdx_packages()

creation_info = spdx.CreationInfo(
person_name=f"{user.first_name} {user.last_name}",
person_email=user.email,
organization_name=user.dataspace.name,
tool=f"DejaCode-{dejacode_version}",
)

document = spdx.Document(
name=f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}_{instance}",
namespace=f"https://dejacode.com/spdxdocs/{instance.uuid}",
creation_info=creation_info,
packages=[package.as_spdx() for package in spdx_packages],
extracted_licenses=get_spdx_extracted_licenses(spdx_packages),
)

return document


def get_spdx_filename(spdx_document):
document_name = spdx_document.as_dict()["name"]
filename = f"{document_name}.spdx.json"
return safe_filename(filename)


def get_cyclonedx_bom(instance, user):
"""https://cyclonedx.org/use-cases/#dependency-graph"""
cyclonedx_components = []

if hasattr(instance, "get_cyclonedx_components"):
cyclonedx_components = [
component.as_cyclonedx() for component in instance.get_cyclonedx_components()
]

bom = cyclonedx_bom.Bom(components=cyclonedx_components)

cdx_component = instance.as_cyclonedx()
cdx_component.dependencies.update([component.bom_ref for component in cyclonedx_components])

bom.metadata = cyclonedx_bom.BomMetaData(
component=cdx_component,
tools=[
cyclonedx_bom.Tool(
vendor="nexB",
name="DejaCode",
version=dejacode_version,
)
],
authors=[
cyclonedx_bom.OrganizationalContact(
name=f"{user.first_name} {user.last_name}",
)
],
)

return bom


def get_cyclonedx_bom_json(cyclonedx_bom):
outputter = cyclonedx_output.get_instance(
bom=cyclonedx_bom,
output_format=cyclonedx_output.OutputFormat.JSON,
)
return outputter.output_as_string()


def get_cyclonedx_filename(instance):
base_filename = f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}"
filename = f"{base_filename}_{instance}.cdx.json"
return safe_filename(filename)
85 changes: 85 additions & 0 deletions dje/tests/test_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/nexB/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

from django.test import TestCase

from cyclonedx.model import bom as cyclonedx_bom

from dejacode import __version__ as dejacode_version
from dje import outputs
from dje.models import Dataspace
from dje.tests import create_superuser
from dje.tests import create_user
from product_portfolio.models import Product


class OutputsTestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
self.super_user = create_superuser("nexb_user", self.dataspace)
self.basic_user = create_user("basic_user", self.dataspace)

self.product1 = Product.objects.create(
name="Product1 With Space", version="1.0", dataspace=self.dataspace
)

def test_outputs_safe_filename(self):
self.assertEqual("low_-_up_", outputs.safe_filename("low -_UP*&//"))

def test_outputs_get_attachment_response(self):
response = outputs.get_attachment_response(
file_content="AAA", filename="file.txt", content_type="application/json"
)
expected = 'attachment; filename="file.txt"'
self.assertEqual(expected, response["Content-Disposition"])
self.assertEqual("application/json", response["Content-Type"])

def test_outputs_get_spdx_document(self):
document = outputs.get_spdx_document(self.product1, self.super_user)
document.creation_info.created = "2000-01-01T01:02:03Z"
expected = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "dejacode_nexb_product_product1_with_space_1.0",
"documentNamespace": f"https://dejacode.com/spdxdocs/{self.product1.uuid}",
"creationInfo": {
"created": "2000-01-01T01:02:03Z",
"creators": [
"Person: (user@email.com)",
"Organization: nexB ()",
f"Tool: DejaCode-{dejacode_version}",
],
"licenseListVersion": "3.18",
},
"packages": [],
"documentDescribes": [],
}
self.assertEqual(expected, document.as_dict())

def test_outputs_get_spdx_filename(self):
document = outputs.get_spdx_document(self.product1, self.super_user)
self.assertEqual(
"dejacode_nexb_product_product1_with_space_1.0.spdx.json",
outputs.get_spdx_filename(document),
)

def test_outputs_get_cyclonedx_bom(self):
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
self.assertIsInstance(bom, cyclonedx_bom.Bom)

def test_outputs_get_cyclonedx_bom_json(self):
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
bom_json = outputs.get_cyclonedx_bom_json(bom)
self.assertTrue(bom_json.startswith('{"$schema":'))

def test_outputs_get_cyclonedx_filename(self):
self.assertEqual(
"dejacode_nexb_product_product1_with_space_1.0.cdx.json",
outputs.get_cyclonedx_filename(instance=self.product1),
)
Loading

0 comments on commit f03e7b7

Please sign in to comment.