Skip to content

Commit 0dd995c

Browse files
authored
Merge pull request #271 from deploymenttheory/fix-multipart_uploads
Fix multipart uploads now supports base64 and raw streams
2 parents 1bb942c + ca84249 commit 0dd995c

File tree

1 file changed

+67
-177
lines changed

1 file changed

+67
-177
lines changed

httpclient/multipartrequest.go

Lines changed: 67 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
package httpclient
22

33
import (
4-
"bytes"
54
"context"
65
"encoding/base64"
76
"fmt"
87
"io"
98
"mime/multipart"
109
"net/http"
1110
"net/textproto"
12-
"net/url"
1311
"os"
1412
"path/filepath"
15-
"strings"
1613
"sync"
1714
"time"
1815

@@ -67,7 +64,11 @@ type UploadState struct {
6764
// }
6865
//
6966
// // Use `result` or `resp` as needed
70-
func (c *Client) DoMultiPartRequest(method, endpoint string, files map[string][]string, formDataFields map[string]string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, out interface{}) (*http.Response, error) {
67+
func (c *Client) DoMultiPartRequest(method, endpoint string, files map[string][]string, formDataFields map[string]string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, encodingType string, out interface{}) (*http.Response, error) {
68+
if encodingType != "raw" && encodingType != "base64" {
69+
c.Sugar.Errorw("Invalid encoding type specified", zap.String("encodingType", encodingType))
70+
return nil, fmt.Errorf("invalid encoding type: %s. Must be 'raw' or 'base64'", encodingType)
71+
}
7172

7273
if method != http.MethodPost && method != http.MethodPut {
7374
c.Sugar.Error("HTTP method not supported for multipart request", zap.String("method", method))
@@ -92,20 +93,21 @@ func (c *Client) DoMultiPartRequest(method, endpoint string, files map[string][]
9293
var body io.Reader
9394
var contentType string
9495

95-
// Create multipart body in a function to ensure it runs again on retry
9696
createBody := func() error {
9797
var err error
98-
body, contentType, err = createStreamingMultipartRequestBody(files, formDataFields, fileContentTypes, formDataPartHeaders, c.Sugar)
98+
body, contentType, err = createStreamingMultipartRequestBody(files, formDataFields, fileContentTypes, formDataPartHeaders, encodingType, c.Sugar)
9999
if err != nil {
100100
c.Sugar.Errorw("Failed to create streaming multipart request body", zap.Error(err))
101101
} else {
102-
c.Sugar.Infow("Successfully created streaming multipart request body", zap.String("content_type", contentType))
102+
c.Sugar.Infow("Successfully created streaming multipart request body",
103+
zap.String("content_type", contentType),
104+
zap.String("encoding", encodingType))
103105
}
104106
return err
105107
}
106108

107109
if err := createBody(); err != nil {
108-
c.Sugar.Errorw("Failed to create streaming multipart request body", zap.Error(err))
110+
c.Sugar.Errorw("Failed to create multipart request body", zap.Error(err))
109111
return nil, err
110112
}
111113

@@ -115,23 +117,33 @@ func (c *Client) DoMultiPartRequest(method, endpoint string, files map[string][]
115117
return nil, err
116118
}
117119

118-
c.Sugar.Infow("Created HTTP Multipart request", zap.String("method", method), zap.String("url", url), zap.String("content_type", contentType))
120+
c.Sugar.Infow("Created HTTP Multipart request",
121+
zap.String("method", method),
122+
zap.String("url", url),
123+
zap.String("content_type", contentType),
124+
zap.String("encoding", encodingType))
119125

120126
(*c.Integration).PrepRequestParamsAndAuth(req)
121-
122127
req.Header.Set("Content-Type", contentType)
123128

124129
startTime := time.Now()
125130

126-
resp, requestErr := c.http.Do(req)
131+
resp, err := c.http.Do(req)
127132
duration := time.Since(startTime)
128133

129-
if requestErr != nil {
130-
c.Sugar.Errorw("Failed to send request", zap.String("method", method), zap.String("endpoint", endpoint), zap.Error(requestErr))
131-
return nil, requestErr
134+
if err != nil {
135+
c.Sugar.Errorw("Failed to send request",
136+
zap.String("method", method),
137+
zap.String("endpoint", endpoint),
138+
zap.Error(err))
139+
return nil, err
132140
}
133141

134-
c.Sugar.Debugw("Request sent successfully", zap.String("method", method), zap.String("endpoint", endpoint), zap.Int("status_code", resp.StatusCode), zap.Duration("duration", duration))
142+
c.Sugar.Debugw("Request sent successfully",
143+
zap.String("method", method),
144+
zap.String("endpoint", endpoint),
145+
zap.Int("status_code", resp.StatusCode),
146+
zap.Duration("duration", duration))
135147

136148
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
137149
return resp, response.HandleAPISuccessResponse(resp, out, c.Sugar)
@@ -161,7 +173,7 @@ func (c *Client) DoMultiPartRequest(method, endpoint string, files map[string][]
161173
// - string: The content type of the multipart request body. This includes the boundary string used by the multipart writer.
162174
// - error: An error object indicating failure during the construction of the multipart request body. This could be due to issues
163175
// such as file reading errors or multipart writer errors.
164-
func createStreamingMultipartRequestBody(files map[string][]string, formDataFields map[string]string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, sugar *zap.SugaredLogger) (io.Reader, string, error) {
176+
func createStreamingMultipartRequestBody(files map[string][]string, formDataFields map[string]string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, encodingType string, sugar *zap.SugaredLogger) (io.Reader, string, error) {
165177
pr, pw := io.Pipe()
166178
writer := multipart.NewWriter(pw)
167179

@@ -177,8 +189,11 @@ func createStreamingMultipartRequestBody(files map[string][]string, formDataFiel
177189

178190
for fieldName, filePaths := range files {
179191
for _, filePath := range filePaths {
180-
sugar.Debugw("Adding file part", zap.String("field_name", fieldName), zap.String("file_path", filePath))
181-
if err := addFilePart(writer, fieldName, filePath, fileContentTypes, formDataPartHeaders, sugar); err != nil {
192+
sugar.Debugw("Adding file part",
193+
zap.String("field_name", fieldName),
194+
zap.String("file_path", filePath),
195+
zap.String("encoding", encodingType))
196+
if err := addFilePartWithEncoding(writer, fieldName, filePath, fileContentTypes, formDataPartHeaders, encodingType, sugar); err != nil {
182197
sugar.Errorw("Failed to add file part", zap.Error(err))
183198
pw.CloseWithError(err)
184199
return
@@ -199,47 +214,33 @@ func createStreamingMultipartRequestBody(files map[string][]string, formDataFiel
199214
return pr, writer.FormDataContentType(), nil
200215
}
201216

202-
// addFilePart adds a base64 encoded file part to the multipart writer with the provided field name and file path.
203-
// This function opens the specified file, sets the appropriate content type and headers, and adds it to the multipart writer.
204-
// Parameters:
205-
// - writer: The multipart writer used to construct the multipart request body.
206-
// - fieldName: The field name for the file part.
207-
// - filePath: The path to the file to be included in the request.
208-
// - fileContentTypes: A map specifying the content type for each file part. The key is the field name and the value is the
209-
// content type (e.g., "image/jpeg").
210-
// - formDataPartHeaders: A map specifying custom headers for each part of the multipart form data. The key is the field name
211-
// and the value is an http.Header containing the headers for that part.
212-
// - sugar: An instance of a logger implementing the logger.Logger interface, used to sugar informational messages, warnings,
213-
// and errors encountered during the addition of the file part.
214-
//
215-
// Returns:
216-
// - error: An error object indicating failure during the addition of the file part. This could be due to issues such as
217-
// file reading errors or multipart writer errors.
218-
func addFilePart(writer *multipart.Writer, fieldName, filePath string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, sugar *zap.SugaredLogger) error {
217+
// addFilePartWithEncoding adds a file part to the multipart writer with specified encoding.
218+
// Supports both raw file content and base64 encoding based on encodingType parameter.
219+
func addFilePartWithEncoding(writer *multipart.Writer, fieldName, filePath string, fileContentTypes map[string]string, formDataPartHeaders map[string]http.Header, encodingType string, sugar *zap.SugaredLogger) error {
219220
file, err := os.Open(filePath)
220221
if err != nil {
221222
sugar.Errorw("Failed to open file", zap.String("filePath", filePath), zap.Error(err))
222223
return err
223224
}
224225
defer file.Close()
225226

226-
// Default fileContentType
227227
contentType := "application/octet-stream"
228228
if ct, ok := fileContentTypes[fieldName]; ok {
229229
contentType = ct
230230
}
231231

232-
header := setFormDataPartHeader(fieldName, filepath.Base(filePath), contentType, formDataPartHeaders[fieldName])
232+
header := createFilePartHeader(fieldName, filePath, contentType, formDataPartHeaders[fieldName], encodingType)
233+
sugar.Debugw("Created file part header",
234+
zap.String("fieldName", fieldName),
235+
zap.String("contentType", contentType),
236+
zap.String("encoding", encodingType))
233237

234238
part, err := writer.CreatePart(header)
235239
if err != nil {
236240
sugar.Errorw("Failed to create form file part", zap.String("fieldName", fieldName), zap.Error(err))
237241
return err
238242
}
239243

240-
encoder := base64.NewEncoder(base64.StdEncoding, part)
241-
defer encoder.Close()
242-
243244
fileSize, err := file.Stat()
244245
if err != nil {
245246
sugar.Errorw("Failed to get file info", zap.String("filePath", filePath), zap.Error(err))
@@ -248,12 +249,34 @@ func addFilePart(writer *multipart.Writer, fieldName, filePath string, fileConte
248249

249250
progressLogger := logUploadProgress(file, fileSize.Size(), sugar)
250251
uploadState := &UploadState{}
251-
if err := chunkFileUpload(file, encoder, progressLogger, uploadState, sugar); err != nil {
252-
sugar.Errorw("Failed to copy file content", zap.String("filePath", filePath), zap.Error(err))
253-
return err
252+
253+
var writeTarget io.Writer = part
254+
if encodingType == "base64" {
255+
encoder := base64.NewEncoder(base64.StdEncoding, part)
256+
defer encoder.Close()
257+
writeTarget = encoder
258+
sugar.Debugw("Using base64 encoding for file upload", zap.String("fieldName", fieldName))
259+
} else {
260+
sugar.Debugw("Using raw encoding for file upload", zap.String("fieldName", fieldName))
254261
}
255262

256-
return nil
263+
return chunkFileUpload(file, writeTarget, progressLogger, uploadState, sugar)
264+
}
265+
266+
// createFilePartHeader creates the MIME header for a file part with the specified encoding type.
267+
func createFilePartHeader(fieldname, filename, contentType string, customHeaders http.Header, encodingType string) textproto.MIMEHeader {
268+
header := textproto.MIMEHeader{}
269+
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldname, filepath.Base(filename)))
270+
header.Set("Content-Type", contentType)
271+
if encodingType == "base64" {
272+
header.Set("Content-Transfer-Encoding", "base64")
273+
}
274+
for key, values := range customHeaders {
275+
for _, value := range values {
276+
header.Add(key, value)
277+
}
278+
}
279+
return header
257280
}
258281

259282
// addFormField adds a form field to the multipart writer with the provided key and value.
@@ -281,31 +304,6 @@ func addFormField(writer *multipart.Writer, key, val string, sugar *zap.SugaredL
281304
return nil
282305
}
283306

284-
// setFormDataPartHeader creates a textproto.MIMEHeader for a form data field with the provided field name, file name, content type, and custom headers.
285-
// This function constructs the MIME headers for a multipart form data part, including the content disposition, content type,
286-
// and any custom headers specified.
287-
// Parameters:
288-
// - fieldname: The name of the form field.
289-
// - filename: The name of the file being uploaded (if applicable).
290-
// - contentType: The content type of the form data part (e.g., "image/jpeg").
291-
// - customHeaders: A map of custom headers to be added to the form data part. The key is the header name and the value is the
292-
// header value.
293-
//
294-
// Returns:
295-
// - textproto.MIMEHeader: The constructed MIME header for the form data part.
296-
func setFormDataPartHeader(fieldname, filename, contentType string, customHeaders http.Header) textproto.MIMEHeader {
297-
header := textproto.MIMEHeader{}
298-
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldname, filename))
299-
header.Set("Content-Type", contentType)
300-
header.Set("Content-Transfer-Encoding", "base64")
301-
for key, values := range customHeaders {
302-
for _, value := range values {
303-
header.Add(key, value)
304-
}
305-
}
306-
return header
307-
}
308-
309307
// chunkFileUpload reads the file upload into chunks and writes it to the writer.
310308
// This function reads the file in chunks and writes it to the provided writer, allowing for progress logging during the upload.
311309
// The chunk size is set to 8192 KB (8 MB) by default. This is a common chunk size used for file uploads to cloud storage services.
@@ -425,111 +423,3 @@ func logUploadProgress(file *os.File, fileSize int64, sugar *zap.SugaredLogger)
425423
}
426424
}
427425
}
428-
429-
// DoImageMultiPartUpload performs a multipart request with a specifically formatted payload.
430-
// This is designed for APIs that expect a very specific multipart format, where the payload
431-
// needs to be constructed manually rather than using the standard multipart writer.
432-
func (c *Client) DoImageMultiPartUpload(method, endpoint string, fileName string, base64Data string, customBoundary string, out interface{}) (*http.Response, error) {
433-
c.Sugar.Infow("Starting DoImageMultiPartUpload",
434-
zap.String("method", method),
435-
zap.String("endpoint", endpoint),
436-
zap.String("fileName", fileName),
437-
zap.String("boundary", customBoundary))
438-
439-
// URL encode the filename for both the Content-Disposition and data prefix
440-
encodedFileName := url.QueryEscape(fileName)
441-
c.Sugar.Debugw("URL encoded filename", zap.String("encodedFileName", encodedFileName))
442-
443-
// Construct payload exactly like the example
444-
payload := fmt.Sprintf("%s\r\n"+
445-
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"+
446-
"Content-Type: image/png\r\n\r\n"+
447-
"data:image/png;name=%s;base64,%s\r\n"+
448-
"%s-",
449-
customBoundary,
450-
encodedFileName,
451-
encodedFileName,
452-
base64Data,
453-
customBoundary)
454-
455-
// Create truncated version of payload for logging
456-
truncatedPayload := payload
457-
if len(base64Data) > 100 {
458-
// Find the position of base64 data in the payload
459-
base64Start := strings.Index(payload, ";base64,") + 8
460-
if base64Start > 0 {
461-
truncatedPayload = payload[:base64Start] + "[BASE64_DATA_LENGTH: " +
462-
fmt.Sprintf("%d", len(base64Data)) + "]\r\n" +
463-
customBoundary + "-"
464-
}
465-
}
466-
467-
c.Sugar.Debugw("Constructed request payload",
468-
zap.String("payload", truncatedPayload))
469-
470-
url := (*c.Integration).GetFQDN() + endpoint
471-
c.Sugar.Debugw("Full request URL", zap.String("url", url))
472-
473-
// Create request with string payload
474-
req, err := http.NewRequest(method, url, strings.NewReader(payload))
475-
if err != nil {
476-
c.Sugar.Errorw("Failed to create request", zap.Error(err))
477-
return nil, fmt.Errorf("failed to create request: %v", err)
478-
}
479-
480-
// Set headers exactly as in example
481-
req.Header.Set("accept", "application/json")
482-
req.Header.Set("content-type", fmt.Sprintf("multipart/form-data; boundary=%s", strings.TrimPrefix(customBoundary, "---")))
483-
484-
c.Sugar.Debugw("Initial headers",
485-
zap.Any("headers", req.Header),
486-
zap.String("accept", req.Header.Get("accept")),
487-
zap.String("content-type", req.Header.Get("content-type")))
488-
489-
// Store initial headers
490-
contentType := req.Header.Get("content-type")
491-
accept := req.Header.Get("accept")
492-
493-
// Apply auth
494-
(*c.Integration).PrepRequestParamsAndAuth(req)
495-
496-
// Restore and log final headers
497-
req.Header.Set("accept", accept)
498-
req.Header.Set("content-type", contentType)
499-
500-
c.Sugar.Infow("Final request headers",
501-
zap.Any("headers", req.Header),
502-
zap.String("accept", req.Header.Get("accept")),
503-
zap.String("content-type", req.Header.Get("content-type")))
504-
505-
// Send the request
506-
resp, err := c.http.Do(req)
507-
if err != nil {
508-
return nil, fmt.Errorf("failed to send request: %v", err)
509-
}
510-
511-
c.Sugar.Debugw("Response received",
512-
zap.Int("statusCode", resp.StatusCode),
513-
zap.Any("responseHeaders", resp.Header))
514-
515-
// Handle response
516-
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
517-
return resp, response.HandleAPISuccessResponse(resp, out, c.Sugar)
518-
}
519-
520-
// For error responses, try to log the response body
521-
if resp.Body != nil {
522-
bodyBytes, err := io.ReadAll(resp.Body)
523-
if err != nil {
524-
c.Sugar.Warnw("Failed to read error response body", zap.Error(err))
525-
} else {
526-
c.Sugar.Errorw("Request failed",
527-
zap.Int("statusCode", resp.StatusCode),
528-
zap.String("responseBody", string(bodyBytes)))
529-
// Create new reader with same data for error handler
530-
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
531-
}
532-
}
533-
534-
return resp, response.HandleAPIErrorResponse(resp, c.Sugar)
535-
}

0 commit comments

Comments
 (0)