Skip to content

Commit a906715

Browse files
Merge pull request #30 from snyk/feat/low-level-file-upload-client
Feat/low level file upload client
2 parents 1e0b892 + 4f57624 commit a906715

File tree

7 files changed

+1006
-0
lines changed

7 files changed

+1006
-0
lines changed

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ issues:
160160
- forcetypeassert
161161
- goconst
162162
- ireturn
163+
- wrapcheck
163164
- path: internal/view/(.+)_test\.go
164165
linters:
165166
- testpackage
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package lowlevel_fileupload //nolint:revive // underscore naming is intentional for this internal package
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"mime/multipart"
11+
"net/http"
12+
13+
"github.com/google/uuid"
14+
"github.com/snyk/error-catalog-golang-public/snyk_errors"
15+
)
16+
17+
// Client defines the interface for file upload API operations.
18+
type Client interface {
19+
CreateRevision(ctx context.Context, orgID OrgID) (*UploadRevisionResponseBody, error)
20+
UploadFiles(ctx context.Context, orgID OrgID, revisionID RevisionID, files []UploadFile) error
21+
SealRevision(ctx context.Context, orgID OrgID, revisionID RevisionID) (*SealUploadRevisionResponseBody, error)
22+
}
23+
24+
// This will force go to complain if the type doesn't satisfy the interface.
25+
var _ Client = (*HTTPClient)(nil)
26+
27+
// Config contains configuration for the file upload client.
28+
type Config struct {
29+
BaseURL string
30+
}
31+
32+
// HTTPClient implements the Client interface for file upload operations via HTTP API.
33+
type HTTPClient struct {
34+
cfg Config
35+
httpClient *http.Client
36+
}
37+
38+
// APIVersion specifies the API version to use for requests.
39+
const APIVersion = "2024-10-15"
40+
41+
// FileSizeLimit specifies the maximum allowed file size in bytes.
42+
const FileSizeLimit = 50_000_000 // arbitrary number, chosen to support max size of SBOMs
43+
44+
// FileCountLimit specifies the maximum number of files allowed in a single upload.
45+
const FileCountLimit = 100 // arbitrary number, will need to be re-evaluated
46+
47+
// ContentType is the HTTP header name for content type.
48+
const ContentType = "Content-Type"
49+
50+
// NewClient creates a new file upload client with the given configuration and options.
51+
func NewClient(cfg Config, opts ...Opt) *HTTPClient {
52+
c := HTTPClient{cfg, http.DefaultClient}
53+
54+
for _, opt := range opts {
55+
opt(&c)
56+
}
57+
58+
return &c
59+
}
60+
61+
// CreateRevision creates a new upload revision for the specified organization.
62+
func (c *HTTPClient) CreateRevision(ctx context.Context, orgID OrgID) (*UploadRevisionResponseBody, error) {
63+
if orgID == uuid.Nil {
64+
return nil, ErrEmptyOrgID
65+
}
66+
67+
body := UploadRevisionRequestBody{
68+
Data: UploadRevisionRequestData{
69+
Attributes: UploadRevisionRequestAttributes{
70+
RevisionType: RevisionTypeSnapshot,
71+
},
72+
Type: ResourceTypeUploadRevision,
73+
},
74+
}
75+
buff := bytes.NewBuffer(nil)
76+
if err := json.NewEncoder(buff).Encode(body); err != nil {
77+
return nil, fmt.Errorf("failed to encode request body: %w", err)
78+
}
79+
80+
url := fmt.Sprintf("%s/hidden/orgs/%s/upload_revisions?version=%s", c.cfg.BaseURL, orgID, APIVersion)
81+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buff)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to create request body: %w", err)
84+
}
85+
req.Header.Set(ContentType, "application/vnd.api+json")
86+
87+
res, err := c.httpClient.Do(req)
88+
if err != nil {
89+
return nil, fmt.Errorf("error making create revision request: %w", err)
90+
}
91+
defer res.Body.Close()
92+
93+
if res.StatusCode != http.StatusCreated {
94+
return nil, c.handleUnexpectedStatusCodes(res.Body, res.StatusCode, res.Status, "create upload revision")
95+
}
96+
97+
var respBody UploadRevisionResponseBody
98+
if err := json.NewDecoder(res.Body).Decode(&respBody); err != nil {
99+
return nil, fmt.Errorf("failed to decode upload revision response: %w", err)
100+
}
101+
102+
return &respBody, nil
103+
}
104+
105+
// UploadFiles uploads the provided files to the specified revision. It will not close the file descriptors.
106+
func (c *HTTPClient) UploadFiles(ctx context.Context, orgID OrgID, revisionID RevisionID, files []UploadFile) error {
107+
if orgID == uuid.Nil {
108+
return ErrEmptyOrgID
109+
}
110+
111+
if revisionID == uuid.Nil {
112+
return ErrEmptyRevisionID
113+
}
114+
115+
if len(files) > FileCountLimit {
116+
return NewFileCountLimitError(len(files), FileCountLimit)
117+
}
118+
119+
if len(files) == 0 {
120+
return ErrNoFilesProvided
121+
}
122+
123+
for _, file := range files {
124+
fileInfo, err := file.File.Stat()
125+
if err != nil {
126+
return NewFileAccessError(file.Path, err)
127+
}
128+
129+
if fileInfo.IsDir() {
130+
return NewDirectoryError(file.Path)
131+
}
132+
133+
if fileInfo.Size() > FileSizeLimit {
134+
return NewFileSizeLimitError(file.Path, fileInfo.Size(), FileSizeLimit)
135+
}
136+
}
137+
138+
// Create pipe for streaming multipart data
139+
pReader, pWriter := io.Pipe()
140+
mpartWriter := multipart.NewWriter(pWriter)
141+
142+
// Start goroutine to write multipart data
143+
go c.streamFilesToPipe(pWriter, mpartWriter, files)
144+
145+
// Create HTTP request with streaming body
146+
url := fmt.Sprintf("%s/hidden/orgs/%s/upload_revisions/%s/files?version=%s", c.cfg.BaseURL, orgID, revisionID, APIVersion)
147+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pReader)
148+
if err != nil {
149+
pReader.Close()
150+
return fmt.Errorf("failed to create upload files request: %w", err)
151+
}
152+
153+
req.Header.Set(ContentType, mpartWriter.FormDataContentType())
154+
155+
res, err := c.httpClient.Do(req)
156+
if err != nil {
157+
return fmt.Errorf("error making upload files request: %w", err)
158+
}
159+
defer res.Body.Close()
160+
161+
if res.StatusCode != http.StatusNoContent {
162+
return c.handleUnexpectedStatusCodes(res.Body, res.StatusCode, res.Status, "upload files")
163+
}
164+
165+
return nil
166+
}
167+
168+
func (c *HTTPClient) streamFilesToPipe(pWriter *io.PipeWriter, mpartWriter *multipart.Writer, files []UploadFile) {
169+
var streamError error
170+
defer func() {
171+
pWriter.CloseWithError(streamError)
172+
}()
173+
defer mpartWriter.Close()
174+
175+
for _, file := range files {
176+
// Create form file part
177+
part, err := mpartWriter.CreateFormFile(file.Path, file.Path)
178+
if err != nil {
179+
streamError = NewMultipartError(file.Path, err)
180+
return
181+
}
182+
183+
_, err = io.Copy(part, file.File)
184+
if err != nil {
185+
streamError = fmt.Errorf("failed to copy file content for %s: %w", file.Path, err)
186+
return
187+
}
188+
}
189+
}
190+
191+
// SealRevision seals the specified upload revision, marking it as complete.
192+
func (c *HTTPClient) SealRevision(ctx context.Context, orgID OrgID, revisionID RevisionID) (*SealUploadRevisionResponseBody, error) {
193+
if orgID == uuid.Nil {
194+
return nil, ErrEmptyOrgID
195+
}
196+
197+
if revisionID == uuid.Nil {
198+
return nil, ErrEmptyRevisionID
199+
}
200+
201+
body := SealUploadRevisionRequestBody{
202+
Data: SealUploadRevisionRequestData{
203+
ID: revisionID,
204+
Attributes: SealUploadRevisionRequestAttributes{
205+
Sealed: true,
206+
},
207+
Type: ResourceTypeUploadRevision,
208+
},
209+
}
210+
buff := bytes.NewBuffer(nil)
211+
if err := json.NewEncoder(buff).Encode(body); err != nil {
212+
return nil, fmt.Errorf("failed to encode request body: %w", err)
213+
}
214+
215+
url := fmt.Sprintf("%s/hidden/orgs/%s/upload_revisions/%s?version=%s", c.cfg.BaseURL, orgID, revisionID, APIVersion)
216+
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, buff)
217+
if err != nil {
218+
return nil, fmt.Errorf("failed to create request body: %w", err)
219+
}
220+
req.Header.Set(ContentType, "application/vnd.api+json")
221+
222+
res, err := c.httpClient.Do(req)
223+
if err != nil {
224+
return nil, fmt.Errorf("error making seal revision request: %w", err)
225+
}
226+
defer res.Body.Close()
227+
228+
if res.StatusCode != http.StatusOK {
229+
return nil, c.handleUnexpectedStatusCodes(res.Body, res.StatusCode, res.Status, "seal upload revision")
230+
}
231+
232+
var respBody SealUploadRevisionResponseBody
233+
if err := json.NewDecoder(res.Body).Decode(&respBody); err != nil {
234+
return nil, fmt.Errorf("failed to decode upload revision response: %w", err)
235+
}
236+
237+
return &respBody, nil
238+
}
239+
240+
func (c *HTTPClient) handleUnexpectedStatusCodes(body io.ReadCloser, statusCode int, status, operation string) error {
241+
bts, err := io.ReadAll(body)
242+
if err != nil {
243+
return fmt.Errorf("failed to read response body: %w", err)
244+
}
245+
246+
if len(bts) > 0 {
247+
snykErrorList, parseErr := snyk_errors.FromJSONAPIErrorBytes(bts)
248+
if parseErr == nil && len(snykErrorList) > 0 && snykErrorList[0].Title != "" {
249+
errsToJoin := []error{}
250+
for i := range snykErrorList {
251+
errsToJoin = append(errsToJoin, snykErrorList[i])
252+
}
253+
return fmt.Errorf("API error during %s: %w", operation, errors.Join(errsToJoin...))
254+
}
255+
}
256+
257+
return NewHTTPError(statusCode, status, operation, bts)
258+
}

0 commit comments

Comments
 (0)