@@ -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.
251253type 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.
256265func (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 ))
0 commit comments