Skip to content

Commit b8a7882

Browse files
Merge pull request #46 from snyk/osf-79-file-upload
feat: add high level fileupload client
2 parents a4de039 + 23e5dcf commit b8a7882

File tree

8 files changed

+852
-1
lines changed

8 files changed

+852
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
### Local development ###
55
# Local build system configuration
66
/local.mk
7+
.idea
78

89
node_modules

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/google/uuid v1.6.0
1212
github.com/muesli/termenv v0.15.2
1313
github.com/oapi-codegen/runtime v1.1.1
14+
github.com/puzpuzpuz/xsync v1.5.2
1415
github.com/rs/zerolog v1.34.0
1516
github.com/snyk/code-client-go v1.21.3
1617
github.com/snyk/error-catalog-golang-public v0.0.0-20250625135845-2d6f9a31f318
@@ -81,7 +82,6 @@ require (
8182
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
8283
github.com/pkg/errors v0.9.1 // indirect
8384
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
84-
github.com/puzpuzpuz/xsync v1.5.2 // indirect
8585
github.com/rivo/uniseg v0.4.7 // indirect
8686
github.com/rogpeppe/go-internal v1.14.1 // indirect
8787
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect

internal/fileupload/client.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package fileupload
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"runtime"
10+
11+
"github.com/google/uuid"
12+
"github.com/puzpuzpuz/xsync"
13+
14+
listsources "github.com/snyk/cli-extension-os-flows/internal/files"
15+
"github.com/snyk/cli-extension-os-flows/internal/fileupload/filters"
16+
"github.com/snyk/cli-extension-os-flows/internal/fileupload/uploadrevision"
17+
)
18+
19+
// Config contains configuration for the file upload client.
20+
type Config struct {
21+
BaseURL string
22+
OrgID OrgID
23+
}
24+
25+
// HTTPClient provides high-level file upload functionality.
26+
type HTTPClient struct {
27+
uploadRevisionSealableClient uploadrevision.SealableClient
28+
filtersClient filters.Client
29+
cfg Config
30+
filters Filters
31+
}
32+
33+
// Client defines the interface for the high level file upload client.
34+
type Client interface {
35+
CreateRevisionFromPaths(ctx context.Context, paths []string, opts UploadOptions) (RevisionID, error)
36+
CreateRevisionFromDir(ctx context.Context, dirPath string, opts UploadOptions) (RevisionID, error)
37+
CreateRevisionFromFile(ctx context.Context, filePath string, opts UploadOptions) (RevisionID, error)
38+
}
39+
40+
// NewClient creates a new high-level file upload client.
41+
func NewClient(httpClient *http.Client, cfg Config, opts ...Option) *HTTPClient {
42+
client := &HTTPClient{
43+
cfg: cfg,
44+
filters: Filters{
45+
supportedExtensions: xsync.NewMapOf[bool](),
46+
supportedConfigFiles: xsync.NewMapOf[bool](),
47+
},
48+
}
49+
50+
for _, opt := range opts {
51+
opt(client)
52+
}
53+
54+
if client.uploadRevisionSealableClient == nil {
55+
client.uploadRevisionSealableClient = uploadrevision.NewClient(uploadrevision.Config{
56+
BaseURL: cfg.BaseURL,
57+
}, uploadrevision.WithHTTPClient(httpClient))
58+
}
59+
60+
if client.filtersClient == nil {
61+
client.filtersClient = filters.NewDeeproxyClient(filters.Config{
62+
BaseURL: cfg.BaseURL,
63+
}, filters.WithHTTPClient(httpClient))
64+
}
65+
66+
return client
67+
}
68+
69+
func (c *HTTPClient) loadFilters(ctx context.Context) error {
70+
c.filters.once.Do(func() {
71+
filtersResp, err := c.filtersClient.GetFilters(ctx, c.cfg.OrgID)
72+
if err != nil {
73+
c.filters.initErr = err
74+
return
75+
}
76+
77+
for _, ext := range filtersResp.Extensions {
78+
c.filters.supportedExtensions.Store(ext, true)
79+
}
80+
for _, configFile := range filtersResp.ConfigFiles {
81+
// .gitignore and .dcignore should not be uploaded
82+
// (https://github.com/snyk/code-client/blob/d6f6a2ce4c14cb4b05aa03fb9f03533d8cf6ca4a/src/files.ts#L138)
83+
if configFile == ".gitignore" || configFile == ".dcignore" {
84+
continue
85+
}
86+
c.filters.supportedConfigFiles.Store(configFile, true)
87+
}
88+
})
89+
return c.filters.initErr
90+
}
91+
92+
// createFileFilter creates a filter function based on the current filtering configuration.
93+
func (c *HTTPClient) createFileFilter(ctx context.Context) (func(string) bool, error) {
94+
if err := c.loadFilters(ctx); err != nil {
95+
return nil, fmt.Errorf("failed to load deeproxy filters: %w", err)
96+
}
97+
98+
return func(path string) bool {
99+
fileExt := filepath.Ext(path)
100+
fileName := filepath.Base(path)
101+
_, isSupportedExtension := c.filters.supportedExtensions.Load(fileExt)
102+
_, isSupportedConfigFile := c.filters.supportedConfigFiles.Load(fileName)
103+
return isSupportedExtension || isSupportedConfigFile
104+
}, nil
105+
}
106+
107+
func (c *HTTPClient) uploadPaths(ctx context.Context, revID RevisionID, rootPath string, paths []string) error {
108+
files := make([]uploadrevision.UploadFile, 0, c.uploadRevisionSealableClient.GetLimits().FileCountLimit)
109+
defer func() {
110+
for _, file := range files {
111+
file.File.Close()
112+
}
113+
}()
114+
115+
for _, pth := range paths {
116+
relPth, err := filepath.Rel(rootPath, pth)
117+
if err != nil {
118+
return fmt.Errorf("failed to get relative path for %s: %w", pth, err)
119+
}
120+
121+
f, err := os.Open(pth)
122+
if err != nil {
123+
return fmt.Errorf("failed to open file %s: %w", pth, err)
124+
}
125+
126+
fstat, err := f.Stat()
127+
if err != nil {
128+
return fmt.Errorf("failed to stat file %s: %w", pth, err)
129+
}
130+
131+
// TODO: This behavior should be configurable through options.
132+
if fstat.Size() > c.uploadRevisionSealableClient.GetLimits().FileSizeLimit {
133+
f.Close()
134+
//nolint:forbidigo // Temporarily use fmt to print warning.
135+
fmt.Printf("skipping file exceeding size limit %s\n", pth)
136+
continue
137+
}
138+
139+
files = append(files, uploadrevision.UploadFile{
140+
Path: relPth,
141+
File: f,
142+
})
143+
}
144+
145+
err := c.uploadRevisionSealableClient.UploadFiles(ctx, c.cfg.OrgID, revID, files)
146+
if err != nil {
147+
return fmt.Errorf("failed to upload files: %w", err)
148+
}
149+
150+
return nil
151+
}
152+
153+
// addPathsToRevision adds multiple file paths to an existing revision.
154+
func (c *HTTPClient) addPathsToRevision(ctx context.Context, revisionID RevisionID, rootPath string, pathsChan <-chan string, opts UploadOptions) error {
155+
var chunks <-chan []string
156+
157+
if opts.SkipFiltering {
158+
chunks = chunkChan(pathsChan, c.uploadRevisionSealableClient.GetLimits().FileCountLimit)
159+
} else {
160+
filter, err := c.createFileFilter(ctx)
161+
if err != nil {
162+
return err
163+
}
164+
chunks = chunkChanFiltered(pathsChan, c.uploadRevisionSealableClient.GetLimits().FileCountLimit, filter)
165+
}
166+
167+
for chunk := range chunks {
168+
err := c.uploadPaths(ctx, revisionID, rootPath, chunk)
169+
if err != nil {
170+
return err
171+
}
172+
}
173+
174+
return nil
175+
}
176+
177+
// createRevision creates a new revision and returns its ID.
178+
func (c *HTTPClient) createRevision(ctx context.Context) (RevisionID, error) {
179+
revision, err := c.uploadRevisionSealableClient.CreateRevision(ctx, c.cfg.OrgID)
180+
if err != nil {
181+
return uuid.Nil, fmt.Errorf("failed to create revision: %w", err)
182+
}
183+
return revision.Data.ID, nil
184+
}
185+
186+
// addFileToRevision adds a single file to an existing revision.
187+
func (c *HTTPClient) addFileToRevision(ctx context.Context, revisionID RevisionID, filePath string, opts UploadOptions) error {
188+
writableChan := make(chan string, 1)
189+
writableChan <- filePath
190+
close(writableChan)
191+
192+
return c.addPathsToRevision(ctx, revisionID, filepath.Dir(filePath), writableChan, opts)
193+
}
194+
195+
// addDirToRevision adds a directory and all its contents to an existing revision.
196+
func (c *HTTPClient) addDirToRevision(ctx context.Context, revisionID RevisionID, dirPath string, opts UploadOptions) error {
197+
sources, err := listsources.ForPath(dirPath, nil, runtime.NumCPU())
198+
if err != nil {
199+
return fmt.Errorf("failed to list files in directory %s: %w", dirPath, err)
200+
}
201+
202+
return c.addPathsToRevision(ctx, revisionID, dirPath, sources, opts)
203+
}
204+
205+
// sealRevision seals a revision, making it immutable.
206+
func (c *HTTPClient) sealRevision(ctx context.Context, revisionID RevisionID) error {
207+
_, err := c.uploadRevisionSealableClient.SealRevision(ctx, c.cfg.OrgID, revisionID)
208+
if err != nil {
209+
return fmt.Errorf("failed to seal revision: %w", err)
210+
}
211+
return nil
212+
}
213+
214+
// CreateRevisionFromPaths uploads multiple paths (files or directories), returning a revision ID.
215+
// This is a convenience method that creates, uploads, and seals a revision.
216+
func (c *HTTPClient) CreateRevisionFromPaths(ctx context.Context, paths []string, opts UploadOptions) (RevisionID, error) {
217+
revisionID, err := c.createRevision(ctx)
218+
if err != nil {
219+
return uuid.Nil, err
220+
}
221+
222+
for _, pth := range paths {
223+
info, err := os.Stat(pth)
224+
if err != nil {
225+
return uuid.Nil, fmt.Errorf("failed to stat path %s: %w", pth, err)
226+
}
227+
228+
if info.IsDir() {
229+
if err := c.addDirToRevision(ctx, revisionID, pth, opts); err != nil {
230+
return uuid.Nil, fmt.Errorf("failed to add directory %s: %w", pth, err)
231+
}
232+
} else {
233+
if err := c.addFileToRevision(ctx, revisionID, pth, opts); err != nil {
234+
return uuid.Nil, fmt.Errorf("failed to add file %s: %w", pth, err)
235+
}
236+
}
237+
}
238+
239+
if err := c.sealRevision(ctx, revisionID); err != nil {
240+
return uuid.Nil, err
241+
}
242+
243+
return revisionID, nil
244+
}
245+
246+
// CreateRevisionFromDir uploads a directory and all its contents, returning a revision ID.
247+
// This is a convenience method equivalent to CreateRevisionFromPaths with a single directory.
248+
func (c *HTTPClient) CreateRevisionFromDir(ctx context.Context, dirPath string, opts UploadOptions) (RevisionID, error) {
249+
return c.CreateRevisionFromPaths(ctx, []string{dirPath}, opts)
250+
}
251+
252+
// CreateRevisionFromFile uploads a single file, returning a revision ID.
253+
// This is a convenience method equivalent to CreateRevisionFromPaths with a single file.
254+
func (c *HTTPClient) CreateRevisionFromFile(ctx context.Context, filePath string, opts UploadOptions) (RevisionID, error) {
255+
return c.CreateRevisionFromPaths(ctx, []string{filePath}, opts)
256+
}

0 commit comments

Comments
 (0)