├── go.mod ├── .travis.yml ├── LICENSE ├── go.sum ├── README.md ├── form.go └── form_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module clevergo.tech/form 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/gorilla/schema v1.1.0 7 | github.com/stretchr/testify v1.5.1 8 | ) 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | language: go 4 | go: 5 | - 1.13.x 6 | - 1.14.x 7 | - 1.15.x 8 | - master 9 | jobs: 10 | allow_failures: 11 | - go: master 12 | fast_finish: true 13 | before_install: 14 | - go get github.com/mattn/goveralls 15 | script: 16 | - go test -v -covermode=count -coverprofile=coverage.out 17 | - go vet ./... 18 | - test -z "$(gofmt -d -s . | tee /dev/stderr)" 19 | - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 CleverGo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= 4 | github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 10 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 14 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form Decoder 2 | [![Build Status](https://img.shields.io/travis/clevergo/form?style=flat-square)](https://travis-ci.org/clevergo/form) 3 | [![Coverage Status](https://img.shields.io/coveralls/github/clevergo/form?style=flat-square)](https://coveralls.io/github/clevergo/form?branch=master) 4 | [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-blue?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/clevergo.tech/form?tab=doc) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/clevergo/form?style=flat-square)](https://goreportcard.com/report/github.com/clevergo/form) 6 | [![Release](https://img.shields.io/github/release/clevergo/form.svg?style=flat-square)](https://github.com/clevergo/form/releases) 7 | [![Downloads](https://img.shields.io/endpoint?url=https://pkg.clevergo.tech/api/badges/downloads/total/clevergo.tech/form&style=flat-square)](https://pkg.clevergo.tech/) 8 | [![Chat](https://img.shields.io/badge/chat-telegram-blue?style=flat-square)](https://t.me/clevergotech) 9 | [![Community](https://img.shields.io/badge/community-forum-blue?style=flat-square&color=orange)](https://forum.clevergo.tech) 10 | 11 | A form decoder that decode request body of any types(xml, json, form, multipart form...) into a sctruct by same codebase. 12 | 13 | By default, form decoder can handles the following content types: 14 | 15 | - Form(application/x-www-form-urlencoded) 16 | - Multipart Form(multipart/form-data) 17 | - JSON(application/json) 18 | - XML(application/xml) 19 | 20 | > Form and multipart form are built on top of gorilla [schema](https://github.com/gorilla/schema), tag name is `schema`. 21 | 22 | [Register](https://pkg.go.dev/clevergo.tech/form?tab=doc#Decoders.Register) allow to register particular decoder or replace default decoder 23 | for the specified content type. 24 | 25 | ## Installation 26 | 27 | ```go 28 | $ go get clevergo.tech/form 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```go 34 | import ( 35 | "net/http" 36 | 37 | "clevergo.tech/form" 38 | ) 39 | 40 | var decoders = form.New() 41 | 42 | type user struct { 43 | Username string `schema:"username" json:"username" xml:"username"` 44 | Password string `schema:"password" json:"password" xml:"password"` 45 | } 46 | 47 | func init() { 48 | // replaces multipart form decoder. 49 | decoders.Register(form.ContentTypeMultipartForm, form.NewMultipartForm(10*1024*1024)) 50 | // registers other decoder 51 | // decoders.Register(contentType, decoder) 52 | } 53 | 54 | func(w http.ResponseWriter, r *http.Request) { 55 | u := user{} 56 | if err := decoders.Decode(r, &u); err != nil { 57 | http.Error(w, http.StatusInternalServerError, err.Error()) 58 | return 59 | } 60 | // ... 61 | } 62 | ``` 63 | 64 | ### Example 65 | 66 | Checkout [example](https://github.com/clevergo/examples/tree/master/form) for details. 67 | -------------------------------------------------------------------------------- /form.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 CleverGo. All rights reserved. 2 | // Use of this source code is governed by a MIT style license that can be found 3 | // in the LICENSE file. 4 | 5 | // Package form is a form decoder that decode request body into a struct. 6 | package form 7 | 8 | import ( 9 | "bytes" 10 | "encoding/json" 11 | "encoding/xml" 12 | "errors" 13 | "io" 14 | "io/ioutil" 15 | "mime" 16 | "net/http" 17 | 18 | "github.com/gorilla/schema" 19 | ) 20 | 21 | // Validatable indicates whether a value can be validated. 22 | type Validatable interface { 23 | Validate() error 24 | } 25 | 26 | // Content type constants. 27 | const ( 28 | ContentType = "Content-Type" 29 | ContentTypeForm = "application/x-www-form-urlencoded" 30 | ContentTypeMultipartForm = "multipart/form-data" 31 | ContentTypeJSON = "application/json" 32 | ContentTypeXML = "application/xml" 33 | ) 34 | 35 | var ( 36 | defaultDecoders *Decoders 37 | defaultDecoder = schema.NewDecoder() 38 | defaultMaxMemory int64 = 10 * 1024 * 1024 39 | ) 40 | 41 | func init() { 42 | defaultDecoder.IgnoreUnknownKeys(true) 43 | 44 | defaultDecoders = New() 45 | } 46 | 47 | // New returns a decoders. 48 | func New() *Decoders { 49 | d := &Decoders{} 50 | d.Register(ContentTypeForm, NewForm(defaultDecoder)) 51 | d.Register(ContentTypeMultipartForm, NewMultipartForm(defaultMaxMemory)) 52 | d.Register(ContentTypeJSON, JSON) 53 | d.Register(ContentTypeXML, XML) 54 | return d 55 | } 56 | 57 | // Register a decoder for the given content type. 58 | func Register(contentType string, decoder Decoder) { 59 | defaultDecoders.Register(contentType, decoder) 60 | } 61 | 62 | // Decode data from a request into v, v should be a pointer. 63 | func Decode(r *http.Request, v interface{}) error { 64 | return defaultDecoders.Decode(r, v) 65 | } 66 | 67 | // Decoders is a map that mapping from content type to decoder. 68 | type Decoders map[string]Decoder 69 | 70 | // Register a decoder for the given content type. 71 | func (d *Decoders) Register(contentType string, decoder Decoder) { 72 | (*d)[contentType] = decoder 73 | } 74 | 75 | // Decode data from a request into v, v should be a pointer. 76 | func (d *Decoders) Decode(r *http.Request, v interface{}) error { 77 | contentType, err := parseContentType(r) 78 | if err != nil { 79 | return err 80 | } 81 | decoder, ok := (*d)[contentType] 82 | if !ok { 83 | return errors.New("Unsupported content type: " + contentType) 84 | } 85 | if err = decoder(r, v); err != nil { 86 | return err 87 | } 88 | if vv, ok := v.(Validatable); ok { 89 | err = vv.Validate() 90 | } 91 | 92 | return err 93 | } 94 | 95 | // Decoder is a function that decode data from request into v. 96 | type Decoder func(req *http.Request, v interface{}) error 97 | 98 | func parseContentType(r *http.Request) (string, error) { 99 | header := r.Header.Get(ContentType) 100 | contentType, _, err := mime.ParseMediaType(header) 101 | if err != nil { 102 | return "", err 103 | } 104 | 105 | return contentType, nil 106 | } 107 | 108 | // JSON is a JSON decoder. 109 | func JSON(r *http.Request, v interface{}) error { 110 | var buf bytes.Buffer 111 | reader := io.TeeReader(r.Body, &buf) 112 | r.Body = ioutil.NopCloser(&buf) 113 | return json.NewDecoder(reader).Decode(v) 114 | } 115 | 116 | // XML is an XML decoder. 117 | func XML(r *http.Request, v interface{}) error { 118 | var buf bytes.Buffer 119 | reader := io.TeeReader(r.Body, &buf) 120 | r.Body = ioutil.NopCloser(&buf) 121 | return xml.NewDecoder(reader).Decode(v) 122 | } 123 | 124 | // NewForm returns a post form decoder with the given schema decoder. 125 | func NewForm(decoder *schema.Decoder) Decoder { 126 | return func(r *http.Request, v interface{}) error { 127 | err := r.ParseForm() 128 | if err != nil { 129 | return err 130 | } 131 | 132 | return decoder.Decode(v, r.PostForm) 133 | } 134 | } 135 | 136 | // NewMultipartForm returns a multipart form decoder with the given schema decoder. 137 | func NewMultipartForm(maxMemory int64) Decoder { 138 | return func(r *http.Request, v interface{}) error { 139 | err := r.ParseMultipartForm(maxMemory) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | return defaultDecoder.Decode(v, r.MultipartForm.Value) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /form_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 CleverGo. All rights reserved. 2 | // Use of this source code is governed by a MIT style license that can be found 3 | // in the LICENSE file. 4 | 5 | package form 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "encoding/xml" 11 | "net/http" 12 | "net/http/httptest" 13 | "strings" 14 | "testing" 15 | 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | type login struct { 20 | validated bool 21 | Username string `schema:"username" json:"username" xml:"username"` 22 | Password string `schema:"password" json:"password" xml:"password"` 23 | } 24 | 25 | func (l *login) Validate() error { 26 | l.validated = true 27 | return nil 28 | } 29 | 30 | func TestRegister(t *testing.T) { 31 | invoked := false 32 | decoder := func(r *http.Request, v interface{}) error { 33 | invoked = true 34 | return nil 35 | } 36 | contentType := "content/type" 37 | Register(contentType, decoder) 38 | actual, ok := (*defaultDecoders)[contentType] 39 | assert.True(t, ok) 40 | err := actual(httptest.NewRequest(http.MethodGet, "/", nil), nil) 41 | assert.Nil(t, err) 42 | assert.True(t, invoked) 43 | } 44 | 45 | func TestJSON(t *testing.T) { 46 | tests := []struct { 47 | shouldErr bool 48 | body []byte 49 | }{ 50 | {true, []byte(`{"invalid json"}`)}, 51 | {false, []byte(`{}`)}, 52 | {false, []byte(`{"username": "foo"}`)}, 53 | {false, []byte(`{"password": "bar"}`)}, 54 | {false, []byte(`{"username": "foo","password": "bar"}`)}, 55 | } 56 | 57 | for _, test := range tests { 58 | actual := login{} 59 | r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(test.body)) 60 | r.Header.Set("Content-Type", ContentTypeJSON) 61 | for i := 0; i < 3; i++ { 62 | err := Decode(r, &actual) 63 | if test.shouldErr { 64 | assert.NotNil(t, err) 65 | continue 66 | } 67 | assert.Nil(t, err) 68 | expected := login{validated: true} 69 | assert.Nil(t, json.Unmarshal(test.body, &expected)) 70 | assert.Equal(t, expected, actual) 71 | } 72 | } 73 | } 74 | 75 | func TestXML(t *testing.T) { 76 | tests := []struct { 77 | shouldErr bool 78 | body []byte 79 | }{ 80 | {true, []byte(`invalid xml`)}, 81 | {false, []byte(``)}, 82 | {false, []byte(`foo`)}, 83 | {false, []byte(`bar>`)}, 84 | {false, []byte(`foobar>`)}, 85 | } 86 | 87 | for _, test := range tests { 88 | actual := login{} 89 | r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(test.body)) 90 | r.Header.Set("Content-Type", ContentTypeXML) 91 | for i := 0; i < 3; i++ { 92 | err := Decode(r, &actual) 93 | if test.shouldErr { 94 | assert.NotNil(t, err) 95 | continue 96 | } 97 | assert.Nil(t, err) 98 | expected := login{validated: true} 99 | assert.Nil(t, xml.Unmarshal(test.body, &expected)) 100 | assert.Equal(t, expected, actual) 101 | } 102 | } 103 | } 104 | 105 | var formData = map[string][]string{ 106 | "username": {"foo"}, 107 | "password": {"bar"}, 108 | } 109 | 110 | func TestForm(t *testing.T) { 111 | expected := login{validated: true} 112 | if err := defaultDecoder.Decode(&expected, formData); err != nil { 113 | t.Fatal(err.Error()) 114 | } 115 | 116 | actual := login{validated: true} 117 | r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("username=foo&password=bar")) 118 | r.Header.Set("Content-Type", ContentTypeForm) 119 | assert.Nil(t, Decode(r, &actual)) 120 | assert.Equal(t, expected, actual) 121 | } 122 | 123 | func TestMultipartForm(t *testing.T) { 124 | expected := login{} 125 | err := defaultDecoder.Decode(&expected, formData) 126 | assert.Nil(t, err) 127 | 128 | actual := login{} 129 | postData := 130 | `--xxx 131 | Content-Disposition: form-data; name="username" 132 | 133 | foo 134 | --xxx 135 | Content-Disposition: form-data; name="password" 136 | 137 | bar 138 | --xxx-- 139 | ` 140 | r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(postData)) 141 | r.Header.Set("Content-Type", ContentTypeMultipartForm+"; boundary=xxx") 142 | assert.Nil(t, Decode(r, &actual)) 143 | assert.True(t, actual.validated) 144 | assert.Equal(t, expected.Username, actual.Username) 145 | assert.Equal(t, expected.Password, actual.Password) 146 | } 147 | 148 | func TestParseContentType(t *testing.T) { 149 | tests := []struct { 150 | contentType string 151 | shouldError bool 152 | expectedContentType string 153 | }{ 154 | {"application/json", false, ContentTypeJSON}, 155 | {"application/json; charset=utf-8", false, ContentTypeJSON}, 156 | {"application/xml", false, ContentTypeXML}, 157 | {"application/xml; charset=utf-8", false, ContentTypeXML}, 158 | {"application/x-www-form-urlencoded", false, ContentTypeForm}, 159 | {"multipart/form-data", false, ContentTypeMultipartForm}, 160 | {"", true, ""}, 161 | } 162 | 163 | for _, test := range tests { 164 | r := httptest.NewRequest(http.MethodGet, "/", nil) 165 | r.Header.Set(ContentType, test.contentType) 166 | contentType, err := parseContentType(r) 167 | if test.shouldError { 168 | assert.NotNil(t, err) 169 | } else { 170 | assert.Equal(t, test.expectedContentType, contentType) 171 | } 172 | } 173 | } 174 | 175 | func TestDecode(t *testing.T) { 176 | delete((*defaultDecoders), "application/json") 177 | req := httptest.NewRequest(http.MethodPost, "/login", nil) 178 | req.Header.Set("Content-Type", "application/json") 179 | v := login{} 180 | err := Decode(req, &v) 181 | assert.NotNil(t, err) 182 | } 183 | 184 | func TestNewMultipartForm(t *testing.T) { 185 | tests := []struct { 186 | maxMemory int64 187 | contentType string 188 | shouldError bool 189 | }{ 190 | {1024, ContentTypeMultipartForm + "; boundary=xxx", false}, 191 | {1024, "", true}, 192 | } 193 | for _, test := range tests { 194 | decoder := NewMultipartForm(test.maxMemory) 195 | v := login{} 196 | body := strings.NewReader(`--xxx--`) 197 | req := httptest.NewRequest(http.MethodPost, "/login", body) 198 | req.Header.Set("Content-Type", test.contentType) 199 | err := decoder(req, &v) 200 | if test.shouldError { 201 | assert.NotNil(t, err) 202 | } 203 | } 204 | } 205 | --------------------------------------------------------------------------------