Skip to content

Commit 759c2ef

Browse files
authored
Merge pull request #843 from zpzjzj/feat/go-sdk-multi-file-transfer
feat(sdk-go): support multi-file upload
2 parents 7765415 + ef1f49e commit 759c2ef

4 files changed

Lines changed: 127 additions & 32 deletions

File tree

sdks/sandbox/go/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Created with `NewExecdClient(baseURL, accessToken string, opts ...Option)`.
161161
| `SearchFiles(ctx, dir, pattern)` | Search files by glob pattern |
162162
| `ReplaceInFiles(ctx, req)` | Text replacement in files |
163163
| `UploadFile(ctx, file, opts)` | Upload a file to the sandbox |
164+
| `UploadFiles(ctx, entries)` | Upload multiple files to the sandbox |
164165
| `DownloadFile(ctx, remotePath, rangeHeader)` | Download a file from the sandbox |
165166

166167
**Directory Operations:**

sdks/sandbox/go/execd.go

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"io"
2222
"mime/multipart"
2323
"net/http"
24+
"net/textproto"
2425
"net/url"
2526
"os"
2627
"strconv"
@@ -248,13 +249,26 @@ func (e *ExecdClient) ReplaceInFiles(ctx context.Context, req ReplaceRequest) er
248249
return e.client.doRequest(ctx, http.MethodPost, "/files/replace", req, nil)
249250
}
250251

252+
// UploadFileOptions configures the destination path and multipart filename for an upload.
251253
type UploadFileOptions struct {
252254
FileName string
253255
Metadata FileMetadata
254256
}
255257

258+
// UploadFileEntry describes one file part in a multi-file upload request.
259+
type UploadFileEntry struct {
260+
File io.Reader
261+
Options UploadFileOptions
262+
}
263+
264+
// UploadFile uploads a single file to the sandbox.
256265
func (e *ExecdClient) UploadFile(ctx context.Context, file io.Reader, opts UploadFileOptions) error {
257-
req, bodyCloser, err := e.newUploadRequest(ctx, file, opts)
266+
return e.UploadFiles(ctx, []UploadFileEntry{{File: file, Options: opts}})
267+
}
268+
269+
// UploadFiles uploads one or more files to the sandbox in a single multipart request.
270+
func (e *ExecdClient) UploadFiles(ctx context.Context, entries []UploadFileEntry) error {
271+
req, bodyCloser, err := e.newUploadFilesRequest(ctx, entries)
258272
if err != nil {
259273
return err
260274
}
@@ -278,45 +292,56 @@ func (e *ExecdClient) UploadFile(ctx context.Context, file io.Reader, opts Uploa
278292
return nil
279293
}
280294

281-
func (e *ExecdClient) newUploadRequest(ctx context.Context, file io.Reader, opts UploadFileOptions) (*http.Request, io.Closer, error) {
282-
if file == nil {
283-
return nil, nil, &InvalidArgumentError{Field: "file", Message: "file reader is required"}
295+
func (e *ExecdClient) newUploadFilesRequest(ctx context.Context, entries []UploadFileEntry) (*http.Request, io.Closer, error) {
296+
if len(entries) == 0 {
297+
return nil, nil, &InvalidArgumentError{Field: "entries", Message: "at least one file entry is required"}
284298
}
285-
if opts.Metadata.Path == "" {
286-
return nil, nil, &InvalidArgumentError{Field: "metadata.path", Message: "path is required"}
287-
}
288-
fileName := opts.FileName
289-
if fileName == "" {
290-
fileName = "file"
299+
for i, entry := range entries {
300+
if entry.File == nil {
301+
return nil, nil, &InvalidArgumentError{Field: fmt.Sprintf("entries[%d].file", i), Message: "file reader is required"}
302+
}
303+
if entry.Options.Metadata.Path == "" {
304+
return nil, nil, &InvalidArgumentError{Field: fmt.Sprintf("entries[%d].metadata.path", i), Message: "path is required"}
305+
}
291306
}
292307

293308
pr, pw := io.Pipe()
294309
writer := multipart.NewWriter(pw)
295310
contentType := writer.FormDataContentType()
296311

297312
go func() {
298-
metaJSON, err := json.Marshal(opts.Metadata)
299-
if err != nil {
300-
_ = pw.CloseWithError(fmt.Errorf("opensandbox: marshal metadata: %w", err))
301-
return
302-
}
303-
metaPart, err := writer.CreateFormFile("metadata", "metadata")
304-
if err != nil {
305-
_ = pw.CloseWithError(fmt.Errorf("opensandbox: create metadata part: %w", err))
306-
return
307-
}
308-
if _, err := metaPart.Write(metaJSON); err != nil {
309-
_ = pw.CloseWithError(fmt.Errorf("opensandbox: write metadata: %w", err))
310-
return
311-
}
312-
filePart, err := writer.CreateFormFile("file", fileName)
313-
if err != nil {
314-
_ = pw.CloseWithError(fmt.Errorf("opensandbox: create file part: %w", err))
315-
return
316-
}
317-
if _, err := io.Copy(filePart, file); err != nil {
318-
_ = pw.CloseWithError(fmt.Errorf("opensandbox: write file: %w", err))
319-
return
313+
for i, entry := range entries {
314+
metaJSON, err := json.Marshal(entry.Options.Metadata)
315+
if err != nil {
316+
_ = pw.CloseWithError(fmt.Errorf("opensandbox: marshal metadata for entry %d: %w", i, err))
317+
return
318+
}
319+
metaHeader := make(textproto.MIMEHeader)
320+
metaHeader.Set("Content-Disposition", `form-data; name="metadata"; filename="metadata"`)
321+
metaHeader.Set("Content-Type", "application/json")
322+
metaPart, err := writer.CreatePart(metaHeader)
323+
if err != nil {
324+
_ = pw.CloseWithError(fmt.Errorf("opensandbox: create metadata part for entry %d: %w", i, err))
325+
return
326+
}
327+
if _, err := metaPart.Write(metaJSON); err != nil {
328+
_ = pw.CloseWithError(fmt.Errorf("opensandbox: write metadata for entry %d: %w", i, err))
329+
return
330+
}
331+
332+
fileName := entry.Options.FileName
333+
if fileName == "" {
334+
fileName = "file"
335+
}
336+
filePart, err := writer.CreateFormFile("file", fileName)
337+
if err != nil {
338+
_ = pw.CloseWithError(fmt.Errorf("opensandbox: create file part for entry %d: %w", i, err))
339+
return
340+
}
341+
if _, err := io.Copy(filePart, entry.File); err != nil {
342+
_ = pw.CloseWithError(fmt.Errorf("opensandbox: write file for entry %d: %w", i, err))
343+
return
344+
}
320345
}
321346
if err := writer.Close(); err != nil {
322347
_ = pw.CloseWithError(fmt.Errorf("opensandbox: close multipart: %w", err))

sdks/sandbox/go/opensandbox_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,66 @@ func TestUploadFile_WithReader(t *testing.T) {
761761
require.NoError(t, err)
762762
}
763763

764+
func TestUploadFiles(t *testing.T) {
765+
_, client := newExecdServer(t, func(w http.ResponseWriter, r *http.Request) {
766+
require.Equal(t, http.MethodPost, r.Method)
767+
require.Equal(t, "/files/upload", r.URL.Path)
768+
require.True(t, strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data"))
769+
770+
require.NoError(t, r.ParseMultipartForm(1<<20))
771+
metadataParts := r.MultipartForm.File["metadata"]
772+
fileParts := r.MultipartForm.File["file"]
773+
require.Len(t, metadataParts, 2)
774+
require.Len(t, fileParts, 2)
775+
776+
wantPaths := []string{"/sandbox/a.txt", "/sandbox/b.txt"}
777+
wantFileNames := []string{"a.txt", "custom-b.txt"}
778+
wantContents := []string{"alpha", "bravo"}
779+
780+
for i := range metadataParts {
781+
metaFile, err := metadataParts[i].Open()
782+
require.NoError(t, err)
783+
require.Equal(t, "application/json", metadataParts[i].Header.Get("Content-Type"))
784+
metaBytes, err := io.ReadAll(metaFile)
785+
require.NoError(t, err)
786+
require.NoError(t, metaFile.Close())
787+
788+
var meta FileMetadata
789+
require.NoError(t, json.Unmarshal(metaBytes, &meta))
790+
require.Equal(t, wantPaths[i], meta.Path)
791+
require.Equal(t, 600+i, meta.Mode)
792+
793+
filePart, err := fileParts[i].Open()
794+
require.NoError(t, err)
795+
data, err := io.ReadAll(filePart)
796+
require.NoError(t, err)
797+
require.NoError(t, filePart.Close())
798+
require.Equal(t, wantFileNames[i], fileParts[i].Filename)
799+
require.Equal(t, wantContents[i], string(data))
800+
}
801+
802+
w.WriteHeader(http.StatusOK)
803+
})
804+
805+
err := client.UploadFiles(context.Background(), []UploadFileEntry{
806+
{
807+
File: strings.NewReader("alpha"),
808+
Options: UploadFileOptions{
809+
FileName: "a.txt",
810+
Metadata: FileMetadata{Path: "/sandbox/a.txt", Mode: 600},
811+
},
812+
},
813+
{
814+
File: strings.NewReader("bravo"),
815+
Options: UploadFileOptions{
816+
FileName: "custom-b.txt",
817+
Metadata: FileMetadata{Path: "/sandbox/b.txt", Mode: 601},
818+
},
819+
},
820+
})
821+
require.NoError(t, err)
822+
}
823+
764824
func TestGetMetrics(t *testing.T) {
765825
want := Metrics{
766826
CPUCount: 4,

sdks/sandbox/go/sandbox_files.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,22 @@ func (s *Sandbox) SetPermissions(ctx context.Context, req PermissionsRequest) er
6060
return s.execd.SetPermissions(ctx, req)
6161
}
6262

63+
// UploadFile uploads a single file to the sandbox.
6364
func (s *Sandbox) UploadFile(ctx context.Context, file io.Reader, opts UploadFileOptions) error {
6465
if s.execd == nil {
6566
return fmt.Errorf("opensandbox: execd client not initialized")
6667
}
6768
return s.execd.UploadFile(ctx, file, opts)
6869
}
6970

71+
// UploadFiles uploads one or more files to the sandbox in a single multipart request.
72+
func (s *Sandbox) UploadFiles(ctx context.Context, entries []UploadFileEntry) error {
73+
if s.execd == nil {
74+
return fmt.Errorf("opensandbox: execd client not initialized")
75+
}
76+
return s.execd.UploadFiles(ctx, entries)
77+
}
78+
7079
// DownloadFile downloads a file from the sandbox.
7180
func (s *Sandbox) DownloadFile(ctx context.Context, remotePath, rangeHeader string) (io.ReadCloser, error) {
7281
if s.execd == nil {

0 commit comments

Comments
 (0)