Repository: xanzy/go-gitlab
Pull request #2063 (2024-11-19 03:15:07)

feat: add dependency list export client service
This PR adds support for the GitLab Dependency List Export API:
https://docs.gitlab.com/ee/api/dependency_list_export.html



lmphil: @timofurrer Done and tested. Thanks for the feedback. 🏓
timofurrer: I've actually applied the patch myself and will merge :)


Patch:
diff --git a/README.md b/README.md
index fa5a049..6038bed 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ to add new and/or missing endpoints. Currently, the following services are suppo
 - [x] Commits
 - [x] Container Registry
 - [x] Custom Attributes
+- [x] Dependency List Export
 - [x] Deploy Keys
 - [x] Deployments
 - [x] Discussions (threaded comments)
diff --git a/dependency_list_export.go b/dependency_list_export.go
new file mode 100644
index 0000000..c1e786e
--- /dev/null
+++ b/dependency_list_export.go
@@ -0,0 +1,122 @@
+package gitlab
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+type DependencyListExportService struct {
+	client *Client
+}
+
+// CreateDependencyListExportOptions represents the available CreateDependencyListExport()
+// options.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export
+type CreateDependencyListExportOptions struct {
+	ExportType *string `url:"export_type" json:"export_type"`
+}
+
+// DependencyListExport represents a request for a GitLab project's dependency list.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export
+type DependencyListExport struct {
+	ID          int    `json:"id"`
+	HasFinished bool   `json:"has_finished"`
+	Self        string `json:"self"`
+	Download    string `json:"download"`
+}
+
+const defaultExportType = "sbom"
+
+// CreateDependencyListExport creates a new CycloneDX JSON export for all the project dependencies
+// detected in a pipeline.
+//
+// If an authenticated user does not have permission to read_dependency, this request returns a 403
+// Forbidden status code.
+//
+// SBOM exports can be only accessed by the export’s author.
+//
+// GitLab docs:
+// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export
+func (s *DependencyListExportService) CreateDependencyListExport(pipelineID int, opt *CreateDependencyListExportOptions, options ...RequestOptionFunc) (*DependencyListExport, *Response, error) {
+	// POST /pipelines/:id/dependency_list_exports
+	createExportPath := fmt.Sprintf("pipelines/%d/dependency_list_exports", pipelineID)
+
+	if opt == nil {
+		opt = &CreateDependencyListExportOptions{}
+	}
+	if opt.ExportType == nil {
+		opt.ExportType = Ptr(defaultExportType)
+	}
+
+	req, err := s.client.NewRequest(http.MethodPost, createExportPath, opt, options)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	export := new(DependencyListExport)
+	resp, err := s.client.Do(req, &export)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return export, resp, nil
+}
+
+// GetDependencyListExport gets metadata about a single dependency list export.
+//
+// GitLab docs:
+// https://docs.gitlab.com/ee/api/dependency_list_export.html#get-single-dependency-list-export
+func (s *DependencyListExportService) GetDependencyListExport(id int, options ...RequestOptionFunc) (*DependencyListExport, *Response, error) {
+	// GET /dependency_list_exports/:id
+	getExportPath := fmt.Sprintf("dependency_list_exports/%d", id)
+
+	req, err := s.client.NewRequest(http.MethodGet, getExportPath, nil, options)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	export := new(DependencyListExport)
+	resp, err := s.client.Do(req, &export)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return export, resp, nil
+}
+
+// DownloadDependencyListExport downloads a single dependency list export.
+//
+// The github.com/CycloneDX/cyclonedx-go package can be used to parse the data from the returned io.Reader.
+//
+//	sbom := new(cdx.BOM)
+//	decoder := cdx.NewBOMDecoder(reader, cdx.BOMFileFormatJSON)
+//
+//	if err = decoder.Decode(sbom); err != nil {
+//		panic(err)
+//	}
+//
+// GitLab docs:
+// https://docs.gitlab.com/ee/api/dependency_list_export.html#download-dependency-list-export
+func (s *DependencyListExportService) DownloadDependencyListExport(id int, options ...RequestOptionFunc) (io.Reader, *Response, error) {
+	// GET /dependency_list_exports/:id/download
+	downloadExportPath := fmt.Sprintf("dependency_list_exports/%d/download", id)
+
+	req, err := s.client.NewRequest(http.MethodGet, downloadExportPath, nil, options)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var sbomBuffer bytes.Buffer
+	resp, err := s.client.Do(req, &sbomBuffer)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return &sbomBuffer, resp, nil
+}
diff --git a/gitlab.go b/gitlab.go
index 19ed3ea..31b62bd 100644
--- a/gitlab.go
+++ b/gitlab.go
@@ -122,6 +122,7 @@ type Client struct {
 	Commits                      *CommitsService
 	ContainerRegistry            *ContainerRegistryService
 	CustomAttribute              *CustomAttributesService
+	DependencyListExport         *DependencyListExportService
 	DeployKeys                   *DeployKeysService
 	DeployTokens                 *DeployTokensService
 	DeploymentMergeRequests      *DeploymentMergeRequestsService
@@ -360,6 +361,7 @@ func newClient(options ...ClientOptionFunc) (*Client, error) {
 	c.Commits = &CommitsService{client: c}
 	c.ContainerRegistry = &ContainerRegistryService{client: c}
 	c.CustomAttribute = &CustomAttributesService{client: c}
+	c.DependencyListExport = &DependencyListExportService{client: c}
 	c.DeployKeys = &DeployKeysService{client: c}
 	c.DeployTokens = &DeployTokensService{client: c}
 	c.DeploymentMergeRequests = &DeploymentMergeRequestsService{client: c}


Test patch:
diff --git a/dependency_list_export_test.go b/dependency_list_export_test.go
new file mode 100644
index 0000000..c892c4c
--- /dev/null
+++ b/dependency_list_export_test.go
@@ -0,0 +1,85 @@
+package gitlab
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateDependencyListExport(t *testing.T) {
+	mux, client := setup(t)
+
+	mux.HandleFunc("/api/v4/pipelines/1234/dependency_list_exports", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		body, err := io.ReadAll(r.Body)
+		require.NoError(t, err)
+
+		var content CreateDependencyListExportOptions
+		err = json.Unmarshal(body, &content)
+		require.NoError(t, err)
+
+		assert.Equal(t, "sbom", *content.ExportType)
+		mustWriteHTTPResponse(t, w, "testdata/create_dependency_list_export.json")
+	})
+
+	d := &CreateDependencyListExportOptions{
+		ExportType: Ptr("sbom"),
+	}
+
+	export, _, err := client.DependencyListExport.CreateDependencyListExport(1234, d)
+	require.NoError(t, err)
+
+	want := &DependencyListExport{
+		ID:          5678,
+		HasFinished: false,
+		Self:        "http://gitlab.example.com/api/v4/dependency_list_exports/5678",
+		Download:    "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download",
+	}
+	require.Equal(t, want, export)
+}
+
+func TestGetDependencyListExport(t *testing.T) {
+	mux, client := setup(t)
+
+	mux.HandleFunc("/api/v4/dependency_list_exports/5678", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodGet)
+		mustWriteHTTPResponse(t, w, "testdata/get_dependency_list_export.json")
+	})
+
+	export, _, err := client.DependencyListExport.GetDependencyListExport(5678)
+	require.NoError(t, err)
+
+	want := &DependencyListExport{
+		ID:          5678,
+		HasFinished: true,
+		Self:        "http://gitlab.example.com/api/v4/dependency_list_exports/5678",
+		Download:    "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download",
+	}
+	require.Equal(t, want, export)
+}
+
+func TestDownloadDependencyListExport(t *testing.T) {
+	mux, client := setup(t)
+
+	mux.HandleFunc("/api/v4/dependency_list_exports/5678/download", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodGet)
+		mustWriteHTTPResponse(t, w, "testdata/download_dependency_list_export.json")
+	})
+
+	sbomReader, _, err := client.DependencyListExport.DownloadDependencyListExport(5678)
+	require.NoError(t, err)
+
+	expectedSbom, err := os.ReadFile("testdata/download_dependency_list_export.json")
+	require.NoError(t, err)
+
+	var want bytes.Buffer
+	want.Write(expectedSbom)
+
+	require.Equal(t, &want, sbomReader)
+}
diff --git a/testdata/create_dependency_list_export.json b/testdata/create_dependency_list_export.json
new file mode 100644
index 0000000..7b55e47
--- /dev/null
+++ b/testdata/create_dependency_list_export.json
@@ -0,0 +1,6 @@
+{
+  "id": 5678,
+  "has_finished": false,
+  "self": "http://gitlab.example.com/api/v4/dependency_list_exports/5678",
+  "download": "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download"
+}
diff --git a/testdata/download_dependency_list_export.json b/testdata/download_dependency_list_export.json
new file mode 100644
index 0000000..49eccc3
--- /dev/null
+++ b/testdata/download_dependency_list_export.json
@@ -0,0 +1,31 @@
+{
+  "bomFormat": "CycloneDX",
+  "specVersion": "1.4",
+  "serialNumber": "urn:uuid:3fa3b1c2-7e21-4dae-917b-b320f6d25ae1",
+  "version": 1,
+  "metadata": {
+    "timestamp": "2024-11-14T23:39:16.117Z",
+    "authors": [{ "name": "GitLab", "email": "support@gitlab.com" }],
+    "properties": [
+      {
+        "name": "gitlab:dependency_scanning:input_file:path",
+        "value": "my_package_manager.lock"
+      },
+      {
+        "name": "gitlab:dependency_scanning:package_manager:name",
+        "value": "my_package_manager"
+      },
+      { "name": "gitlab:meta:schema_version", "value": "1" }
+    ],
+    "tools": [{ "vendor": "GitLab", "name": "Gemnasium", "version": "5.8.0" }]
+  },
+  "components": [
+    {
+      "name": "dummy",
+      "version": "1.0.0",
+      "purl": "pkg:testing/dummy@1.0.0",
+      "type": "library",
+      "licenses": [{ "license": { "name": "unknown" } }]
+    }
+  ]
+}
diff --git a/testdata/get_dependency_list_export.json b/testdata/get_dependency_list_export.json
new file mode 100644
index 0000000..58a3b2f
--- /dev/null
+++ b/testdata/get_dependency_list_export.json
@@ -0,0 +1,6 @@
+{
+  "id": 5678,
+  "has_finished": true,
+  "self": "http://gitlab.example.com/api/v4/dependency_list_exports/5678",
+  "download": "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download"
+}