From da48c440e74737cafeb05f36c066bbd1ce628712 Mon Sep 17 00:00:00 2001 From: Stainless Bot Date: Tue, 26 Mar 2024 15:07:40 +0000 Subject: [PATCH] docs(readme): document file uploads --- README.md | 13 +++++++++++++ field.go | 15 +++++++++++++++ internal/apiform/encoder.go | 24 ++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ed558cc..82bd482 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,19 @@ client.HRIS.Directory.List( ) ``` +### File uploads + +Request parameters that correspond to file uploads in multipart requests are typed as +`param.Field[io.Reader]`. The contents of the `io.Reader` will by default be sent as a multipart form +part with the file name of "anonymous_file" and content-type of "application/octet-stream". + +The file name and content-type can be customized by implementing `Name() string` or `ContentType() +string` on the run-time type of `io.Reader`. Note that `os.File` implements `Name() string`, so a +file returned by `os.Open` will be sent with the file name on disk. + +We also provide a helper `finchgo.FileParam(reader io.Reader, filename string, contentType string)` +which can be used to wrap any `io.Reader` with the appropriate file name and content type. + ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. diff --git a/field.go b/field.go index 48c999b..1c89174 100644 --- a/field.go +++ b/field.go @@ -2,6 +2,7 @@ package finchgo import ( "github.com/Finch-API/finch-api-go/internal/param" + "io" ) // F is a param field helper used to initialize a [param.Field] generic struct. @@ -33,3 +34,17 @@ func Float(value float64) param.Field[float64] { return F(value) } // Bool is a param field helper which helps specify bools. func Bool(value bool) param.Field[bool] { return F(value) } + +// FileParam is a param field helper which helps files with a mime content-type. +func FileParam(reader io.Reader, filename string, contentType string) param.Field[io.Reader] { + return F[io.Reader](&file{reader, filename, contentType}) +} + +type file struct { + io.Reader + name string + contentType string +} + +func (f *file) Name() string { return f.name } +func (f *file) ContentType() string { return f.contentType } diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index b30ca66..8f16432 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -4,10 +4,12 @@ import ( "fmt" "io" "mime/multipart" + "net/textproto" "path" "reflect" "sort" "strconv" + "strings" "sync" "time" @@ -302,15 +304,33 @@ func (e encoder) newInterfaceEncoder() encoderFunc { } } +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + func (e *encoder) newReaderTypeEncoder() encoderFunc { return func(key string, value reflect.Value, writer *multipart.Writer) error { reader := value.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) filename := "anonymous_file" + contentType := "application/octet-stream" if named, ok := reader.(interface{ Name() string }); ok { filename = path.Base(named.Name()) } - filewriter, err := writer.CreateFormFile(key, filename) - io.Copy(filewriter, reader) + if typed, ok := reader.(interface{ ContentType() string }); ok { + contentType = path.Base(typed.ContentType()) + } + + // Below is taken almost 1-for-1 from [multipart.CreateFormFile] + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(key), escapeQuotes(filename))) + h.Set("Content-Type", contentType) + filewriter, err := writer.CreatePart(h) + if err != nil { + return err + } + _, err = io.Copy(filewriter, reader) return err } }