Skip to content

Commit

Permalink
Feat: file handling / multi-part form helpers with echo wrappers (#9)
Browse files Browse the repository at this point in the history
* add some file handling functions, other required components for evaluating multipart form http requests

* add comments to missing exported functions
  • Loading branch information
matoszz authored Sep 22, 2024
1 parent 48f7248 commit fb7354f
Show file tree
Hide file tree
Showing 11 changed files with 394 additions and 0 deletions.
50 changes: 50 additions & 0 deletions echo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Wrapper for [echo](https://echo.labstack.com/)

## Usage

<details>
<summary>Example data</summary>

```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--
```
</details>

```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)
}
```
44 changes: 44 additions & 0 deletions echo/context.go
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions echo/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package echoform
48 changes: 48 additions & 0 deletions echo/parser.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions echo/parser_test.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
79 changes: 79 additions & 0 deletions files.go
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
Loading

0 comments on commit fb7354f

Please sign in to comment.