├── LICENSE ├── README.md ├── bind_test.go ├── binding.go ├── common_test.go ├── errorhandler_test.go ├── errors.go ├── errors_test.go ├── file_test.go ├── form_test.go ├── json_test.go ├── misc_test.go ├── multipart_test.go ├── validate_test.go └── wercker.yml /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Jeremy Saenz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # binding [![wercker status](https://app.wercker.com/status/f00480949f8a4e4130557f802c5b5b6b "wercker status")](https://app.wercker.com/project/bykey/f00480949f8a4e4130557f802c5b5b6b) 2 | 3 | Request data binding and validation for Martini. 4 | 5 | [![GoDoc](https://godoc.org/github.com/martini-contrib/binding?status.svg)](https://godoc.org/github.com/martini-contrib/binding) 6 | 7 | ## Features 8 | 9 | - Automatically converts data from a request into a struct ready for your application 10 | - Supports form, JSON, and multipart form data (including file uploads) 11 | - Can use interfaces 12 | - Provides data validation facilities 13 | - Enforces required fields 14 | - Invoke your own data validator 15 | - Built-in error handling (or use your own) 16 | - 99% test coverage 17 | 18 | 19 | ## Usage 20 | 21 | #### Getting form data from a request 22 | 23 | Suppose you have a contact form on your site where at least name and message are required. We'll need a struct to receive the data: 24 | 25 | ```go 26 | type ContactForm struct { 27 | Name string `form:"name" binding:"required"` 28 | Email string `form:"email"` 29 | Message string `form:"message" binding:"required"` 30 | } 31 | ``` 32 | 33 | Then we simply add our route in Martini: 34 | 35 | ```go 36 | m.Post("/contact/submit", binding.Bind(ContactForm{}), func(contact ContactForm) string { 37 | return fmt.Sprintf("Name: %s\nEmail: %s\nMessage: %s\n", 38 | contact.Name, contact.Email, contact.Message) 39 | }) 40 | ``` 41 | 42 | That's it! The `binding.Bind` function takes care of validating required fields. If there are any errors (like a required field is empty), `binding` will return an error to the client and your app won't even see the request. 43 | 44 | (Caveat: Don't try to bind to embedded struct pointers; it won't work. See [issue 30](https://github.com/martini-contrib/binding/issues/30) if you want to help with this.) 45 | 46 | 47 | #### Getting JSON data from a request 48 | 49 | To get data from JSON payloads, simply use the `json:` struct tags instead of `form:`. Pro Tip: Use [JSON-to-Go](http://mholt.github.io/json-to-go/) to correctly convert JSON to a Go type definition. It's useful if you're new to this or the structure is large/complex. 50 | 51 | 52 | 53 | #### Custom validation 54 | 55 | If you want additional validation beyond just checking required fields, your struct can implement the `binding.Validator` interface like so: 56 | 57 | ```go 58 | func (cf ContactForm) Validate(errors binding.Errors, req *http.Request) binding.Errors { 59 | if strings.Contains(cf.Message, "Go needs generics") { 60 | errors = append(errors, binding.Error{ 61 | FieldNames: []string{"message"}, 62 | Classification: "ComplaintError", 63 | Message: "Go has generics. They're called interfaces.", 64 | }) 65 | } 66 | return errors 67 | } 68 | ``` 69 | 70 | Now, any contact form submissions with "Go needs generics" in the message will return an error explaining your folly. 71 | 72 | 73 | #### Binding to interfaces 74 | 75 | If you'd like to bind the data to an interface rather than to a concrete struct, you can specify the interface and use it like this: 76 | 77 | ```go 78 | m.Post("/contact/submit", binding.Bind(ContactForm{}, (*MyInterface)(nil)), func(contact MyInterface) { 79 | // ... your struct became an interface! 80 | }) 81 | ``` 82 | 83 | 84 | 85 | ## Description of Handlers 86 | 87 | Each of these middleware handlers are independent and optional, though be aware that some handlers invoke other ones. 88 | 89 | 90 | ### Bind 91 | 92 | `binding.Bind` is a convenient wrapper over the other handlers in this package. It does the following boilerplate for you: 93 | 94 | 1. Deserializes request data into a struct 95 | 2. Performs validation with `binding.Validate` 96 | 3. Bails out with `binding.ErrorHandler` if there are any errors 97 | 98 | Your application (the final handler) will not even see the request if there are any errors. 99 | 100 | Content-Type will be used to know how to deserialize the requests. 101 | 102 | **Important safety tip:** Don't attempt to bind a pointer to a struct. This will cause a panic [to prevent a race condition](https://github.com/codegangsta/martini-contrib/pull/34#issuecomment-29683659) where every request would be pointing to the same struct. 103 | 104 | 105 | ### Form 106 | 107 | `binding.Form` deserializes form data from the request, whether in the query string or as a form-urlencoded payload. It only does these things: 108 | 109 | 1. Deserializes request data into a struct 110 | 2. Performs validation with `binding.Validate` 111 | 112 | Note that it does not handle errors. You may receive a `binding.Errors` into your own handler if you want to handle errors. (For automatic error handling, use `binding.Bind`.) 113 | 114 | 115 | 116 | ### MultipartForm and file uploads 117 | 118 | Like `binding.Form`, `binding.MultipartForm` deserializes form data from a request into the struct you pass in. Additionally, this will deserialize a POST request that has a form of *enctype="multipart/form-data"*. If the bound struct contains a field of type [`*multipart.FileHeader`](http://golang.org/pkg/mime/multipart/#FileHeader) (or `[]*multipart.FileHeader`), you also can read any uploaded files that were part of the form. 119 | 120 | This handler does the following: 121 | 122 | 1. Deserializes request data into a struct 123 | 2. Performs validation with `binding.Validate` 124 | 125 | Again, like `binding.Form`, no error handling is performed, but you can get the errors in your handler by receiving a `binding.Errors` type. 126 | 127 | #### MultipartForm example 128 | 129 | ```go 130 | type UploadForm struct { 131 | Title string `form:"title"` 132 | TextUpload *multipart.FileHeader `form:"txtUpload"` 133 | } 134 | 135 | func main() { 136 | m := martini.Classic() 137 | m.Post("/", binding.MultipartForm(UploadForm{}), uploadHandler(uf UploadForm) string { 138 | file, err := uf.TextUpload.Open() 139 | // ... you can now read the uploaded file 140 | }) 141 | m.Run() 142 | } 143 | ``` 144 | 145 | 146 | ### Json 147 | 148 | `binding.Json` deserializes JSON data in the payload of the request. It does the following things: 149 | 150 | 1. Deserializes request data into a struct 151 | 2. Performs validation with `binding.Validate` 152 | 153 | Similar to `binding.Form`, no error handling is performed, but you can get the errors and handle them yourself. 154 | 155 | 156 | 157 | ### Validate 158 | 159 | `binding.Validate` receives a populated struct and checks it for errors, first by enforcing the `binding:"required"` value on struct field tags, then by executing the `Validate()` method on the struct, if it is a `binding.Validator`. 160 | 161 | *Note:* Marking a field as "required" means that you do not allow the zero value for that type (i.e. if you want to allow 0 in an int field, do not make it required). 162 | 163 | *Sanitizing:* If you'd like more powerful validation by sanitizing the input, take a look at [jamieomatthews/martini-validate](https://github.com/jamieomatthews/martini-validate) which has a few useful validate functions built-in. 164 | 165 | 166 | ### ErrorHandler 167 | 168 | `binding.ErrorHandler` is a small middleware that simply writes an error code to the response and also a JSON payload describing the errors, *if* any errors have been mapped to the context. It does nothing if there are no errors. 169 | 170 | - Deserialization errors yield a 400 171 | - Content-Type errors yield a 415 172 | - Any other kinds of errors (including your own) yield a 422 (Unprocessable Entity) 173 | 174 | 175 | 176 | ## Contributing 177 | 178 | Hey, cool! Let's make this package even better. We have several goals for this package as a community: 179 | 180 | - Lightweight (small) 181 | - Tightly focused (doesn't branch into other areas of concern) 182 | - Performant (yeah, it uses reflection, so we could improve here) 183 | - 100% test coverage (or close to it) 184 | 185 | Adding more features tends to chip away at each of these goals, but let's discuss them anyway: don't feel like you can't recommend or ask something. We all want the best possible binding package. 186 | 187 | Bug fixes will be accepted any time as pull requests, as long as tests assert correct behavior. Thanks for getting involved! 188 | 189 | 190 | ## Primary Authors 191 | 192 | - [Matthew Holt](https://github.com/mholt) 193 | - [Jeremy Saenz](https://github.com/codegangsta) 194 | 195 | 196 | #### Thanks to all [contributors](https://github.com/martini-contrib/binding/graphs/contributors)! 197 | -------------------------------------------------------------------------------- /bind_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import "testing" 4 | 5 | func TestBind(t *testing.T) { 6 | for _, testCase := range formTestCases { 7 | performFormTest(t, Bind, testCase) 8 | } 9 | for _, testCase := range jsonTestCases { 10 | performJsonTest(t, Bind, testCase) 11 | } 12 | for _, testCase := range multipartFormTestCases { 13 | performMultipartFormTest(t, Bind, testCase) 14 | } 15 | for _, testCase := range fileTestCases { 16 | performFileTest(t, Bind, testCase) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /binding.go: -------------------------------------------------------------------------------- 1 | // Package binding transforms a raw request into a struct 2 | // ready to be used your application. It can also perform 3 | // validation on the data and handle errors. 4 | package binding 5 | 6 | import ( 7 | "encoding/json" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "reflect" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/go-martini/martini" 16 | ) 17 | 18 | /* 19 | For the land of Middle-ware Earth: 20 | One func to rule them all, 21 | One func to find them, 22 | One func to bring them all, 23 | And in this package BIND them. 24 | */ 25 | 26 | // Bind wraps up the functionality of the Form and Json middleware 27 | // according to the Content-Type and verb of the request. 28 | // A Content-Type is required for POST, PUT and PATCH requests. 29 | // Bind invokes the ErrorHandler middleware to bail out if errors 30 | // occurred. If you want to perform your own error handling, use 31 | // Form or Json middleware directly. An interface pointer can 32 | // be added as a second argument in order to map the struct to 33 | // a specific interface. 34 | func Bind(obj interface{}, ifacePtr ...interface{}) martini.Handler { 35 | return func(context martini.Context, req *http.Request) { 36 | contentType := req.Header.Get("Content-Type") 37 | 38 | if req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH" || contentType != "" { 39 | if strings.Contains(contentType, "form-urlencoded") { 40 | context.Invoke(Form(obj, ifacePtr...)) 41 | } else if strings.Contains(contentType, "multipart/form-data") { 42 | context.Invoke(MultipartForm(obj, ifacePtr...)) 43 | } else if strings.Contains(contentType, "json") { 44 | context.Invoke(Json(obj, ifacePtr...)) 45 | } else { 46 | var errors Errors 47 | if contentType == "" { 48 | errors.Add([]string{}, ContentTypeError, "Empty Content-Type") 49 | } else { 50 | errors.Add([]string{}, ContentTypeError, "Unsupported Content-Type") 51 | } 52 | context.Map(errors) 53 | } 54 | } else { 55 | context.Invoke(Form(obj, ifacePtr...)) 56 | } 57 | 58 | context.Invoke(ErrorHandler) 59 | } 60 | } 61 | 62 | // Form is middleware to deserialize form-urlencoded data from the request. 63 | // It gets data from the form-urlencoded body, if present, or from the 64 | // query string. It uses the http.Request.ParseForm() method 65 | // to perform deserialization, then reflection is used to map each field 66 | // into the struct with the proper type. Structs with primitive slice types 67 | // (bool, float, int, string) can support deserialization of repeated form 68 | // keys, for example: key=val1&key=val2&key=val3 69 | // An interface pointer can be added as a second argument in order 70 | // to map the struct to a specific interface. 71 | func Form(formStruct interface{}, ifacePtr ...interface{}) martini.Handler { 72 | return func(context martini.Context, req *http.Request) { 73 | var errors Errors 74 | 75 | ensureNotPointer(formStruct) 76 | formStruct := reflect.New(reflect.TypeOf(formStruct)) 77 | parseErr := req.ParseForm() 78 | 79 | // Format validation of the request body or the URL would add considerable overhead, 80 | // and ParseForm does not complain when URL encoding is off. 81 | // Because an empty request body or url can also mean absence of all needed values, 82 | // it is not in all cases a bad request, so let's return 422. 83 | if parseErr != nil { 84 | errors.Add([]string{}, DeserializationError, parseErr.Error()) 85 | } 86 | mapForm(formStruct, req.Form, nil, errors) 87 | validateAndMap(formStruct, context, errors, ifacePtr...) 88 | } 89 | } 90 | 91 | // MultipartForm works much like Form, except it can parse multipart forms 92 | // and handle file uploads. Like the other deserialization middleware handlers, 93 | // you can pass in an interface to make the interface available for injection 94 | // into other handlers later. 95 | func MultipartForm(formStruct interface{}, ifacePtr ...interface{}) martini.Handler { 96 | return func(context martini.Context, req *http.Request) { 97 | var errors Errors 98 | 99 | ensureNotPointer(formStruct) 100 | formStruct := reflect.New(reflect.TypeOf(formStruct)) 101 | 102 | // This if check is necessary due to https://github.com/martini-contrib/csrf/issues/6 103 | if req.MultipartForm == nil { 104 | // Workaround for multipart forms returning nil instead of an error 105 | // when content is not multipart; see https://code.google.com/p/go/issues/detail?id=6334 106 | if multipartReader, err := req.MultipartReader(); err != nil { 107 | // TODO: Cover this and the next error check with tests 108 | errors.Add([]string{}, DeserializationError, err.Error()) 109 | } else { 110 | form, parseErr := multipartReader.ReadForm(MaxMemory) 111 | if parseErr != nil { 112 | errors.Add([]string{}, DeserializationError, parseErr.Error()) 113 | } 114 | req.MultipartForm = form 115 | } 116 | } 117 | 118 | mapForm(formStruct, req.MultipartForm.Value, req.MultipartForm.File, errors) 119 | validateAndMap(formStruct, context, errors, ifacePtr...) 120 | } 121 | 122 | } 123 | 124 | // Json is middleware to deserialize a JSON payload from the request 125 | // into the struct that is passed in. The resulting struct is then 126 | // validated, but no error handling is actually performed here. 127 | // An interface pointer can be added as a second argument in order 128 | // to map the struct to a specific interface. 129 | func Json(jsonStruct interface{}, ifacePtr ...interface{}) martini.Handler { 130 | return func(context martini.Context, req *http.Request) { 131 | var errors Errors 132 | 133 | ensureNotPointer(jsonStruct) 134 | 135 | jsonStruct := reflect.New(reflect.TypeOf(jsonStruct)) 136 | 137 | if req.Body != nil { 138 | defer req.Body.Close() 139 | err := json.NewDecoder(req.Body).Decode(jsonStruct.Interface()) 140 | if err != nil && err != io.EOF { 141 | errors.Add([]string{}, DeserializationError, err.Error()) 142 | } 143 | } 144 | 145 | validateAndMap(jsonStruct, context, errors, ifacePtr...) 146 | } 147 | } 148 | 149 | // Validate is middleware to enforce required fields. If the struct 150 | // passed in implements Validator, then the user-defined Validate method 151 | // is executed, and its errors are mapped to the context. This middleware 152 | // performs no error handling: it merely detects errors and maps them. 153 | func Validate(obj interface{}) martini.Handler { 154 | return func(context martini.Context, req *http.Request) { 155 | var errors Errors 156 | 157 | v := reflect.ValueOf(obj) 158 | k := v.Kind() 159 | 160 | if k == reflect.Interface || k == reflect.Ptr { 161 | 162 | v = v.Elem() 163 | k = v.Kind() 164 | } 165 | 166 | if k == reflect.Slice || k == reflect.Array { 167 | 168 | for i := 0; i < v.Len(); i++ { 169 | 170 | e := v.Index(i).Interface() 171 | errors = validateStruct(errors, e) 172 | 173 | if validator, ok := e.(Validator); ok { 174 | errors = validator.Validate(errors, req) 175 | } 176 | } 177 | } else { 178 | 179 | errors = validateStruct(errors, obj) 180 | 181 | if validator, ok := obj.(Validator); ok { 182 | errors = validator.Validate(errors, req) 183 | } 184 | } 185 | context.Map(errors) 186 | } 187 | } 188 | 189 | // Performs required field checking on a struct 190 | func validateStruct(errors Errors, obj interface{}) Errors { 191 | typ := reflect.TypeOf(obj) 192 | val := reflect.ValueOf(obj) 193 | 194 | if typ.Kind() == reflect.Ptr { 195 | typ = typ.Elem() 196 | val = val.Elem() 197 | } 198 | 199 | for i := 0; i < typ.NumField(); i++ { 200 | field := typ.Field(i) 201 | 202 | // Skip ignored and unexported fields in the struct 203 | if field.Tag.Get("form") == "-" || !val.Field(i).CanInterface() { 204 | continue 205 | } 206 | 207 | fieldValue := val.Field(i).Interface() 208 | zero := reflect.Zero(field.Type).Interface() 209 | 210 | // Validate nested and embedded structs (if pointer, only do so if not nil) 211 | if field.Type.Kind() == reflect.Struct || 212 | (field.Type.Kind() == reflect.Ptr && !reflect.DeepEqual(zero, fieldValue) && 213 | field.Type.Elem().Kind() == reflect.Struct) { 214 | errors = validateStruct(errors, fieldValue) 215 | } 216 | 217 | if strings.Index(field.Tag.Get("binding"), "required") > -1 { 218 | if reflect.DeepEqual(zero, fieldValue) { 219 | name := field.Name 220 | if j := field.Tag.Get("json"); j != "" { 221 | name = j 222 | } else if f := field.Tag.Get("form"); f != "" { 223 | name = f 224 | } 225 | errors.Add([]string{name}, RequiredError, "Required") 226 | } 227 | } 228 | } 229 | return errors 230 | } 231 | 232 | // Takes values from the form data and puts them into a struct 233 | func mapForm(formStruct reflect.Value, form map[string][]string, 234 | formfile map[string][]*multipart.FileHeader, errors Errors) { 235 | 236 | if formStruct.Kind() == reflect.Ptr { 237 | formStruct = formStruct.Elem() 238 | } 239 | typ := formStruct.Type() 240 | 241 | for i := 0; i < typ.NumField(); i++ { 242 | typeField := typ.Field(i) 243 | structField := formStruct.Field(i) 244 | 245 | if typeField.Type.Kind() == reflect.Ptr && typeField.Anonymous { 246 | structField.Set(reflect.New(typeField.Type.Elem())) 247 | mapForm(structField.Elem(), form, formfile, errors) 248 | if reflect.DeepEqual(structField.Elem().Interface(), reflect.Zero(structField.Elem().Type()).Interface()) { 249 | structField.Set(reflect.Zero(structField.Type())) 250 | } 251 | } else if typeField.Type.Kind() == reflect.Struct { 252 | mapForm(structField, form, formfile, errors) 253 | } else if inputFieldName := typeField.Tag.Get("form"); inputFieldName != "" { 254 | if !structField.CanSet() { 255 | continue 256 | } 257 | 258 | inputValue, exists := form[inputFieldName] 259 | inputFile, existsFile := formfile[inputFieldName] 260 | if exists && !existsFile { 261 | numElems := len(inputValue) 262 | if structField.Kind() == reflect.Slice && numElems > 0 { 263 | sliceOf := structField.Type().Elem().Kind() 264 | slice := reflect.MakeSlice(structField.Type(), numElems, numElems) 265 | for i := 0; i < numElems; i++ { 266 | setWithProperType(sliceOf, inputValue[i], slice.Index(i), inputFieldName, errors) 267 | } 268 | formStruct.Field(i).Set(slice) 269 | } else { 270 | kind := typeField.Type.Kind() 271 | if structField.Kind() == reflect.Ptr { 272 | structField.Set(reflect.New(typeField.Type.Elem())) 273 | structField = structField.Elem() 274 | kind = typeField.Type.Elem().Kind() 275 | } 276 | setWithProperType(kind, inputValue[0], structField, inputFieldName, errors) 277 | } 278 | continue 279 | } 280 | 281 | if !existsFile { 282 | continue 283 | } 284 | fhType := reflect.TypeOf((*multipart.FileHeader)(nil)) 285 | numElems := len(inputFile) 286 | if structField.Kind() == reflect.Slice && numElems > 0 && structField.Type().Elem() == fhType { 287 | slice := reflect.MakeSlice(structField.Type(), numElems, numElems) 288 | for i := 0; i < numElems; i++ { 289 | slice.Index(i).Set(reflect.ValueOf(inputFile[i])) 290 | } 291 | structField.Set(slice) 292 | } else if structField.Type() == fhType { 293 | structField.Set(reflect.ValueOf(inputFile[0])) 294 | } 295 | } 296 | } 297 | } 298 | 299 | // ErrorHandler simply counts the number of errors in the 300 | // context and, if more than 0, writes a response with an 301 | // error code and a JSON payload describing the errors. 302 | // The response will have a JSON content-type. 303 | // Middleware remaining on the stack will not even see the request 304 | // if, by this point, there are any errors. 305 | // This is a "default" handler, of sorts, and you are 306 | // welcome to use your own instead. The Bind middleware 307 | // invokes this automatically for convenience. 308 | func ErrorHandler(errs Errors, resp http.ResponseWriter) { 309 | if len(errs) > 0 { 310 | resp.Header().Set("Content-Type", jsonContentType) 311 | if errs.Has(DeserializationError) { 312 | resp.WriteHeader(http.StatusBadRequest) 313 | } else if errs.Has(ContentTypeError) { 314 | resp.WriteHeader(http.StatusUnsupportedMediaType) 315 | } else { 316 | resp.WriteHeader(StatusUnprocessableEntity) 317 | } 318 | errOutput, _ := json.Marshal(errs) 319 | resp.Write(errOutput) 320 | return 321 | } 322 | } 323 | 324 | // This sets the value in a struct of an indeterminate type to the 325 | // matching value from the request (via Form middleware) in the 326 | // same type, so that not all deserialized values have to be strings. 327 | // Supported types are string, int, float, and bool. 328 | func setWithProperType(valueKind reflect.Kind, val string, structField reflect.Value, nameInTag string, errors Errors) { 329 | switch valueKind { 330 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 331 | if val == "" { 332 | val = "0" 333 | } 334 | intVal, err := strconv.ParseInt(val, 10, 64) 335 | if err != nil { 336 | errors.Add([]string{nameInTag}, TypeError, "Value could not be parsed as integer") 337 | } else { 338 | structField.SetInt(intVal) 339 | } 340 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 341 | if val == "" { 342 | val = "0" 343 | } 344 | uintVal, err := strconv.ParseUint(val, 10, 64) 345 | if err != nil { 346 | errors.Add([]string{nameInTag}, TypeError, "Value could not be parsed as unsigned integer") 347 | } else { 348 | structField.SetUint(uintVal) 349 | } 350 | case reflect.Bool: 351 | if val == "" { 352 | val = "false" 353 | } 354 | boolVal, err := strconv.ParseBool(val) 355 | if err != nil { 356 | errors.Add([]string{nameInTag}, TypeError, "Value could not be parsed as boolean") 357 | } else { 358 | structField.SetBool(boolVal) 359 | } 360 | case reflect.Float32: 361 | if val == "" { 362 | val = "0.0" 363 | } 364 | floatVal, err := strconv.ParseFloat(val, 32) 365 | if err != nil { 366 | errors.Add([]string{nameInTag}, TypeError, "Value could not be parsed as 32-bit float") 367 | } else { 368 | structField.SetFloat(floatVal) 369 | } 370 | case reflect.Float64: 371 | if val == "" { 372 | val = "0.0" 373 | } 374 | floatVal, err := strconv.ParseFloat(val, 64) 375 | if err != nil { 376 | errors.Add([]string{nameInTag}, TypeError, "Value could not be parsed as 64-bit float") 377 | } else { 378 | structField.SetFloat(floatVal) 379 | } 380 | case reflect.String: 381 | structField.SetString(val) 382 | } 383 | } 384 | 385 | // Don't pass in pointers to bind to. Can lead to bugs. See: 386 | // https://github.com/codegangsta/martini-contrib/pull/34#issuecomment-29683659 387 | func ensureNotPointer(obj interface{}) { 388 | if reflect.TypeOf(obj).Kind() == reflect.Ptr { 389 | panic("Pointers are not accepted as binding models") 390 | } 391 | } 392 | 393 | // Performs validation and combines errors from validation 394 | // with errors from deserialization, then maps both the 395 | // resulting struct and the errors to the context. 396 | func validateAndMap(obj reflect.Value, context martini.Context, errors Errors, ifacePtr ...interface{}) { 397 | context.Invoke(Validate(obj.Interface())) 398 | errors = append(errors, getErrors(context)...) 399 | context.Map(errors) 400 | context.Map(obj.Elem().Interface()) 401 | if len(ifacePtr) > 0 { 402 | context.MapTo(obj.Elem().Interface(), ifacePtr[0]) 403 | } 404 | } 405 | 406 | // getErrors simply gets the errors from the context (it's kind of a chore) 407 | func getErrors(context martini.Context) Errors { 408 | return context.Get(reflect.TypeOf(Errors{})).Interface().(Errors) 409 | } 410 | 411 | type ( 412 | // Implement the Validator interface to handle some rudimentary 413 | // request validation logic so your application doesn't have to. 414 | Validator interface { 415 | // Validate validates that the request is OK. It is recommended 416 | // that validation be limited to checking values for syntax and 417 | // semantics, enough to know that you can make sense of the request 418 | // in your application. For example, you might verify that a credit 419 | // card number matches a valid pattern, but you probably wouldn't 420 | // perform an actual credit card authorization here. 421 | Validate(Errors, *http.Request) Errors 422 | } 423 | ) 424 | 425 | var ( 426 | // Maximum amount of memory to use when parsing a multipart form. 427 | // Set this to whatever value you prefer; default is 10 MB. 428 | MaxMemory = int64(1024 * 1024 * 10) 429 | ) 430 | 431 | const ( 432 | jsonContentType = "application/json; charset=utf-8" 433 | StatusUnprocessableEntity = 422 434 | ) 435 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "mime/multipart" 5 | "net/http" 6 | 7 | "github.com/go-martini/martini" 8 | ) 9 | 10 | // These types are mostly contrived examples, but they're used 11 | // across many test cases. The idea is to cover all the scenarios 12 | // that this binding package might encounter in actual use. 13 | type ( 14 | 15 | // For basic test cases with a required field 16 | Post struct { 17 | Title string `form:"title" json:"title" binding:"required"` 18 | Content string `form:"content" json:"content"` 19 | } 20 | 21 | // To be used as a nested struct (with a required field) 22 | Person struct { 23 | Name string `form:"name" json:"name" binding:"required"` 24 | Email string `form:"email" json:"email"` 25 | } 26 | 27 | // For advanced test cases: multiple values, embedded 28 | // and nested structs, an ignored field, and single 29 | // and multiple file uploads 30 | BlogPost struct { 31 | Post 32 | Id int `form:"id" binding:"required"` // JSON not specified here for test coverage 33 | Ignored string `form:"-" json:"-"` 34 | Ratings []int `form:"rating" json:"ratings"` 35 | Author Person `json:"author"` 36 | Coauthor *Person `json:"coauthor"` 37 | HeaderImage *multipart.FileHeader `form:"headerImage"` 38 | Pictures []*multipart.FileHeader `form:"picture"` 39 | IsActive *bool `form:"is_active"` 40 | unexported string `form:"unexported"` 41 | } 42 | 43 | EmbedPerson struct { 44 | *Person 45 | } 46 | 47 | // The common function signature of the handlers going under test. 48 | handlerFunc func(interface{}, ...interface{}) martini.Handler 49 | 50 | // Used for testing mapping an interface to the context 51 | // If used (withInterface = true in the testCases), a modeler 52 | // should be mapped to the context as well as BlogPost, meaning 53 | // you can receive a modeler in your application instead of a 54 | // concrete BlogPost. 55 | modeler interface { 56 | Model() string 57 | } 58 | ) 59 | 60 | func (p Post) Validate(errs Errors, req *http.Request) Errors { 61 | if len(p.Title) < 10 { 62 | errs = append(errs, Error{ 63 | FieldNames: []string{"title"}, 64 | Classification: "LengthError", 65 | Message: "Life is too short", 66 | }) 67 | } 68 | return errs 69 | } 70 | 71 | func (p Post) Model() string { 72 | return p.Title 73 | } 74 | 75 | const ( 76 | testRoute = "/test" 77 | formContentType = "application/x-www-form-urlencoded" 78 | ) 79 | -------------------------------------------------------------------------------- /errorhandler_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | var errorTestCases = []errorTestCase{ 11 | { 12 | description: "No errors", 13 | errors: Errors{}, 14 | expected: errorTestResult{ 15 | statusCode: http.StatusOK, 16 | }, 17 | }, 18 | { 19 | description: "Deserialization error", 20 | errors: Errors{ 21 | { 22 | Classification: DeserializationError, 23 | Message: "Some parser error here", 24 | }, 25 | }, 26 | expected: errorTestResult{ 27 | statusCode: http.StatusBadRequest, 28 | contentType: jsonContentType, 29 | body: `[{"classification":"DeserializationError","message":"Some parser error here"}]`, 30 | }, 31 | }, 32 | { 33 | description: "Content-Type error", 34 | errors: Errors{ 35 | { 36 | Classification: ContentTypeError, 37 | Message: "Empty Content-Type", 38 | }, 39 | }, 40 | expected: errorTestResult{ 41 | statusCode: http.StatusUnsupportedMediaType, 42 | contentType: jsonContentType, 43 | body: `[{"classification":"ContentTypeError","message":"Empty Content-Type"}]`, 44 | }, 45 | }, 46 | { 47 | description: "Requirement error", 48 | errors: Errors{ 49 | { 50 | FieldNames: []string{"some_field"}, 51 | Classification: RequiredError, 52 | Message: "Required", 53 | }, 54 | }, 55 | expected: errorTestResult{ 56 | statusCode: StatusUnprocessableEntity, 57 | contentType: jsonContentType, 58 | body: `[{"fieldNames":["some_field"],"classification":"RequiredError","message":"Required"}]`, 59 | }, 60 | }, 61 | { 62 | description: "Bad header error", 63 | errors: Errors{ 64 | { 65 | Classification: "HeaderError", 66 | Message: "The X-Something header must be specified", 67 | }, 68 | }, 69 | expected: errorTestResult{ 70 | statusCode: StatusUnprocessableEntity, 71 | contentType: jsonContentType, 72 | body: `[{"classification":"HeaderError","message":"The X-Something header must be specified"}]`, 73 | }, 74 | }, 75 | { 76 | description: "Custom field error", 77 | errors: Errors{ 78 | { 79 | FieldNames: []string{"month", "year"}, 80 | Classification: "DateError", 81 | Message: "The month and year must be in the future", 82 | }, 83 | }, 84 | expected: errorTestResult{ 85 | statusCode: StatusUnprocessableEntity, 86 | contentType: jsonContentType, 87 | body: `[{"fieldNames":["month","year"],"classification":"DateError","message":"The month and year must be in the future"}]`, 88 | }, 89 | }, 90 | { 91 | description: "Multiple errors", 92 | errors: Errors{ 93 | { 94 | FieldNames: []string{"foo"}, 95 | Classification: RequiredError, 96 | Message: "Required", 97 | }, 98 | { 99 | FieldNames: []string{"foo"}, 100 | Classification: "LengthError", 101 | Message: "The length of the 'foo' field is too short", 102 | }, 103 | }, 104 | expected: errorTestResult{ 105 | statusCode: StatusUnprocessableEntity, 106 | contentType: jsonContentType, 107 | body: `[{"fieldNames":["foo"],"classification":"RequiredError","message":"Required"},{"fieldNames":["foo"],"classification":"LengthError","message":"The length of the 'foo' field is too short"}]`, 108 | }, 109 | }, 110 | } 111 | 112 | func TestErrorHandler(t *testing.T) { 113 | for _, testCase := range errorTestCases { 114 | performErrorTest(t, testCase) 115 | } 116 | } 117 | 118 | func performErrorTest(t *testing.T, testCase errorTestCase) { 119 | httpRecorder := httptest.NewRecorder() 120 | 121 | ErrorHandler(testCase.errors, httpRecorder) 122 | 123 | actualBody, _ := ioutil.ReadAll(httpRecorder.Body) 124 | actualContentType := httpRecorder.Header().Get("Content-Type") 125 | 126 | if httpRecorder.Code != testCase.expected.statusCode { 127 | t.Errorf("For '%s': expected status code %d but got %d instead", 128 | testCase.description, testCase.expected.statusCode, httpRecorder.Code) 129 | } 130 | if actualContentType != testCase.expected.contentType { 131 | t.Errorf("For '%s': expected content-type '%s' but got '%s' instead", 132 | testCase.description, testCase.expected.contentType, actualContentType) 133 | } 134 | if string(actualBody) != testCase.expected.body { 135 | t.Errorf("For '%s': expected body to be '%s' but got '%s' instead", 136 | testCase.description, testCase.expected.body, actualBody) 137 | } 138 | } 139 | 140 | type ( 141 | errorTestCase struct { 142 | description string 143 | errors Errors 144 | expected errorTestResult 145 | } 146 | 147 | errorTestResult struct { 148 | statusCode int 149 | contentType string 150 | body string 151 | } 152 | ) 153 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | type ( 4 | // Errors may be generated during deserialization, binding, 5 | // or validation. This type is mapped to the context so you 6 | // can inject it into your own handlers and use it in your 7 | // application if you want all your errors to look the same. 8 | Errors []Error 9 | 10 | Error struct { 11 | // An error supports zero or more field names, because an 12 | // error can morph three ways: (1) it can indicate something 13 | // wrong with the request as a whole, (2) it can point to a 14 | // specific problem with a particular input field, or (3) it 15 | // can span multiple related input fields. 16 | FieldNames []string `json:"fieldNames,omitempty"` 17 | 18 | // The classification is like an error code, convenient to 19 | // use when processing or categorizing an error programmatically. 20 | // It may also be called the "kind" of error. 21 | Classification string `json:"classification,omitempty"` 22 | 23 | // Message should be human-readable and detailed enough to 24 | // pinpoint and resolve the problem, but it should be brief. For 25 | // example, a payload of 100 objects in a JSON array might have 26 | // an error in the 41st object. The message should help the 27 | // end user find and fix the error with their request. 28 | Message string `json:"message,omitempty"` 29 | } 30 | ) 31 | 32 | // Add adds an error associated with the fields indicated 33 | // by fieldNames, with the given classification and message. 34 | func (e *Errors) Add(fieldNames []string, classification, message string) { 35 | *e = append(*e, Error{ 36 | FieldNames: fieldNames, 37 | Classification: classification, 38 | Message: message, 39 | }) 40 | } 41 | 42 | // Len returns the number of errors. 43 | func (e *Errors) Len() int { 44 | return len(*e) 45 | } 46 | 47 | // Has determines whether an Errors slice has an Error with 48 | // a given classification in it; it does not search on messages 49 | // or field names. 50 | func (e *Errors) Has(class string) bool { 51 | for _, err := range *e { 52 | if err.Kind() == class { 53 | return true 54 | } 55 | } 56 | return false 57 | } 58 | 59 | /* 60 | // WithClass gets a copy of errors that are classified by the 61 | // the given classification. 62 | func (e *Errors) WithClass(classification string) Errors { 63 | var errs Errors 64 | for _, err := range *e { 65 | if err.Kind() == classification { 66 | errs = append(errs, err) 67 | } 68 | } 69 | return errs 70 | } 71 | 72 | // ForField gets a copy of errors that are associated with the 73 | // field by the given name. 74 | func (e *Errors) ForField(name string) Errors { 75 | var errs Errors 76 | for _, err := range *e { 77 | for _, fieldName := range err.Fields() { 78 | if fieldName == name { 79 | errs = append(errs, err) 80 | break 81 | } 82 | } 83 | } 84 | return errs 85 | } 86 | 87 | // Get gets errors of a particular class for the specified 88 | // field name. 89 | func (e *Errors) Get(class, fieldName string) Errors { 90 | var errs Errors 91 | for _, err := range *e { 92 | if err.Kind() == class { 93 | for _, nameOfField := range err.Fields() { 94 | if nameOfField == fieldName { 95 | errs = append(errs, err) 96 | break 97 | } 98 | } 99 | } 100 | } 101 | return errs 102 | } 103 | */ 104 | 105 | // Fields returns the list of field names this error is 106 | // associated with. 107 | func (e Error) Fields() []string { 108 | return e.FieldNames 109 | } 110 | 111 | // Kind returns this error's classification. 112 | func (e Error) Kind() string { 113 | return e.Classification 114 | } 115 | 116 | // Error returns this error's message. 117 | func (e Error) Error() string { 118 | return e.Message 119 | } 120 | 121 | const ( 122 | RequiredError = "RequiredError" 123 | ContentTypeError = "ContentTypeError" 124 | DeserializationError = "DeserializationError" 125 | TypeError = "TypeError" 126 | ) 127 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestErrorsAdd(t *testing.T) { 9 | var actual Errors 10 | expected := Errors{ 11 | Error{ 12 | FieldNames: []string{"Field1", "Field2"}, 13 | Classification: "ErrorClass", 14 | Message: "Some message", 15 | }, 16 | } 17 | 18 | actual.Add(expected[0].FieldNames, expected[0].Classification, expected[0].Message) 19 | 20 | if len(actual) != 1 { 21 | t.Errorf("Expected 1 error, but actually had %d", len(actual)) 22 | return 23 | } 24 | 25 | expectedStr := fmt.Sprintf("%#v", expected) 26 | actualStr := fmt.Sprintf("%#v", actual) 27 | 28 | if actualStr != expectedStr { 29 | t.Errorf("Expected:\n%s\nbut got:\n%s", expectedStr, actualStr) 30 | } 31 | } 32 | 33 | func TestErrorsLen(t *testing.T) { 34 | actual := errorsTestSet.Len() 35 | expected := len(errorsTestSet) 36 | if actual != expected { 37 | t.Errorf("Expected %d, but got %d", expected, actual) 38 | return 39 | } 40 | } 41 | 42 | func TestErrorsHas(t *testing.T) { 43 | if errorsTestSet.Has("ClassA") != true { 44 | t.Errorf("Expected to have error of kind ClassA, but didn't") 45 | } 46 | if errorsTestSet.Has("ClassQ") != false { 47 | t.Errorf("Expected to NOT have error of kind ClassQ, but did") 48 | } 49 | } 50 | 51 | func TestErrorGetters(t *testing.T) { 52 | err := Error{ 53 | FieldNames: []string{"field1", "field2"}, 54 | Classification: "ErrorClass", 55 | Message: "The message", 56 | } 57 | 58 | fieldsActual := err.Fields() 59 | 60 | if len(fieldsActual) != 2 { 61 | t.Errorf("Expected Fields() to return 2 errors, but got %d", len(fieldsActual)) 62 | } else { 63 | if fieldsActual[0] != "field1" || fieldsActual[1] != "field2" { 64 | t.Errorf("Expected Fields() to return the correct fields, but it didn't") 65 | } 66 | } 67 | 68 | if err.Kind() != "ErrorClass" { 69 | t.Errorf("Expected the classification to be 'ErrorClass', but got '%s'", err.Kind()) 70 | } 71 | 72 | if err.Error() != "The message" { 73 | t.Errorf("Expected the message to be 'The message', but got '%s'", err.Error()) 74 | } 75 | } 76 | 77 | /* 78 | func TestErrorsWithClass(t *testing.T) { 79 | expected := Errors{ 80 | errorsTestSet[0], 81 | errorsTestSet[3], 82 | } 83 | actualStr := fmt.Sprintf("%#v", errorsTestSet.WithClass("ClassA")) 84 | expectedStr := fmt.Sprintf("%#v", expected) 85 | if actualStr != expectedStr { 86 | t.Errorf("Expected:\n%s\nbut got:\n%s", expectedStr, actualStr) 87 | } 88 | } 89 | */ 90 | 91 | var errorsTestSet = Errors{ 92 | Error{ 93 | FieldNames: []string{}, 94 | Classification: "ClassA", 95 | Message: "Foobar", 96 | }, 97 | Error{ 98 | FieldNames: []string{}, 99 | Classification: "ClassB", 100 | Message: "Foo", 101 | }, 102 | Error{ 103 | FieldNames: []string{"field1", "field2"}, 104 | Classification: "ClassB", 105 | Message: "Foobar", 106 | }, 107 | Error{ 108 | FieldNames: []string{"field2"}, 109 | Classification: "ClassA", 110 | Message: "Foobar", 111 | }, 112 | Error{ 113 | FieldNames: []string{"field2"}, 114 | Classification: "ClassB", 115 | Message: "Foobar", 116 | }, 117 | } 118 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "bytes" 5 | "mime/multipart" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/go-martini/martini" 11 | ) 12 | 13 | var fileTestCases = []fileTestCase{ 14 | { 15 | description: "Single file", 16 | singleFile: &fileInfo{ 17 | fileName: "message.txt", 18 | data: "All your binding are belong to us", 19 | }, 20 | }, 21 | { 22 | description: "Multiple files", 23 | multipleFiles: []*fileInfo{ 24 | &fileInfo{ 25 | fileName: "cool-gopher-fact.txt", 26 | data: "Did you know? https://plus.google.com/+MatthewHolt/posts/GmVfd6TPJ51", 27 | }, 28 | &fileInfo{ 29 | fileName: "gophercon2014.txt", 30 | data: "@bradfitz has a Go time machine: https://twitter.com/mholt6/status/459463953395875840", 31 | }, 32 | }, 33 | }, 34 | { 35 | description: "Single file and multiple files", 36 | singleFile: &fileInfo{ 37 | fileName: "social media.txt", 38 | data: "Hey, you should follow @mholt6 (Twitter) or +MatthewHolt (Google+)", 39 | }, 40 | multipleFiles: []*fileInfo{ 41 | &fileInfo{ 42 | fileName: "thank you!", 43 | data: "Also, thanks to all the contributors of this package!", 44 | }, 45 | &fileInfo{ 46 | fileName: "btw...", 47 | data: "This tool translates JSON into Go structs: http://mholt.github.io/json-to-go/", 48 | }, 49 | }, 50 | }, 51 | } 52 | 53 | func TestFileUploads(t *testing.T) { 54 | for _, testCase := range fileTestCases { 55 | performFileTest(t, MultipartForm, testCase) 56 | } 57 | } 58 | 59 | func performFileTest(t *testing.T, binder handlerFunc, testCase fileTestCase) { 60 | httpRecorder := httptest.NewRecorder() 61 | m := martini.Classic() 62 | 63 | fileTestHandler := func(actual BlogPost, errs Errors) { 64 | assertFileAsExpected(t, testCase, actual.HeaderImage, testCase.singleFile) 65 | 66 | if len(testCase.multipleFiles) != len(actual.Pictures) { 67 | t.Errorf("For '%s': Expected %d multiple files, but actually had %d instead", 68 | testCase.description, len(testCase.multipleFiles), len(actual.Pictures)) 69 | } 70 | 71 | for i, expectedFile := range testCase.multipleFiles { 72 | if i >= len(actual.Pictures) { 73 | break 74 | } 75 | assertFileAsExpected(t, testCase, actual.Pictures[i], expectedFile) 76 | } 77 | } 78 | 79 | m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { 80 | fileTestHandler(actual, errs) 81 | }) 82 | 83 | m.ServeHTTP(httpRecorder, buildRequestWithFile(testCase)) 84 | 85 | switch httpRecorder.Code { 86 | case http.StatusNotFound: 87 | panic("Routing is messed up in test fixture (got 404): check methods and paths") 88 | case http.StatusInternalServerError: 89 | panic("Something bad happened on '" + testCase.description + "'") 90 | } 91 | } 92 | 93 | func assertFileAsExpected(t *testing.T, testCase fileTestCase, actual *multipart.FileHeader, expected *fileInfo) { 94 | if expected == nil && actual == nil { 95 | return 96 | } 97 | 98 | if expected != nil && actual == nil { 99 | t.Errorf("For '%s': Expected to have a file, but didn't", 100 | testCase.description) 101 | return 102 | } else if expected == nil && actual != nil { 103 | t.Errorf("For '%s': Did not expect a file, but ended up having one!", 104 | testCase.description) 105 | return 106 | } 107 | 108 | if actual.Filename != expected.fileName { 109 | t.Errorf("For '%s': expected file name to be '%s' but got '%s'", 110 | testCase.description, expected.fileName, actual.Filename) 111 | } 112 | 113 | actualMultipleFileData := unpackFileHeaderData(actual) 114 | 115 | if actualMultipleFileData != expected.data { 116 | t.Errorf("For '%s': expected file data to be '%s' but got '%s'", 117 | testCase.description, expected.data, actualMultipleFileData) 118 | } 119 | } 120 | 121 | func buildRequestWithFile(testCase fileTestCase) *http.Request { 122 | b := &bytes.Buffer{} 123 | w := multipart.NewWriter(b) 124 | 125 | if testCase.singleFile != nil { 126 | formFileSingle, err := w.CreateFormFile("headerImage", testCase.singleFile.fileName) 127 | if err != nil { 128 | panic("Could not create FormFile (single file): " + err.Error()) 129 | } 130 | formFileSingle.Write([]byte(testCase.singleFile.data)) 131 | } 132 | 133 | for _, file := range testCase.multipleFiles { 134 | formFileMultiple, err := w.CreateFormFile("picture", file.fileName) 135 | if err != nil { 136 | panic("Could not create FormFile (multiple files): " + err.Error()) 137 | } 138 | formFileMultiple.Write([]byte(file.data)) 139 | } 140 | 141 | err := w.Close() 142 | if err != nil { 143 | panic("Could not close multipart writer: " + err.Error()) 144 | } 145 | 146 | req, err := http.NewRequest("POST", testRoute, b) 147 | if err != nil { 148 | panic("Could not create file upload request: " + err.Error()) 149 | } 150 | 151 | req.Header.Set("Content-Type", w.FormDataContentType()) 152 | 153 | return req 154 | } 155 | 156 | func unpackFileHeaderData(fh *multipart.FileHeader) string { 157 | if fh == nil { 158 | return "" 159 | } 160 | 161 | f, err := fh.Open() 162 | if err != nil { 163 | panic("Could not open file header:" + err.Error()) 164 | } 165 | defer f.Close() 166 | 167 | var fb bytes.Buffer 168 | _, err = fb.ReadFrom(f) 169 | if err != nil { 170 | panic("Could not read from file header:" + err.Error()) 171 | } 172 | 173 | return fb.String() 174 | } 175 | 176 | type ( 177 | fileTestCase struct { 178 | description string 179 | input BlogPost 180 | singleFile *fileInfo 181 | multipleFiles []*fileInfo 182 | } 183 | 184 | multipartFileFormTestCase struct { 185 | description string 186 | fields map[string]string 187 | multipleFiles []*fileInfo 188 | } 189 | 190 | fileInfo struct { 191 | fileName string 192 | data string 193 | } 194 | ) 195 | 196 | var multipartFileFormTestCases = []multipartFileFormTestCase{ 197 | { 198 | description: "Test upload files", 199 | multipleFiles: []*fileInfo{ 200 | &fileInfo{ 201 | fileName: "cool-gopher-fact.txt", 202 | data: "Did you know? https://plus.google.com/+MatthewHolt/posts/GmVfd6TPJ51", 203 | }, 204 | &fileInfo{ 205 | fileName: "gophercon2014.txt", 206 | data: "@bradfitz has a Go time machine: https://twitter.com/mholt6/status/459463953395875840", 207 | }, 208 | }, 209 | }, 210 | { 211 | description: "Test upload files with empty input[type=file]", 212 | multipleFiles: []*fileInfo{ 213 | &fileInfo{ 214 | fileName: "cool-gopher-fact.txt", 215 | data: "Did you know? https://plus.google.com/+MatthewHolt/posts/GmVfd6TPJ51", 216 | }, 217 | &fileInfo{ 218 | fileName: "gophercon2014.txt", 219 | data: "@bradfitz has a Go time machine: https://twitter.com/mholt6/status/459463953395875840", 220 | }, 221 | &fileInfo{}, 222 | }, 223 | }, 224 | { 225 | description: "Test send post data", 226 | fields: map[string]string{ 227 | "test": "data", 228 | "save": "", 229 | }, 230 | }, 231 | { 232 | description: "Test send post data and upload files with empty input[type=file]", 233 | fields: map[string]string{ 234 | "test": "data", 235 | "save": "", 236 | }, 237 | multipleFiles: []*fileInfo{ 238 | &fileInfo{ 239 | fileName: "cool-gopher-fact.txt", 240 | data: "Did you know? https://plus.google.com/+MatthewHolt/posts/GmVfd6TPJ51", 241 | }, 242 | &fileInfo{ 243 | fileName: "gophercon2014.txt", 244 | data: "@bradfitz has a Go time machine: https://twitter.com/mholt6/status/459463953395875840", 245 | }, 246 | &fileInfo{}, 247 | &fileInfo{}, 248 | }, 249 | }, 250 | } 251 | 252 | func TestMultipartFilesForm(t *testing.T) { 253 | for _, testCase := range multipartFileFormTestCases { 254 | performMultipartFilesFormTest(t, MultipartForm, testCase) 255 | } 256 | } 257 | 258 | func performMultipartFilesFormTest(t *testing.T, binder handlerFunc, testCase multipartFileFormTestCase) { 259 | httpRecorder := httptest.NewRecorder() 260 | m := martini.Classic() 261 | 262 | m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { 263 | expectedCountFiles := 0 264 | actualCountFiles := 0 265 | 266 | for _, value := range testCase.multipleFiles { 267 | if value.fileName != "" { 268 | expectedCountFiles++ // Counting only not null files 269 | } 270 | } 271 | 272 | for _, value := range actual.Pictures { 273 | if value != nil { 274 | actualCountFiles++ // Counting only not null files 275 | } 276 | } 277 | 278 | if actualCountFiles != expectedCountFiles { 279 | t.Errorf("'%s': expected\n'%d'\nbut got\n'%d'", testCase.description, expectedCountFiles, actualCountFiles) 280 | } 281 | }) 282 | 283 | multipartPayload, mpWriter := makeMultipartFilesPayload(testCase) 284 | 285 | req, err := http.NewRequest("POST", testRoute, multipartPayload) 286 | if err != nil { 287 | panic(err) 288 | } 289 | 290 | req.Header.Add("Content-Type", mpWriter.FormDataContentType()) 291 | 292 | err = mpWriter.Close() 293 | if err != nil { 294 | panic(err) 295 | } 296 | 297 | m.ServeHTTP(httpRecorder, req) 298 | 299 | switch httpRecorder.Code { 300 | case http.StatusNotFound: 301 | panic("Routing is messed up in test fixture (got 404): check methods and paths") 302 | case http.StatusInternalServerError: 303 | panic("Something bad happened on '" + testCase.description + "'") 304 | } 305 | } 306 | 307 | // Writes the input from a test case into a buffer using the multipart writer. 308 | func makeMultipartFilesPayload(testCase multipartFileFormTestCase) (*bytes.Buffer, *multipart.Writer) { 309 | body := &bytes.Buffer{} 310 | writer := multipart.NewWriter(body) 311 | 312 | var boundary string 313 | 314 | for key, value := range testCase.fields { 315 | boundary += "--" + writer.Boundary() + "\nContent-Disposition: form-data; name=\"" + key + "\";\n\n" + value + "\n\n" 316 | } 317 | 318 | for _, file := range testCase.multipleFiles { 319 | boundary += "--" + writer.Boundary() + "\nContent-Disposition: form-data; name=\"picture\"; filename=\"" + file.fileName + "\"\nContent-Type: text/plain\n\n" + file.data + "\n\n" 320 | } 321 | 322 | boundary += "--" + writer.Boundary() + "--\n" 323 | 324 | body.Write([]byte(boundary)) 325 | return body, writer 326 | } 327 | -------------------------------------------------------------------------------- /form_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/go-martini/martini" 12 | ) 13 | 14 | var addressableTrue bool = true 15 | 16 | var formTestCases = []formTestCase{ 17 | { 18 | description: "Happy path", 19 | shouldSucceed: true, 20 | payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, 21 | contentType: formContentType, 22 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 23 | }, 24 | { 25 | description: "Happy path with interface", 26 | shouldSucceed: true, 27 | withInterface: true, 28 | payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, 29 | contentType: formContentType, 30 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 31 | }, 32 | { 33 | description: "Empty payload", 34 | shouldSucceed: false, 35 | payload: ``, 36 | contentType: formContentType, 37 | expected: Post{}, 38 | }, 39 | { 40 | description: "Empty content type", 41 | shouldSucceed: false, 42 | payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, 43 | contentType: ``, 44 | expected: Post{}, 45 | }, 46 | { 47 | description: "Malformed form body", 48 | shouldSucceed: false, 49 | payload: `title=%2`, 50 | contentType: formContentType, 51 | expected: Post{}, 52 | }, 53 | { 54 | description: "With nested and embedded structs", 55 | shouldSucceed: true, 56 | payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt`, 57 | contentType: formContentType, 58 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 59 | }, 60 | { 61 | description: "Required embedded struct field not specified", 62 | shouldSucceed: false, 63 | payload: `id=1&name=Matt+Holt`, 64 | contentType: formContentType, 65 | expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, 66 | }, 67 | { 68 | description: "Required nested struct field not specified", 69 | shouldSucceed: false, 70 | payload: `title=Glorious+Post+Title&id=1`, 71 | contentType: formContentType, 72 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, 73 | }, 74 | { 75 | description: "Multiple values into slice", 76 | shouldSucceed: true, 77 | payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&rating=4&rating=3&rating=5`, 78 | contentType: formContentType, 79 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, Ratings: []int{4, 3, 5}}, 80 | }, 81 | { 82 | description: "Unexported field", 83 | shouldSucceed: true, 84 | payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&unexported=foo`, 85 | contentType: formContentType, 86 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 87 | }, 88 | { 89 | description: "Bool pointer", 90 | shouldSucceed: true, 91 | payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&is_active=true`, 92 | contentType: formContentType, 93 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, IsActive: &addressableTrue}, 94 | deepEqual: true, 95 | }, 96 | { 97 | description: "Query string POST", 98 | shouldSucceed: true, 99 | payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, 100 | contentType: formContentType, 101 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 102 | }, 103 | { 104 | description: "Query string with Content-Type (POST request)", 105 | shouldSucceed: true, 106 | queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", 107 | payload: ``, 108 | contentType: formContentType, 109 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 110 | }, 111 | { 112 | description: "Query string without Content-Type (GET request)", 113 | shouldSucceed: true, 114 | method: "GET", 115 | queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", 116 | payload: ``, 117 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 118 | }, 119 | { 120 | description: "Embed struct pointer", 121 | shouldSucceed: true, 122 | deepEqual: true, 123 | method: "GET", 124 | queryString: "?name=Glorious+Post+Title&email=Lorem+ipsum+dolor+sit+amet", 125 | payload: ``, 126 | expected: EmbedPerson{&Person{Name: "Glorious Post Title", Email: "Lorem ipsum dolor sit amet"}}, 127 | }, 128 | { 129 | description: "Embed struct pointer remain nil if not binded", 130 | shouldSucceed: true, 131 | deepEqual: true, 132 | method: "GET", 133 | queryString: "?", 134 | payload: ``, 135 | expected: EmbedPerson{nil}, 136 | }, 137 | } 138 | 139 | func TestForm(t *testing.T) { 140 | for _, testCase := range formTestCases { 141 | performFormTest(t, Form, testCase) 142 | } 143 | } 144 | 145 | func performFormTest(t *testing.T, binder handlerFunc, testCase formTestCase) { 146 | httpRecorder := httptest.NewRecorder() 147 | m := martini.Classic() 148 | 149 | formTestHandler := func(actual interface{}, errs Errors) { 150 | if testCase.shouldSucceed && len(errs) > 0 { 151 | t.Errorf("'%s' should have succeeded, but there were errors (%d):\n%+v", 152 | testCase.description, len(errs), errs) 153 | } else if !testCase.shouldSucceed && len(errs) == 0 { 154 | t.Errorf("'%s' should have had errors, but there were none", testCase.description) 155 | } 156 | expString := fmt.Sprintf("%+v", testCase.expected) 157 | actString := fmt.Sprintf("%+v", actual) 158 | if actString != expString && !(testCase.deepEqual && reflect.DeepEqual(testCase.expected, actual)) { 159 | t.Errorf("'%s': expected\n'%s'\nbut got\n'%s'", 160 | testCase.description, expString, actString) 161 | } 162 | } 163 | 164 | switch testCase.expected.(type) { 165 | case Post: 166 | if testCase.withInterface { 167 | m.Post(testRoute, binder(Post{}, (*modeler)(nil)), func(actual Post, iface modeler, errs Errors) { 168 | if actual.Title != iface.Model() { 169 | t.Errorf("For '%s': expected the struct to be mapped to the context as an interface", 170 | testCase.description) 171 | } 172 | formTestHandler(actual, errs) 173 | }) 174 | } else { 175 | m.Post(testRoute, binder(Post{}), func(actual Post, errs Errors) { 176 | formTestHandler(actual, errs) 177 | }) 178 | m.Get(testRoute, binder(Post{}), func(actual Post, errs Errors) { 179 | formTestHandler(actual, errs) 180 | }) 181 | } 182 | 183 | case BlogPost: 184 | if testCase.withInterface { 185 | m.Post(testRoute, binder(BlogPost{}, (*modeler)(nil)), func(actual BlogPost, iface modeler, errs Errors) { 186 | if actual.Title != iface.Model() { 187 | t.Errorf("For '%s': expected the struct to be mapped to the context as an interface", 188 | testCase.description) 189 | } 190 | formTestHandler(actual, errs) 191 | }) 192 | } else { 193 | m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { 194 | formTestHandler(actual, errs) 195 | }) 196 | } 197 | 198 | case EmbedPerson: 199 | m.Post(testRoute, binder(EmbedPerson{}), func(actual EmbedPerson, errs Errors) { 200 | formTestHandler(actual, errs) 201 | }) 202 | m.Get(testRoute, binder(EmbedPerson{}), func(actual EmbedPerson, errs Errors) { 203 | formTestHandler(actual, errs) 204 | }) 205 | } 206 | 207 | if testCase.method == "" { 208 | testCase.method = "POST" 209 | } 210 | 211 | req, err := http.NewRequest(testCase.method, testRoute+testCase.queryString, strings.NewReader(testCase.payload)) 212 | if err != nil { 213 | panic(err) 214 | } 215 | req.Header.Set("Content-Type", testCase.contentType) 216 | 217 | m.ServeHTTP(httpRecorder, req) 218 | 219 | switch httpRecorder.Code { 220 | case http.StatusNotFound: 221 | panic("Routing is messed up in test fixture (got 404): check methods and paths") 222 | case http.StatusInternalServerError: 223 | panic("Something bad happened on '" + testCase.description + "'") 224 | } 225 | } 226 | 227 | type ( 228 | formTestCase struct { 229 | description string 230 | shouldSucceed bool 231 | deepEqual bool 232 | withInterface bool 233 | queryString string 234 | payload string 235 | contentType string 236 | expected interface{} 237 | method string 238 | } 239 | ) 240 | -------------------------------------------------------------------------------- /json_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/go-martini/martini" 12 | ) 13 | 14 | var jsonTestCases = []jsonTestCase{ 15 | { 16 | description: "Happy path", 17 | shouldSucceedOnJson: true, 18 | payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, 19 | contentType: jsonContentType, 20 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 21 | }, 22 | { 23 | description: "Happy path with interface", 24 | shouldSucceedOnJson: true, 25 | withInterface: true, 26 | payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, 27 | contentType: jsonContentType, 28 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 29 | }, 30 | { 31 | description: "Nil payload", 32 | shouldSucceedOnJson: false, 33 | payload: `-nil-`, 34 | contentType: jsonContentType, 35 | expected: Post{}, 36 | }, 37 | { 38 | description: "Empty payload", 39 | shouldSucceedOnJson: false, 40 | payload: ``, 41 | contentType: jsonContentType, 42 | expected: Post{}, 43 | }, 44 | { 45 | description: "Empty content type", 46 | shouldSucceedOnJson: true, 47 | shouldFailOnBind: true, 48 | payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, 49 | contentType: ``, 50 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 51 | }, 52 | { 53 | description: "Unsupported content type", 54 | shouldSucceedOnJson: true, 55 | shouldFailOnBind: true, 56 | payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, 57 | contentType: `BoGuS`, 58 | expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, 59 | }, 60 | { 61 | description: "Malformed JSON", 62 | shouldSucceedOnJson: false, 63 | payload: `{"title":"foo"`, 64 | contentType: jsonContentType, 65 | expected: Post{}, 66 | }, 67 | { 68 | description: "Deserialization with nested and embedded struct", 69 | shouldSucceedOnJson: true, 70 | payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, 71 | contentType: jsonContentType, 72 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 73 | }, 74 | { 75 | description: "Deserialization with nested and embedded struct with interface", 76 | shouldSucceedOnJson: true, 77 | withInterface: true, 78 | payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, 79 | contentType: jsonContentType, 80 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 81 | }, 82 | { 83 | description: "Required nested struct field not specified", 84 | shouldSucceedOnJson: false, 85 | payload: `{"title":"Glorious Post Title", "id":1, "author":{}}`, 86 | contentType: jsonContentType, 87 | expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, 88 | }, 89 | { 90 | description: "Required embedded struct field not specified", 91 | shouldSucceedOnJson: false, 92 | payload: `{"id":1, "author":{"name":"Matt Holt"}}`, 93 | contentType: jsonContentType, 94 | expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, 95 | }, 96 | { 97 | description: "Slice of Posts", 98 | shouldSucceedOnJson: true, 99 | payload: `[{"title": "First Post"}, {"title": "Second Post"}]`, 100 | contentType: jsonContentType, 101 | expected: []Post{Post{Title: "First Post"}, Post{Title: "Second Post"}}, 102 | }, 103 | } 104 | 105 | func TestJson(t *testing.T) { 106 | for _, testCase := range jsonTestCases { 107 | performJsonTest(t, Json, testCase) 108 | } 109 | } 110 | 111 | func performJsonTest(t *testing.T, binder handlerFunc, testCase jsonTestCase) { 112 | var payload io.Reader 113 | httpRecorder := httptest.NewRecorder() 114 | m := martini.Classic() 115 | 116 | jsonTestHandler := func(actual interface{}, errs Errors) { 117 | if testCase.shouldSucceedOnJson && len(errs) > 0 { 118 | t.Errorf("'%s' should have succeeded, but there were errors (%d):\n%+v", 119 | testCase.description, len(errs), errs) 120 | } else if !testCase.shouldSucceedOnJson && len(errs) == 0 { 121 | t.Errorf("'%s' should NOT have succeeded, but there were NO errors", testCase.description) 122 | } 123 | expString := fmt.Sprintf("%+v", testCase.expected) 124 | actString := fmt.Sprintf("%+v", actual) 125 | if actString != expString { 126 | t.Errorf("'%s': expected\n'%s'\nbut got\n'%s'", 127 | testCase.description, expString, actString) 128 | } 129 | } 130 | 131 | switch testCase.expected.(type) { 132 | case []Post: 133 | if testCase.withInterface { 134 | m.Post(testRoute, binder([]Post{}, (*modeler)(nil)), func(actual []Post, iface modeler, errs Errors) { 135 | 136 | for _, a := range actual { 137 | if a.Title != iface.Model() { 138 | t.Errorf("For '%s': expected the struct to be mapped to the context as an interface", 139 | testCase.description) 140 | } 141 | jsonTestHandler(a, errs) 142 | } 143 | }) 144 | } else { 145 | m.Post(testRoute, binder([]Post{}), func(actual []Post, errs Errors) { 146 | jsonTestHandler(actual, errs) 147 | }) 148 | } 149 | 150 | case Post: 151 | if testCase.withInterface { 152 | m.Post(testRoute, binder(Post{}, (*modeler)(nil)), func(actual Post, iface modeler, errs Errors) { 153 | if actual.Title != iface.Model() { 154 | t.Errorf("For '%s': expected the struct to be mapped to the context as an interface", 155 | testCase.description) 156 | } 157 | jsonTestHandler(actual, errs) 158 | }) 159 | } else { 160 | m.Post(testRoute, binder(Post{}), func(actual Post, errs Errors) { 161 | jsonTestHandler(actual, errs) 162 | }) 163 | } 164 | 165 | case BlogPost: 166 | if testCase.withInterface { 167 | m.Post(testRoute, binder(BlogPost{}, (*modeler)(nil)), func(actual BlogPost, iface modeler, errs Errors) { 168 | if actual.Title != iface.Model() { 169 | t.Errorf("For '%s': expected the struct to be mapped to the context as an interface", 170 | testCase.description) 171 | } 172 | jsonTestHandler(actual, errs) 173 | }) 174 | } else { 175 | m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { 176 | jsonTestHandler(actual, errs) 177 | }) 178 | } 179 | } 180 | 181 | if testCase.payload == "-nil-" { 182 | payload = nil 183 | } else { 184 | payload = strings.NewReader(testCase.payload) 185 | } 186 | 187 | req, err := http.NewRequest("POST", testRoute, payload) 188 | if err != nil { 189 | panic(err) 190 | } 191 | req.Header.Set("Content-Type", testCase.contentType) 192 | 193 | m.ServeHTTP(httpRecorder, req) 194 | 195 | switch httpRecorder.Code { 196 | case http.StatusNotFound: 197 | panic("Routing is messed up in test fixture (got 404): check method and path") 198 | case http.StatusInternalServerError: 199 | panic("Something bad happened on '" + testCase.description + "'") 200 | default: 201 | if testCase.shouldSucceedOnJson && 202 | httpRecorder.Code != http.StatusOK && 203 | !testCase.shouldFailOnBind { 204 | t.Errorf("'%s' should have succeeded (except when using Bind, where it should fail), but returned HTTP status %d with body '%s'", 205 | testCase.description, httpRecorder.Code, httpRecorder.Body.String()) 206 | } 207 | } 208 | } 209 | 210 | type ( 211 | jsonTestCase struct { 212 | description string 213 | withInterface bool 214 | shouldSucceedOnJson bool 215 | shouldFailOnBind bool 216 | payload string 217 | contentType string 218 | expected interface{} 219 | } 220 | ) 221 | -------------------------------------------------------------------------------- /misc_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/go-martini/martini" 11 | ) 12 | 13 | // When binding from Form data, testing the type of data to bind 14 | // and converting a string into that type is tedious, so these tests 15 | // cover all those cases. 16 | func TestSetWithProperType(t *testing.T) { 17 | testInputs := map[string]string{ 18 | "successful": `integer=-1&integer8=-8&integer16=-16&integer32=-32&integer64=-64&uinteger=1&uinteger8=8&uinteger16=16&uinteger32=32&uinteger64=64&boolean_1=true&fl32_1=32.3232&fl64_1=-64.6464646464&str=string`, 19 | "errorful": `integer=&integer8=asdf&integer16=--&integer32=&integer64=dsf&uinteger=&uinteger8=asdf&uinteger16=+&uinteger32= 32 &uinteger64=+%20+&boolean_1=&boolean_2=asdf&fl32_1=asdf&fl32_2=&fl64_1=&fl64_2=asdfstr`, 20 | } 21 | 22 | expectedOutputs := map[string]Everything{ 23 | "successful": Everything{ 24 | Integer: -1, 25 | Integer8: -8, 26 | Integer16: -16, 27 | Integer32: -32, 28 | Integer64: -64, 29 | Uinteger: 1, 30 | Uinteger8: 8, 31 | Uinteger16: 16, 32 | Uinteger32: 32, 33 | Uinteger64: 64, 34 | Boolean_1: true, 35 | Fl32_1: 32.3232, 36 | Fl64_1: -64.6464646464, 37 | Str: "string", 38 | }, 39 | "errorful": Everything{}, 40 | } 41 | 42 | for key, testCase := range testInputs { 43 | httpRecorder := httptest.NewRecorder() 44 | m := martini.Classic() 45 | 46 | m.Post(testRoute, Form(Everything{}), func(actual Everything, errs Errors) { 47 | actualStr := fmt.Sprintf("%+v", actual) 48 | expectedStr := fmt.Sprintf("%+v", expectedOutputs[key]) 49 | if actualStr != expectedStr { 50 | t.Errorf("For '%s' expected\n%s but got\n%s", key, expectedStr, actualStr) 51 | } 52 | }) 53 | req, err := http.NewRequest("POST", testRoute, strings.NewReader(testCase)) 54 | if err != nil { 55 | panic(err) 56 | } 57 | req.Header.Set("Content-Type", formContentType) 58 | m.ServeHTTP(httpRecorder, req) 59 | } 60 | } 61 | 62 | // Each binder middleware should assert that the struct passed in is not 63 | // a pointer (to avoid race conditions) 64 | func TestEnsureNotPointer(t *testing.T) { 65 | shouldPanic := func() { 66 | defer func() { 67 | r := recover() 68 | if r == nil { 69 | t.Errorf("Should have panicked because argument is a pointer, but did NOT panic") 70 | } 71 | }() 72 | ensureNotPointer(&Post{}) 73 | } 74 | 75 | shouldNotPanic := func() { 76 | defer func() { 77 | r := recover() 78 | if r != nil { 79 | t.Errorf("Should NOT have panicked because argument is not a pointer, but did panic") 80 | } 81 | }() 82 | ensureNotPointer(Post{}) 83 | } 84 | 85 | shouldPanic() 86 | shouldNotPanic() 87 | } 88 | 89 | // Used in testing setWithProperType; kind of clunky... 90 | type Everything struct { 91 | Integer int `form:"integer"` 92 | Integer8 int8 `form:"integer8"` 93 | Integer16 int16 `form:"integer16"` 94 | Integer32 int32 `form:"integer32"` 95 | Integer64 int64 `form:"integer64"` 96 | Uinteger uint `form:"uinteger"` 97 | Uinteger8 uint8 `form:"uinteger8"` 98 | Uinteger16 uint16 `form:"uinteger16"` 99 | Uinteger32 uint32 `form:"uinteger32"` 100 | Uinteger64 uint64 `form:"uinteger64"` 101 | Boolean_1 bool `form:"boolean_1"` 102 | Boolean_2 bool `form:"boolean_2"` 103 | Fl32_1 float32 `form:"fl32_1"` 104 | Fl32_2 float32 `form:"fl32_2"` 105 | Fl64_1 float64 `form:"fl64_1"` 106 | Fl64_2 float64 `form:"fl64_2"` 107 | Str string `form:"str"` 108 | } 109 | -------------------------------------------------------------------------------- /multipart_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "mime/multipart" 7 | "net/http" 8 | "net/http/httptest" 9 | "strconv" 10 | "testing" 11 | 12 | "github.com/go-martini/martini" 13 | ) 14 | 15 | var multipartFormTestCases = []multipartFormTestCase{ 16 | { 17 | description: "Happy multipart form path", 18 | shouldSucceed: true, 19 | inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 20 | }, 21 | { 22 | description: "FormValue called before req.MultipartReader(); see https://github.com/martini-contrib/csrf/issues/6", 23 | shouldSucceed: true, 24 | callFormValueBefore: true, 25 | inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, 26 | }, 27 | { 28 | description: "Empty payload", 29 | shouldSucceed: false, 30 | inputAndExpected: BlogPost{}, 31 | }, 32 | { 33 | description: "Missing required field (Id)", 34 | shouldSucceed: false, 35 | inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Author: Person{Name: "Matt Holt"}}, 36 | }, 37 | { 38 | description: "Required embedded struct field not specified", 39 | shouldSucceed: false, 40 | inputAndExpected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, 41 | }, 42 | { 43 | description: "Required nested struct field not specified", 44 | shouldSucceed: false, 45 | inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, 46 | }, 47 | { 48 | description: "Multiple values", 49 | shouldSucceed: true, 50 | inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, Ratings: []int{3, 5, 4}}, 51 | }, 52 | { 53 | description: "Bad multipart encoding", 54 | shouldSucceed: false, 55 | malformEncoding: true, 56 | }, 57 | } 58 | 59 | func TestMultipartForm(t *testing.T) { 60 | for _, testCase := range multipartFormTestCases { 61 | performMultipartFormTest(t, MultipartForm, testCase) 62 | } 63 | } 64 | 65 | func performMultipartFormTest(t *testing.T, binder handlerFunc, testCase multipartFormTestCase) { 66 | httpRecorder := httptest.NewRecorder() 67 | m := martini.Classic() 68 | 69 | m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { 70 | if testCase.shouldSucceed && len(errs) > 0 { 71 | t.Errorf("'%s' should have succeeded, but there were errors (%d):\n%+v", 72 | testCase.description, len(errs), errs) 73 | } else if !testCase.shouldSucceed && len(errs) == 0 { 74 | t.Errorf("'%s' should not have succeeded, but it did (there were no errors)", testCase.description) 75 | } 76 | expString := fmt.Sprintf("%+v", testCase.inputAndExpected) 77 | actString := fmt.Sprintf("%+v", actual) 78 | if actString != expString { 79 | t.Errorf("'%s': expected\n'%s'\nbut got\n'%s'", 80 | testCase.description, expString, actString) 81 | } 82 | }) 83 | 84 | multipartPayload, mpWriter := makeMultipartPayload(testCase) 85 | 86 | req, err := http.NewRequest("POST", testRoute, multipartPayload) 87 | if err != nil { 88 | panic(err) 89 | } 90 | 91 | req.Header.Add("Content-Type", mpWriter.FormDataContentType()) 92 | 93 | err = mpWriter.Close() 94 | if err != nil { 95 | panic(err) 96 | } 97 | 98 | if testCase.callFormValueBefore { 99 | req.FormValue("foo") 100 | } 101 | 102 | m.ServeHTTP(httpRecorder, req) 103 | 104 | switch httpRecorder.Code { 105 | case http.StatusNotFound: 106 | panic("Routing is messed up in test fixture (got 404): check methods and paths") 107 | case http.StatusInternalServerError: 108 | panic("Something bad happened on '" + testCase.description + "'") 109 | } 110 | } 111 | 112 | // Writes the input from a test case into a buffer using the multipart writer. 113 | func makeMultipartPayload(testCase multipartFormTestCase) (*bytes.Buffer, *multipart.Writer) { 114 | body := &bytes.Buffer{} 115 | writer := multipart.NewWriter(body) 116 | if testCase.malformEncoding { 117 | // TODO: Break the multipart form parser which is apparently impervious!! 118 | // (Get it to return an error. Trying to get 100% test coverage.) 119 | body.Write([]byte(`--` + writer.Boundary() + `\nContent-Disposition: form-data; name="foo"\n\n--` + writer.Boundary() + `--`)) 120 | return body, writer 121 | } else { 122 | writer.WriteField("title", testCase.inputAndExpected.Title) 123 | writer.WriteField("content", testCase.inputAndExpected.Content) 124 | writer.WriteField("id", strconv.Itoa(testCase.inputAndExpected.Id)) 125 | writer.WriteField("ignored", testCase.inputAndExpected.Ignored) 126 | for _, value := range testCase.inputAndExpected.Ratings { 127 | writer.WriteField("rating", strconv.Itoa(value)) 128 | } 129 | writer.WriteField("name", testCase.inputAndExpected.Author.Name) 130 | writer.WriteField("email", testCase.inputAndExpected.Author.Email) 131 | writer.WriteField("unexported", testCase.inputAndExpected.unexported) 132 | return body, writer 133 | } 134 | } 135 | 136 | type ( 137 | multipartFormTestCase struct { 138 | description string 139 | shouldSucceed bool 140 | inputAndExpected BlogPost 141 | malformEncoding bool 142 | callFormValueBefore bool 143 | } 144 | ) 145 | -------------------------------------------------------------------------------- /validate_test.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/go-martini/martini" 10 | ) 11 | 12 | var validationTestCases = []validationTestCase{ 13 | { 14 | description: "No errors", 15 | data: BlogPost{ 16 | Id: 1, 17 | Post: Post{ 18 | Title: "Behold The Title!", 19 | Content: "And some content", 20 | }, 21 | Author: Person{ 22 | Name: "Matt Holt", 23 | }, 24 | }, 25 | expectedErrors: Errors{}, 26 | }, 27 | { 28 | description: "ID required", 29 | data: BlogPost{ 30 | Post: Post{ 31 | Title: "Behold The Title!", 32 | Content: "And some content", 33 | }, 34 | Author: Person{ 35 | Name: "Matt Holt", 36 | }, 37 | }, 38 | expectedErrors: Errors{ 39 | Error{ 40 | FieldNames: []string{"id"}, 41 | Classification: RequiredError, 42 | Message: "Required", 43 | }, 44 | }, 45 | }, 46 | { 47 | description: "Embedded struct field required", 48 | data: BlogPost{ 49 | Id: 1, 50 | Post: Post{ 51 | Content: "Content given, but title is required", 52 | }, 53 | Author: Person{ 54 | Name: "Matt Holt", 55 | }, 56 | }, 57 | expectedErrors: Errors{ 58 | Error{ 59 | FieldNames: []string{"title"}, 60 | Classification: RequiredError, 61 | Message: "Required", 62 | }, 63 | Error{ 64 | FieldNames: []string{"title"}, 65 | Classification: "LengthError", 66 | Message: "Life is too short", 67 | }, 68 | }, 69 | }, 70 | { 71 | description: "Nested struct field required", 72 | data: BlogPost{ 73 | Id: 1, 74 | Post: Post{ 75 | Title: "Behold The Title!", 76 | Content: "And some content", 77 | }, 78 | }, 79 | expectedErrors: Errors{ 80 | Error{ 81 | FieldNames: []string{"name"}, 82 | Classification: RequiredError, 83 | Message: "Required", 84 | }, 85 | }, 86 | }, 87 | { 88 | description: "Required field missing in nested struct pointer", 89 | data: BlogPost{ 90 | Id: 1, 91 | Post: Post{ 92 | Title: "Behold The Title!", 93 | Content: "And some content", 94 | }, 95 | Author: Person{ 96 | Name: "Matt Holt", 97 | }, 98 | Coauthor: &Person{}, 99 | }, 100 | expectedErrors: Errors{ 101 | Error{ 102 | FieldNames: []string{"name"}, 103 | Classification: RequiredError, 104 | Message: "Required", 105 | }, 106 | }, 107 | }, 108 | { 109 | description: "All required fields specified in nested struct pointer", 110 | data: BlogPost{ 111 | Id: 1, 112 | Post: Post{ 113 | Title: "Behold The Title!", 114 | Content: "And some content", 115 | }, 116 | Author: Person{ 117 | Name: "Matt Holt", 118 | }, 119 | Coauthor: &Person{ 120 | Name: "Jeremy Saenz", 121 | }, 122 | }, 123 | expectedErrors: Errors{}, 124 | }, 125 | { 126 | description: "Custom validation should put an error", 127 | data: BlogPost{ 128 | Id: 1, 129 | Post: Post{ 130 | Title: "Too short", 131 | Content: "And some content", 132 | }, 133 | Author: Person{ 134 | Name: "Matt Holt", 135 | }, 136 | }, 137 | expectedErrors: Errors{ 138 | Error{ 139 | FieldNames: []string{"title"}, 140 | Classification: "LengthError", 141 | Message: "Life is too short", 142 | }, 143 | }, 144 | }, 145 | { 146 | description: "List Validation", 147 | data: []BlogPost{ 148 | BlogPost{ 149 | Id: 1, 150 | Post: Post{ 151 | Title: "First Post", 152 | Content: "And some content", 153 | }, 154 | Author: Person{ 155 | Name: "Leeor Aharon", 156 | }, 157 | }, 158 | BlogPost{ 159 | Id: 2, 160 | Post: Post{ 161 | Title: "Second Post", 162 | Content: "And some content", 163 | }, 164 | Author: Person{ 165 | Name: "Leeor Aharon", 166 | }, 167 | }, 168 | }, 169 | expectedErrors: Errors{}, 170 | }, 171 | { 172 | description: "List Validation w/ Errors", 173 | data: []BlogPost{ 174 | BlogPost{ 175 | Id: 1, 176 | Post: Post{ 177 | Title: "First Post", 178 | Content: "And some content", 179 | }, 180 | Author: Person{ 181 | Name: "Leeor Aharon", 182 | }, 183 | }, 184 | BlogPost{ 185 | Id: 2, 186 | Post: Post{ 187 | Title: "Too Short", 188 | Content: "And some content", 189 | }, 190 | Author: Person{ 191 | Name: "Leeor Aharon", 192 | }, 193 | }, 194 | }, 195 | expectedErrors: Errors{ 196 | Error{ 197 | FieldNames: []string{"title"}, 198 | Classification: "LengthError", 199 | Message: "Life is too short", 200 | }, 201 | }, 202 | }, 203 | } 204 | 205 | func TestValidation(t *testing.T) { 206 | for _, testCase := range validationTestCases { 207 | performValidationTest(t, testCase) 208 | } 209 | } 210 | 211 | func performValidationTest(t *testing.T, testCase validationTestCase) { 212 | httpRecorder := httptest.NewRecorder() 213 | m := martini.Classic() 214 | 215 | m.Post(testRoute, Validate(testCase.data), func(actual Errors) { 216 | expString := fmt.Sprintf("%+v", testCase.expectedErrors) 217 | actString := fmt.Sprintf("%+v", actual) 218 | if actString != expString { 219 | t.Errorf("For '%s': expected errors to be\n'%s'\nbut got\n'%s'", 220 | testCase.description, expString, actString) 221 | } 222 | }) 223 | 224 | req, err := http.NewRequest("POST", testRoute, nil) 225 | if err != nil { 226 | panic(err) 227 | } 228 | 229 | m.ServeHTTP(httpRecorder, req) 230 | 231 | switch httpRecorder.Code { 232 | case http.StatusNotFound: 233 | panic("Routing is messed up in test fixture (got 404): check methods and paths") 234 | case http.StatusInternalServerError: 235 | panic("Something bad happened on '" + testCase.description + "'") 236 | } 237 | } 238 | 239 | type ( 240 | validationTestCase struct { 241 | description string 242 | data interface{} 243 | expectedErrors Errors 244 | } 245 | ) 246 | -------------------------------------------------------------------------------- /wercker.yml: -------------------------------------------------------------------------------- 1 | box: wercker/golang@1.1.1 --------------------------------------------------------------------------------