From fb7354ff453c76398b7c1eb71ae225b7bfc4290e Mon Sep 17 00:00:00 2001 From: Matt Anderson <42154938+matoszz@users.noreply.github.com> Date: Sun, 22 Sep 2024 10:02:02 -0600 Subject: [PATCH] Feat: file handling / multi-part form helpers with echo wrappers (#9) * add some file handling functions, other required components for evaluating multipart form http requests * add comments to missing exported functions --- echo/README.md | 50 +++++++++++++++++++++ echo/context.go | 44 ++++++++++++++++++ echo/doc.go | 1 + echo/parser.go | 48 ++++++++++++++++++++ echo/parser_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++ errors.go | 4 ++ files.go | 79 +++++++++++++++++++++++++++++++++ go.mod | 8 ++++ go.sum | 10 +++++ options.go | 36 +++++++++++++++ requester.go | 8 ++++ 11 files changed, 394 insertions(+) create mode 100644 echo/README.md create mode 100644 echo/context.go create mode 100644 echo/doc.go create mode 100644 echo/parser.go create mode 100644 echo/parser_test.go create mode 100644 files.go diff --git a/echo/README.md b/echo/README.md new file mode 100644 index 0000000..5641a33 --- /dev/null +++ b/echo/README.md @@ -0,0 +1,50 @@ +# Wrapper for [echo](https://echo.labstack.com/) + +## Usage + +
+Example data + +```text +--boundary +Content-Disposition: form-data; name="name" + +mazrean +--boundary +Content-Disposition: form-data; name="password" + +password +--boundary +Content-Disposition: form-data; name="icon"; filename="icon.png" +Content-Type: image/png + +icon contents +--boundary-- +``` +
+ +```go +func createUserHandler(c echo.Context) error { + parser, err := echoform.NewFormParser(c) + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + + err = parser.Register("icon", func(r io.Reader, header formstream.Header) error { + name, _, _ := parser.Value("name") + password, _, _ := parser.Value("password") + + return saveUser(c.Request().Context(), name, password, r) + }, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password")) + if err != nil { + return err + } + + err = parser.Parse() + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + + return c.NoContent(http.StatusCreated) +} +``` \ No newline at end of file diff --git a/echo/context.go b/echo/context.go new file mode 100644 index 0000000..ba4d7c1 --- /dev/null +++ b/echo/context.go @@ -0,0 +1,44 @@ +package echoform + +import ( + "fmt" + "time" + + echo "github.com/theopenlane/echox" +) + +// EchoContextAdapter acts as an adapter for an `echo.Context` object. It provides methods to interact with the underlying +// `echo.Context` object and extract information such as deadline, done channel, error, and values +// associated with specific keys from the context. The struct is used to enhance the functionality of +// the `echo.Context` object by providing additional methods and capabilities +type EchoContextAdapter struct { + c echo.Context +} + +// NewEchoContextAdapter takes echo.Context as a parameter and returns a pointer to +// a new EchoContextAdapter struct initialized with the provided echo.Context +func NewEchoContextAdapter(c echo.Context) *EchoContextAdapter { + return &EchoContextAdapter{c: c} +} + +// Deadline represents the time when the request should be completed +// deadline returns two values: deadline, which is the deadline time, and ok, indicating if a deadline is set or not +func (a *EchoContextAdapter) Deadline() (deadline time.Time, ok bool) { + return a.c.Request().Context().Deadline() +} + +// Done channel is used to receive a signal when the request context associated with the EchoContextAdapter is done or canceled +func (a *EchoContextAdapter) Done() <-chan struct{} { + return a.c.Request().Context().Done() +} + +// Err handles if an error occurred during the processing of the request +func (a *EchoContextAdapter) Err() error { + return a.c.Request().Context().Err() +} + +// Value implements the Value method of the context.Context interface +// used to retrieve a value associated with a specific key from the context +func (a *EchoContextAdapter) Value(key interface{}) interface{} { + return a.c.Get(fmt.Sprintf("%v", key)) +} diff --git a/echo/doc.go b/echo/doc.go new file mode 100644 index 0000000..47caefc --- /dev/null +++ b/echo/doc.go @@ -0,0 +1 @@ +package echoform diff --git a/echo/parser.go b/echo/parser.go new file mode 100644 index 0000000..06bc069 --- /dev/null +++ b/echo/parser.go @@ -0,0 +1,48 @@ +package echoform + +import ( + "errors" + "io" + "mime" + "net/http" + + "github.com/mazrean/formstream" + echo "github.com/theopenlane/echox" +) + +type FormParser struct { + *formstream.Parser + reader io.Reader +} + +// NewFormParser creates a new multipart form parser +func NewFormParser(c echo.Context, options ...formstream.ParserOption) (*FormParser, error) { + contentType := c.Request().Header.Get("Content-Type") + + d, params, err := mime.ParseMediaType(contentType) + if err != nil || d != "multipart/form-data" { + return nil, http.ErrNotMultipart + } + + boundary, ok := params["boundary"] + if !ok { + return nil, http.ErrMissingBoundary + } + + return &FormParser{ + Parser: formstream.NewParser(boundary, options...), + reader: c.Request().Body, + }, nil +} + +// Parse parses the request body; it returns the echo.HTTPError if the hook function returns an echo.HTTPError +func (p *FormParser) Parse() error { + err := p.Parser.Parse(p.reader) + + var httpErr *echo.HTTPError + if errors.As(err, &httpErr) { + return httpErr + } + + return err +} diff --git a/echo/parser_test.go b/echo/parser_test.go new file mode 100644 index 0000000..665b47d --- /dev/null +++ b/echo/parser_test.go @@ -0,0 +1,106 @@ +package echoform_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mazrean/formstream" + echo "github.com/theopenlane/echox" + + echoform "github.com/theopenlane/httpsling/echo" +) + +func TestExample(t *testing.T) { + e := echo.New() + + req := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(` +--boundary +Content-Disposition: form-data; name="name" + +mitb +--boundary +Content-Disposition: form-data; name="password" + +password +--boundary +Content-Disposition: form-data; name="icon"; filename="icon.png" +Content-Type: image/png + +icon contents +--boundary--`)) + req.Header.Set(echo.HeaderContentType, "multipart/form-data; boundary=boundary") + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := createUserHandler(c) + if err != nil { + t.Fatalf("failed to create user: %s\n", err) + return + } + + if user.name != "mitb" { + t.Errorf("user name is wrong: expected: mazrean, actual: %s\n", user.name) + } + + if user.password != "password" { + t.Errorf("user password is wrong: expected: password, actual: %s\n", user.password) + } + + if user.icon != "icon contents" { + t.Errorf("user icon is wrong: expected: icon contents, actual: %s\n", user.icon) + } +} + +func createUserHandler(c echo.Context) error { + parser, err := echoform.NewFormParser(c) + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + + err = parser.Register("icon", func(r io.Reader, _ formstream.Header) error { + name, _, _ := parser.Value("name") + password, _, _ := parser.Value("password") + + return saveUser(c.Request().Context(), name, password, r) + }, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password")) + if err != nil { + return err + } + + err = parser.Parse() + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + + return c.NoContent(http.StatusCreated) +} + +var ( + user = struct { + name string + password string + icon string + }{} +) + +func saveUser(_ context.Context, name string, password string, iconReader io.Reader) error { + user.name = name + user.password = password + + sb := strings.Builder{} + + _, err := io.Copy(&sb, iconReader) + if err != nil { + return fmt.Errorf("failed to copy: %w", err) + } + + user.icon = sb.String() + + return nil +} diff --git a/errors.go b/errors.go index 32c68e8..a4d7e00 100644 --- a/errors.go +++ b/errors.go @@ -9,4 +9,8 @@ var ( ErrUnsupportedContentType = errors.New("unsupported content type") // ErrUnsuccessfulResponse is returned when the response is unsuccessful ErrUnsuccessfulResponse = errors.New("unsuccessful response") + // ErrNoFilesUploaded is returned when no files are found in a multipart form request + ErrNoFilesUploaded = errors.New("no uploadable files found in request") + // ErrUnsupportedMimeType is returned when the mime type is unsupported + ErrUnsupportedMimeType = errors.New("unsupported mime type") ) diff --git a/files.go b/files.go new file mode 100644 index 0000000..a87bc98 --- /dev/null +++ b/files.go @@ -0,0 +1,79 @@ +package httpsling + +import ( + "fmt" + "net/http" + "strings" +) + +// Files is a map of form field names to a slice of files +type Files map[string][]File + +// File represents a file that has been sent in an http request +type File struct { + // FieldName denotes the field from the multipart form + FieldName string `json:"field_name,omitempty"` + // OriginalName is he name of the file from the client side / which was sent in the request + OriginalName string `json:"original_name,omitempty"` + // MimeType of the uploaded file + MimeType string `json:"mime_type,omitempty"` + // Size in bytes of the uploaded file + Size int64 `json:"size,omitempty"` +} + +// ValidationFunc is a type that can be used to dynamically validate a file +type ValidationFunc func(f File) error + +// ErrResponseHandler is a custom error that should be used to handle errors when an upload fails +type ErrResponseHandler func(error) http.HandlerFunc + +// NameGeneratorFunc allows you alter the name of the file before it is ultimately uploaded and stored +type NameGeneratorFunc func(s string) string + +// FilesFromContext returns all files that have been uploaded during the request +func FilesFromContext(r *http.Request, key string) (Files, error) { + files, ok := r.Context().Value(key).(Files) + if !ok { + return nil, ErrNoFilesUploaded + } + + return files, nil +} + +// FilesFromContextWithKey returns all files that have been uploaded during the request +// and sorts by the provided form field +func FilesFromContextWithKey(r *http.Request, key string) ([]File, error) { + files, ok := r.Context().Value(key).(Files) + if !ok { + return nil, ErrNoFilesUploaded + } + + return files[key], nil +} + +// MimeTypeValidator makes sure we only accept a valid mimetype. +// It takes in an array of supported mimes +func MimeTypeValidator(validMimeTypes ...string) ValidationFunc { + return func(f File) error { + for _, mimeType := range validMimeTypes { + if strings.EqualFold(strings.ToLower(mimeType), f.MimeType) { + return nil + } + } + + return fmt.Errorf("%w: %s", ErrUnsupportedMimeType, f.MimeType) + } +} + +// ChainValidators returns a validator that accepts multiple validating criteras +func ChainValidators(validators ...ValidationFunc) ValidationFunc { + return func(f File) error { + for _, validator := range validators { + if err := validator(f); err != nil { + return err + } + } + + return nil + } +} diff --git a/go.mod b/go.mod index 89d2adc..7bdefd8 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,17 @@ require ( ) +require ( + go.uber.org/mock v0.4.0 // indirect + golang.org/x/mod v0.11.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/tools v0.6.0 // indirect +) + require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/mazrean/formstream v1.1.1 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/theopenlane/echox v0.2.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 034013a..286742d 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/mazrean/formstream v1.1.1 h1:8CpESXh2jOxSrVRck5LvaLlliNM8k36vlreMB1Y2Gjw= +github.com/mazrean/formstream v1.1.1/go.mod h1:Rz8+Viu/83GqutUEwcbH/dbRM0oZlGMlULiz2QNpq9g= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= @@ -15,10 +17,18 @@ github.com/theopenlane/echox v0.2.0 h1:s9DJJrsLOSPsXVfgmQxgXmSVtxzztBnSmcVX4ax7t github.com/theopenlane/echox v0.2.0/go.mod h1:nfxwQpwvqYYI/pFHJKDs3/HLvjYKEGCih4XDgLSma64= github.com/theopenlane/utils v0.2.1 h1:T6VfvOQDcAXBa1NFVL4QCsCbHvVQkp6Tl4hGJVd7TwQ= github.com/theopenlane/utils v0.2.1/go.mod h1:ydEtwhmEvkVt3KKmNqiQiSY5b3rKH7U4umZ3QbFDsxU= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/options.go b/options.go index 8f5249b..3d8c9e2 100644 --- a/options.go +++ b/options.go @@ -419,3 +419,39 @@ func WithDoer(d Doer) Option { return nil }) } + +// WithMaxFileSize sets the maximum file size for file uploads +func WithMaxFileSize(i int64) Option { + return OptionFunc(func(r *Requester) error { + r.MaxFileSize = i + + return nil + }) +} + +// WithValidationFunc allows you to set a function that can be used to perform validations +func WithValidationFunc(validationFunc ValidationFunc) Option { + return OptionFunc(func(r *Requester) error { + r.validationFunc = validationFunc + + return nil + }) +} + +// WithNameFuncGenerator allows you configure how you'd like to rename your uploaded files +func WithNameFuncGenerator(nameFunc NameGeneratorFunc) Option { + return OptionFunc(func(r *Requester) error { + r.fileNameFuncGenerator = nameFunc + + return nil + }) +} + +// WithFileErrorResponseHandler allows you to configure how you'd like to handle errors when a file upload fails either to your own server or the destination server or both +func WithFileErrorResponseHandler(errHandler ErrResponseHandler) Option { + return OptionFunc(func(r *Requester) error { + r.fileUploaderrorResponseHandler = errHandler + + return nil + }) +} diff --git a/requester.go b/requester.go index 26a413d..f2075c2 100644 --- a/requester.go +++ b/requester.go @@ -44,6 +44,14 @@ type Requester struct { Middleware []Middleware // Unmarshaler will be used by the Receive methods to unmarshal the response body Unmarshaler Unmarshaler + // MaxFileSize is the maximum size of a file to download + MaxFileSize int64 + // ValidationFunc is a function that can be used to validate the response + validationFunc ValidationFunc + // NameGeneratorFunc is a function that can be used to generate a name (added for files but could be used for other things) + fileNameFuncGenerator NameGeneratorFunc + // errorResponseHandler is a function that can be used to handle errors when a file upload fails + fileUploaderrorResponseHandler ErrResponseHandler } // New returns a new Requester, applying all options