Skip to content

Commit

Permalink
add some file handling functions, other required components for evalu…
Browse files Browse the repository at this point in the history
…ating multipart form http requests
  • Loading branch information
matoszz committed Sep 21, 2024
1 parent 48f7248 commit 5dc0ebd
Show file tree
Hide file tree
Showing 11 changed files with 391 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 5dc0ebd

Please sign in to comment.