Skip to content

Commit

Permalink
Binder interface base implementation + minor refactoring
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitrymomot committed Jan 7, 2024
1 parent 337a423 commit e3ecf09
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 210 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ HTTP request data binder.
- [x] Bind JSON body to struct fields
- [x] Get file from multipart form
- [x] Bind multipart form values to struct fields (limited support, see [supported types](#supported-types))
- [ ] Binder interface implementation
- [x] Binder interface implementation

### Supported types

Expand Down
45 changes: 45 additions & 0 deletions binder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package binder

import (
"net/http"
"strings"
)

// Binder is the interface that wraps the Bind method.
Expand All @@ -14,3 +15,47 @@ type Binder interface {

// BinderFunc is the function type that implements the Binder interface.
type BinderFunc func(*http.Request, interface{}) error

// DefaultBinder is the default implementation of the Binder interface.
type DefaultBinder struct{}

// Bind binds the passed v pointer to the request.
// For example, the implementation could bind the request body to the v pointer.
// Bind implements the Binder interface.
// It returns an error if the binding fails.
// Binding depends on the request method and the content type.
// If the request method is GET, HEAD, DELETE, or OPTIONS, then the binding is done from the query.
// If the request method is POST, PUT, or PATCH, then the binding is done from the request body.
// If the content type is JSON, then the binding is done from the request body.
// If the content type is form, then the binding is done from the request body.
func (b *DefaultBinder) Bind(r *http.Request, v interface{}) error {
return BindFunc(r, v)
}

// BindFunc is the function type that implements the BinderFunc interface.
// It returns an error if the binding fails.
// Binding depends on the request method and the content type.
// If the request method is GET, HEAD, DELETE, or OPTIONS, then the binding is done from the query.
// If the request method is POST, PUT, or PATCH, then the binding is done from the request body.
// If the content type is JSON, then the binding is done from the request body.
// If the content type is form, then the binding is done from the request body.
func BindFunc(r *http.Request, v interface{}) error {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodDelete, http.MethodOptions:
return BindQuery(r, v)
case http.MethodPost, http.MethodPut, http.MethodPatch:
contentType := strings.ToLower(r.Header.Get("Content-Type"))
switch {
case strings.HasPrefix(contentType, "application/json"):
return BindJSON(r, v)
case strings.HasPrefix(contentType, "application/x-www-form-urlencoded"):
return BindForm(r, v)
case strings.HasPrefix(contentType, "multipart/form-data"):
return BindFormMultipart(r, v)
default:
return ErrInvalidContentType
}
default:
return ErrInvalidMethod
}
}
88 changes: 88 additions & 0 deletions binder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package binder_test

import (
"bytes"
"mime/multipart"
"net/http"
"strings"
"testing"

"github.com/dmitrymomot/binder"
"github.com/stretchr/testify/require"
)

func TestBindFunc(t *testing.T) {
// Test GET request
t.Run("GET", func(t *testing.T) {
getReq, err := http.NewRequest(http.MethodGet, "/data?id=123", nil)
require.NoError(t, err)
var getParams struct {
ID string `json:"id"`
}
err = binder.BindFunc(getReq, &getParams)
require.NoError(t, err)
require.Equal(t, "123", getParams.ID)
})

// Test POST request with JSON content type
t.Run("POST JSON", func(t *testing.T) {
jsonReq, err := http.NewRequest(http.MethodPost, "/data", strings.NewReader(`{"name":"John"}`))
jsonReq.Header.Set("Content-Type", "application/json")
require.NoError(t, err)
var jsonBody struct {
Name string `json:"name"`
}
err = binder.BindFunc(jsonReq, &jsonBody)
require.NoError(t, err)
require.Equal(t, "John", jsonBody.Name)
})

// Test POST request with form-urlencoded content type
t.Run("POST form-urlencoded", func(t *testing.T) {
formReq, err := http.NewRequest(http.MethodPost, "/data", strings.NewReader("name=John"))
formReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
require.NoError(t, err)
var formBody struct {
Name string `form:"name"`
}
err = binder.BindFunc(formReq, &formBody)
require.NoError(t, err)
require.Equal(t, "John", formBody.Name)
})

// Test POST request with multipart/form-data content type
t.Run("POST multipart/form-data", func(t *testing.T) {
// Create a test multipart form request.
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test.txt")
require.NoError(t, err)

_, err = part.Write([]byte("Test file data"))
require.NoError(t, err)

err = writer.Close()
require.NoError(t, err)

multipartReq, err := http.NewRequest(http.MethodPost, "/data", body)
require.NoError(t, err)
multipartReq.Header.Set("Content-Type", writer.FormDataContentType())

var multipartBody struct {
File binder.FileData `form:"file"`
}
err = binder.BindFunc(multipartReq, &multipartBody)
require.NoError(t, err)
// Add assertions for multipart form data binding
})

// Test invalid content type
t.Run("Invalid content type", func(t *testing.T) {
invalidContentTypeReq, err := http.NewRequest(http.MethodPost, "/data", nil)
require.NoError(t, err)
invalidContentTypeReq.Header.Set("Content-Type", "text/plain")
var invalidContentTypeBody struct{}
err = binder.BindFunc(invalidContentTypeReq, &invalidContentTypeBody)
require.ErrorIs(t, err, binder.ErrInvalidContentType)
})
}
2 changes: 1 addition & 1 deletion const.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ const (
// MultiPartFormMaxMemory is the maximum amount of memory to use when parsing a multipart form.
// It is passed to http.Request.ParseMultipartForm.
// Default value is 32 << 20 (32 MB).
const MultiPartFormMaxMemory int64 = 32 << 20
var MultiPartFormMaxMemory int64 = 32 << 20
2 changes: 2 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ var (
ErrUnsupportedType = errors.New("unsupported type")
ErrTargetMustBeAPointer = errors.New("target must be a pointer")
ErrTargetMustBeAStruct = errors.New("target must be a struct")
ErrInputIsNil = errors.New("input is nil")
ErrDecodeJSON = errors.New("failed to decode json")
)
16 changes: 8 additions & 8 deletions file.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package binder

import (
"fmt"
"errors"
"io"
"mime/multipart"
"net/http"
Expand All @@ -28,20 +28,20 @@ func GetFileData(req *http.Request, fieldName string) (*FileData, error) {
// Parse the multipart form data.
err := req.ParseMultipartForm(32 << 20) // MaxMemory is 32MB
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrParseForm, err.Error())
return nil, errors.Join(ErrParseForm, err)

Check failure on line 31 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

// Get the file from the request.
file, fileHeader, err := req.FormFile(fieldName)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrGetFile, err.Error())
return nil, errors.Join(ErrGetFile, err)

Check failure on line 37 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}
defer file.Close()

// Read the file data into memory.
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrReadFile, err.Error())
return nil, errors.Join(ErrReadFile, err)

Check failure on line 44 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

// Get the MIME type of the file.
Expand All @@ -64,14 +64,14 @@ func GetFileDataFromMultipartFileHeader(header *multipart.FileHeader) (*FileData
// Open the file.
file, err := header.Open()
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrGetFile, err.Error())
return nil, errors.Join(ErrGetFile, err)

Check failure on line 67 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}
defer file.Close()

// Read the file data into memory.
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrReadFile, err.Error())
return nil, errors.Join(ErrReadFile, err)

Check failure on line 74 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

// Get the MIME type of the file.
Expand All @@ -93,12 +93,12 @@ func GetFileDataFromMultipartFileHeader(header *multipart.FileHeader) (*FileData
// github.com/gabriel-vasile/mimetype package.
func GetFileMimeType(input []byte) (string, error) {
if input == nil {
return "", fmt.Errorf("%w: input is nil", ErrGetFileMimeType)
return "", errors.Join(ErrGetFileMimeType, ErrInputIsNil)

Check failure on line 96 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

mtype := mimetype.Detect(input)
if mtype == nil {
return "", fmt.Errorf("%w: unknown MIME type", ErrGetFileMimeType)
return "", errors.Join(ErrGetFileMimeType, ErrUnsupportedType)

Check failure on line 101 in file.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

parts := strings.Split(mtype.String(), ";")
Expand Down
83 changes: 26 additions & 57 deletions file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,37 @@ import (
"testing"

"github.com/dmitrymomot/binder"
"github.com/stretchr/testify/require"
)

func TestGetFileData(t *testing.T) {
// Create a test multipart form request.
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test.txt")
if err != nil {
t.Fatalf("Failed to create form file: %s", err.Error())
}
require.NoError(t, err)

_, err = part.Write([]byte("Test file data"))
if err != nil {
t.Fatalf("Failed to write form file data: %s", err.Error())
}
require.NoError(t, err)

err = writer.Close()
if err != nil {
t.Fatalf("Failed to close multipart writer: %s", err.Error())
}
require.NoError(t, err)

// Create a test HTTP request from the multipart form.
req, err := http.NewRequest(http.MethodPost, "/upload", body)
if err != nil {
t.Fatalf("Failed to create HTTP request: %s", err.Error())
}
require.NoError(t, err)

req.Header.Set("Content-Type", writer.FormDataContentType())

// Call GetFileData with the test request and field name.
fileData, err := binder.GetFileData(req, "file")
if err != nil {
t.Fatalf("Unexpected error from GetFileData: %s", err.Error())
}
require.NoError(t, err)

// Check that the returned file data matches the expected values.
if fileData.Name != "test.txt" {
t.Errorf("Name is %q, expected %q", fileData.Name, "test.txt")
}
if fileData.Size != int64(len("Test file data")) {
t.Errorf("Size is %d, expected %d", fileData.Size, len("Test file data"))
}
if fileData.MimeType != "text/plain" {
t.Errorf("MimeType is %q, expected %q", fileData.MimeType, "text/plain")
}
if string(fileData.Data) != "Test file data" {
t.Errorf("Data is %q, expected %q", string(fileData.Data), "Test file data")
}
require.Equal(t, "test.txt", fileData.Name)
require.Equal(t, int64(len("Test file data")), fileData.Size)
require.Equal(t, "text/plain", fileData.MimeType)
require.Equal(t, []byte("Test file data"), fileData.Data)
}

// TestGetFileData_Image tests the GetFileData function with an image file.
Expand All @@ -62,50 +48,33 @@ func TestGetFileData(t *testing.T) {
func TestGetFileData_Image(t *testing.T) {
// Read the test image file.
testImage, err := os.ReadFile("testdata/test.jpg")
if err != nil {
t.Fatalf("Failed to read test image file: %s", err.Error())
}
require.NoError(t, err)

// Create a test multipart form request.
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test.jpg")
if err != nil {
t.Fatalf("Failed to create form file: %s", err.Error())
}
require.NoError(t, err)

_, err = part.Write(testImage)
if err != nil {
t.Fatalf("Failed to write form file data: %s", err.Error())
}
require.NoError(t, err)

err = writer.Close()
if err != nil {
t.Fatalf("Failed to close multipart writer: %s", err.Error())
}
require.NoError(t, err)

// Create a test HTTP request from the multipart form.
req, err := http.NewRequest(http.MethodPost, "/upload", body)
if err != nil {
t.Fatalf("Failed to create HTTP request: %s", err.Error())
}
require.NoError(t, err)

req.Header.Set("Content-Type", writer.FormDataContentType())

// Call GetFileData with the test request and field name.
fileData, err := binder.GetFileData(req, "file")
if err != nil {
t.Fatalf("Unexpected error from GetFileData: %s", err.Error())
}
require.NoError(t, err)

// Check that the returned file data matches the expected values.
if fileData.Name != "test.jpg" {
t.Errorf("Name is %q, expected %q", fileData.Name, "test.jpg")
}
if fileData.Size != int64(len(testImage)) {
t.Errorf("Size is %d, expected %d", fileData.Size, len(testImage))
}
if fileData.MimeType != "image/jpeg" {
t.Errorf("MimeType is %q, expected %q", fileData.MimeType, "image/jpeg")
}
if !bytes.Equal(fileData.Data, testImage) {
t.Errorf("Data is %q, expected %q", string(fileData.Data), "Test file data")
}
require.Equal(t, "test.jpg", fileData.Name)
require.Equal(t, int64(len(testImage)), fileData.Size)
require.Equal(t, "image/jpeg", fileData.MimeType)
require.Equal(t, testImage, fileData.Data)
}
7 changes: 4 additions & 3 deletions form.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package binder

import (
"errors"
"fmt"
"net/http"
)
Expand All @@ -22,7 +23,7 @@ func BindForm(r *http.Request, v interface{}) error {

// Validate v pointer before decoding query into it
if !isPointer(v) {
return fmt.Errorf("%w: v must be a pointer to a struct", ErrInvalidInput)
return errors.Join(ErrInvalidInput, ErrTargetMustBeAPointer)

Check failure on line 26 in form.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

// Check if the request body is empty
Expand All @@ -32,12 +33,12 @@ func BindForm(r *http.Request, v interface{}) error {

// Parse the request body
if err := r.ParseForm(); err != nil {
return fmt.Errorf("%w: %s", ErrParseForm, err.Error())
return errors.Join(ErrParseForm, err)

Check failure on line 36 in form.go

View workflow job for this annotation

GitHub Actions / golangci

undefined: errors.Join
}

// Decode the request body into the v pointer
if err := formDecoder.Decode(v, r.PostForm); err != nil {
return fmt.Errorf("%w: %s", ErrDecodeForm, err.Error())
return errors.Join(ErrDecodeForm, err)
}

return nil
Expand Down

0 comments on commit e3ecf09

Please sign in to comment.