From 4a5c02e4d13e014014606de14799c6f30e979f13 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 18 May 2025 14:01:54 +0200 Subject: [PATCH] Adding upstream version 1.0.0. Signed-off-by: Daniel Baumann --- .forgejo/workflows/test.yml | 23 ++ .gitignore | 1 + LICENSE | 191 ++++++++++ README.md | 5 + bind_test.go | 46 +++ binding.go | 739 ++++++++++++++++++++++++++++++++++++ common_test.go | 124 ++++++ errorhandler_test.go | 161 ++++++++ errors.go | 160 ++++++++ errors_test.go | 93 +++++ file_test.go | 190 +++++++++ form_test.go | 333 ++++++++++++++++ go.mod | 14 + go.sum | 12 + json_test.go | 269 +++++++++++++ misc_test.go | 122 ++++++ multipart_test.go | 154 ++++++++ renovate.json | 4 + validate_test.go | 613 ++++++++++++++++++++++++++++++ 19 files changed, 3254 insertions(+) create mode 100644 .forgejo/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bind_test.go create mode 100644 binding.go create mode 100755 common_test.go create mode 100755 errorhandler_test.go create mode 100644 errors.go create mode 100755 errors_test.go create mode 100755 file_test.go create mode 100755 form_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 json_test.go create mode 100755 misc_test.go create mode 100755 multipart_test.go create mode 100644 renovate.json create mode 100755 validate_test.go diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..a0eb9c7 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,23 @@ +name: test +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: docker-bookworm + container: + image: 'code.forgejo.org/oci/node:20-bookworm' + steps: + - uses: https://code.forgejo.org/actions/checkout@v4 + - uses: https://code.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: golangci-lint + uses: https://github.com/golangci/golangci-lint-action@v6 + with: + version: v1.61.0 # renovate: datasource=go depName=golangci-lint packageName=github.com/golangci/golangci-lint/cmd/golangci-lint + - name: test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8405e89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..13fa3dc --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +Middleware binding provides request data binding and validation for net/http, It's a fork of [gitea/binding](https://gitea.com/go-chi/binding) which is a fork of [Macaron](https://github.com/go-macaron/macaron). + +## License + +This project is under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for the full license text. diff --git a/bind_test.go b/bind_test.go new file mode 100644 index 0000000..cb9a79b --- /dev/null +++ b/bind_test.go @@ -0,0 +1,46 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "testing" +) + +func Test_Bind(t *testing.T) { + t.Run("Bind form", func(t *testing.T) { + for _, testCase := range formTestCases { + performFormTest(t, Bind, testCase) + } + }) + + t.Run("Bind JSON", func(t *testing.T) { + for _, testCase := range jsonTestCases { + performJSONTest(t, Bind, testCase) + } + }) + + t.Run("Bind multipart form", func(t *testing.T) { + for _, testCase := range multipartFormTestCases { + performMultipartFormTest(t, Bind, testCase) + } + }) + + t.Run("Bind with file", func(t *testing.T) { + for _, testCase := range fileTestCases { + performFileTest(t, Bind, testCase) + } + }) +} diff --git a/binding.go b/binding.go new file mode 100644 index 0000000..2083fdd --- /dev/null +++ b/binding.go @@ -0,0 +1,739 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// Copyright 2020 The Gitea Authors +// Copyright 2024 The Forgejo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Package binding is a middleware that provides request data binding and validation for Chi. +package binding + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +// Bind wraps up the functionality of the Form and Json middleware +// according to the Content-Type and verb of the request. +// A Content-Type is required for POST and PUT requests. +// Bind invokes the ErrorHandler middleware to bail out if errors +// occurred. If you want to perform your own error handling, use +// Form or Json middleware directly. An interface pointer can +// be added as a second argument in order to map the struct to +// a specific interface. +func Bind(req *http.Request, obj any) Errors { + contentType := req.Header.Get("Content-Type") + if req.Method == "POST" || req.Method == "PUT" || len(contentType) > 0 { + switch { + case strings.Contains(contentType, "form-urlencoded"): + return Form(req, obj) + case strings.Contains(contentType, "multipart/form-data"): + return MultipartForm(req, obj) + case strings.Contains(contentType, "json"): + return JSON(req, obj) + default: + var errors Errors + if contentType == "" { + errors.Add([]string{}, ERR_CONTENT_TYPE, "Empty Content-Type") + } else { + errors.Add([]string{}, ERR_CONTENT_TYPE, "Unsupported Content-Type") + } + return errors + } + } else { + return Form(req, obj) + } +} + +const jsonContentType = "application/json; charset=utf-8" + +// errorHandler simply counts the number of errors in the +// context and, if more than 0, writes a response with an +// error code and a JSON payload describing the errors. +// The response will have a JSON content-type. +// Middleware remaining on the stack will not even see the request +// if, by this point, there are any errors. +// This is a "default" handler, of sorts, and you are +// welcome to use your own instead. The Bind middleware +// invokes this automatically for convenience. +func errorHandler(errs Errors, rw http.ResponseWriter) { + if len(errs) > 0 { + rw.Header().Set("Content-Type", jsonContentType) + switch { + case errs.Has(ERR_DESERIALIZATION): + rw.WriteHeader(http.StatusBadRequest) + case errs.Has(ERR_CONTENT_TYPE): + rw.WriteHeader(http.StatusUnsupportedMediaType) + default: + rw.WriteHeader(http.StatusUnprocessableEntity) + } + errOutput, _ := json.Marshal(errs) + _, _ = rw.Write(errOutput) + return + } +} + +// Form is middleware to deserialize form-urlencoded data from the request. +// It gets data from the form-urlencoded body, if present, or from the +// query string. It uses the http.Request.ParseForm() method +// to perform deserialization, then reflection is used to map each field +// into the struct with the proper type. Structs with primitive slice types +// (bool, float, int, string) can support deserialization of repeated form +// keys, for example: key=val1&key=val2&key=val3 +// An interface pointer can be added as a second argument in order +// to map the struct to a specific interface. +func Form(req *http.Request, formStruct any) Errors { + var errors Errors + + ensurePointer(formStruct) + formStructV := reflect.ValueOf(formStruct) + parseErr := req.ParseForm() + + // Format validation of the request body or the URL would add considerable overhead, + // and ParseForm does not complain when URL encoding is off. + // Because an empty request body or url can also mean absence of all needed values, + // it is not in all cases a bad request, so let's return 422. + if parseErr != nil { + errors.Add([]string{}, ERR_DESERIALIZATION, parseErr.Error()) + } + errors = mapForm(formStructV, req.Form, nil, errors) + return append(errors, Validate(req, formStruct)...) +} + +// MaxMemory represents maximum amount of memory to use when parsing a multipart form. +// Set this to whatever value you prefer; default is 10 MB. +var MaxMemory = int64(1024 * 1024 * 10) + +// MultipartForm works much like Form, except it can parse multipart forms +// and handle file uploads. Like the other deserialization middleware handlers, +// you can pass in an interface to make the interface available for injection +// into other handlers later. +func MultipartForm(req *http.Request, formStruct any) Errors { + var errors Errors + ensurePointer(formStruct) + formStructV := reflect.ValueOf(formStruct) + // This if check is necessary due to https://github.com/martini-contrib/csrf/issues/6 + if req.MultipartForm == nil { + // Workaround for multipart forms returning nil instead of an error + // when content is not multipart; see https://code.google.com/p/go/issues/detail?id=6334 + if multipartReader, err := req.MultipartReader(); err != nil { + errors.Add([]string{}, ERR_DESERIALIZATION, err.Error()) + } else { + form, parseErr := multipartReader.ReadForm(MaxMemory) + if parseErr != nil { + errors.Add([]string{}, ERR_DESERIALIZATION, parseErr.Error()) + } + + if req.Form == nil { + _ = req.ParseForm() + } + if form == nil { + return append(errors, Validate(req, formStruct)...) + } + for k, v := range form.Value { + req.Form[k] = append(req.Form[k], v...) + } + + req.MultipartForm = form + } + } + errors = mapForm(formStructV, req.MultipartForm.Value, req.MultipartForm.File, errors) + return append(errors, Validate(req, formStruct)...) +} + +// JSON is middleware to deserialize a JSON payload from the request +// into the struct that is passed in. The resulting struct is then +// validated, but no error handling is actually performed here. +// An interface pointer can be added as a second argument in order +// to map the struct to a specific interface. +func JSON(req *http.Request, jsonStruct any) Errors { + var errors Errors + ensurePointer(jsonStruct) + + if req.Body != nil { + defer req.Body.Close() + err := json.NewDecoder(req.Body).Decode(jsonStruct) + if err != nil && err != io.EOF { + errors.Add([]string{}, ERR_DESERIALIZATION, err.Error()) + } + } + return append(errors, Validate(req, jsonStruct)...) +} + +// RawValidate is same as Validate but does not require a HTTP context, +// and can be used independently just for validation. +// This function does not support Validator interface. +func RawValidate(obj any) Errors { + var errs Errors + v := reflect.ValueOf(obj) + k := v.Kind() + if k == reflect.Interface || k == reflect.Ptr { + v = v.Elem() + k = v.Kind() + } + if k == reflect.Slice || k == reflect.Array { + for i := 0; i < v.Len(); i++ { + e := v.Index(i).Interface() + errs = validateStruct(errs, e) + } + } else { + errs = validateStruct(errs, obj) + } + return errs +} + +// Validate is middleware to enforce required fields. If the struct +// passed in implements Validator, then the user-defined Validate method +// is executed, and its errors are mapped to the context. This middleware +// performs no error handling: it merely detects errors and maps them. +func Validate(req *http.Request, obj any) Errors { + var errs Errors + v := reflect.ValueOf(obj) + k := v.Kind() + if k == reflect.Interface || k == reflect.Ptr { + v = v.Elem() + k = v.Kind() + } + if k == reflect.Slice || k == reflect.Array { + for i := 0; i < v.Len(); i++ { + e := v.Index(i).Interface() + errs = validateStruct(errs, e) + if validator, ok := e.(Validator); ok { + errs = validator.Validate(req, errs) + } + } + } else { + errs = validateStruct(errs, obj) + if validator, ok := obj.(Validator); ok { + errs = validator.Validate(req, errs) + } + } + return errs +} + +var ( + AlphaDashPattern = regexp.MustCompile(`[^\d\w-_]`) + AlphaDashDotPattern = regexp.MustCompile(`[^\d\w-_\.]`) + EmailPattern = regexp.MustCompile(`\A[\w!#$%&'*+/=?^_` + "`" + `{|}~-]+(?:\.[\w!#$%&'*+/=?^_` + "`" + `{|}~-]+)*@(?:[\w](?:[\w-]*[\w])?\.)+[a-zA-Z0-9](?:[\w-]*[\w])?\z`) +) + +// Copied from github.com/asaskevich/govalidator. +const ( + maxURLRuneCount = 2083 + minURLRuneCount = 3 +) + +var ( + urlSchemaRx = `((ftp|tcp|udp|wss?|https?):\/\/)` + urlUsernameRx = `(\S+(:\S*)?@)` + urlIPRx = `([1-9]\d?|1\d\d|2[01]\d|22[0-3])(\.(1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))` + ipRx = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))` + urlSubdomainRx = `((www\.)|([a-zA-Z0-9]([-\.][-\._a-zA-Z0-9]+)*))` + urlPortRx = `(:(\d{1,5}))` + urlPathRx = `((\/|\?|#)[^\s]*)` + URLPattern = regexp.MustCompile(`\A` + urlSchemaRx + `?` + urlUsernameRx + `?` + `((` + urlIPRx + `|(\[` + ipRx + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + urlSubdomainRx + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + urlPortRx + `?` + urlPathRx + `?\z`) +) + +// IsURL check if the string is an URL. +func isURL(str string) bool { + if str == "" || utf8.RuneCountInString(str) >= maxURLRuneCount || len(str) <= minURLRuneCount || strings.HasPrefix(str, ".") { + return false + } + u, err := url.Parse(str) + if err != nil { + return false + } + if strings.HasPrefix(u.Host, ".") { + return false + } + if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) { + return false + } + return URLPattern.MatchString(str) +} + +type ( + // Rule represents a validation rule. + Rule struct { + // IsMatch checks if rule matches. + IsMatch func(string) bool + // IsValid applies validation rule to condition. + IsValid func(Errors, string, any) (bool, Errors) + } + + // ParamRule does same thing as Rule but passes rule itself to IsValid method. + ParamRule struct { + // IsMatch checks if rule matches. + IsMatch func(string) bool + // IsValid applies validation rule to condition. + IsValid func(Errors, string, string, any) (bool, Errors) + } + + // RuleMapper and ParamRuleMapper represent validation rule mappers, + // it allwos users to add custom validation rules. + RuleMapper []*Rule + ParamRuleMapper []*ParamRule +) + +var ( + ruleMapper RuleMapper + paramRuleMapper ParamRuleMapper +) + +// AddRule adds new validation rule. +func AddRule(r *Rule) { + ruleMapper = append(ruleMapper, r) +} + +// AddParamRule adds new validation rule. +func AddParamRule(r *ParamRule) { + paramRuleMapper = append(paramRuleMapper, r) +} + +func in(fieldValue any, arr string) bool { + val := fmt.Sprintf("%v", fieldValue) + vals := strings.Split(arr, ",") + isIn := false + for _, v := range vals { + if v == val { + isIn = true + break + } + } + return isIn +} + +func parseFormName(raw, actual string) string { + if len(actual) > 0 { + return actual + } + return nameMapper(raw) +} + +// Performs required field checking on a struct +func validateStruct(errors Errors, obj any) Errors { + typ := reflect.TypeOf(obj) + val := reflect.ValueOf(obj) + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + val = val.Elem() + } + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + + // Allow ignored fields in the struct + if field.Tag.Get("form") == "-" || !val.Field(i).CanInterface() { + continue + } + + fieldVal := val.Field(i) + fieldValue := fieldVal.Interface() + zero := reflect.Zero(field.Type).Interface() + + // Validate nested and embedded structs (if pointer, only do so if not nil) + if field.Type.Kind() == reflect.Struct || + (field.Type.Kind() == reflect.Ptr && !reflect.DeepEqual(zero, fieldValue) && + field.Type.Elem().Kind() == reflect.Struct) { + errors = validateStruct(errors, fieldValue) + continue + } + + if fieldVal.Kind() == reflect.Ptr { + isZero := reflect.DeepEqual(zero, fieldValue) + + zero = reflect.Zero(fieldVal.Type().Elem()).Interface() + fieldVal = fieldVal.Elem() + + if isZero { + fieldValue = zero + } else { + fieldValue = fieldVal.Interface() + } + } + + errors = validateField(errors, zero, field, fieldVal, fieldValue) + } + return errors +} + +// Don't pass in pointers to bind to. Can lead to bugs. +func ensureNotPointer(obj any) { + if reflect.TypeOf(obj).Kind() == reflect.Ptr { + panic("Pointers are not accepted as binding models") + } +} + +func validateField(errors Errors, zero any, field reflect.StructField, fieldVal reflect.Value, fieldValue any) Errors { + if fieldVal.Kind() == reflect.Slice { + for i := 0; i < fieldVal.Len(); i++ { + sliceVal := fieldVal.Index(i) + if sliceVal.Kind() == reflect.Ptr { + sliceVal = sliceVal.Elem() + } + + sliceValue := sliceVal.Interface() + zero := reflect.Zero(sliceVal.Type()).Interface() + if sliceVal.Kind() == reflect.Struct || + (sliceVal.Kind() == reflect.Ptr && !reflect.DeepEqual(zero, sliceValue) && + sliceVal.Elem().Kind() == reflect.Struct) { + errors = validateStruct(errors, sliceValue) + } + /* Apply validation rules to each item in a slice. ISSUE #3 + else { + errors = validateField(errors, zero, field, sliceVal, sliceValue) + }*/ + } + } + + rules := strings.Split(field.Tag.Get("binding"), ";") + preProcessorRules := strings.Split(field.Tag.Get("preprocess"), ";") + + if reflect.DeepEqual(zero, fieldValue) { + for _, rule := range rules { + if rule == "Required" { + errors.Add([]string{field.Name}, ERR_REQUIRED, "Required") + break + } + if strings.HasPrefix(rule, "Default(") { + if fieldVal.CanSet() { + errors = setWithProperType(field.Type.Kind(), rule[8:len(rule)-1], fieldVal, field.Tag.Get("form"), preProcessorRules, errors) + } else { + errors.Add([]string{field.Name}, ERR_EXCLUDE, "Default") + } + break + } + } + + return errors + } + +VALIDATE_RULES: + for _, rule := range rules { + if len(rule) == 0 { + continue + } + + switch { + case rule == "Required": + continue + case strings.HasPrefix(rule, "Default("): + continue + case rule == "OmitEmpty": // legacy + continue + + case rule == "AlphaDash": + if AlphaDashPattern.MatchString(fmt.Sprintf("%v", fieldValue)) { + errors.Add([]string{field.Name}, ERR_ALPHA_DASH, "AlphaDash") + break VALIDATE_RULES + } + case rule == "AlphaDashDot": + if AlphaDashDotPattern.MatchString(fmt.Sprintf("%v", fieldValue)) { + errors.Add([]string{field.Name}, ERR_ALPHA_DASH_DOT, "AlphaDashDot") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "Size("): + size, _ := strconv.Atoi(rule[5 : len(rule)-1]) + if str, ok := fieldValue.(string); ok && utf8.RuneCountInString(str) != size { + errors.Add([]string{field.Name}, ERR_SIZE, "Size") + break VALIDATE_RULES + } + if fieldVal.Kind() == reflect.Slice && fieldVal.Len() != size { + errors.Add([]string{field.Name}, ERR_SIZE, "Size") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "MinSize("): + min, _ := strconv.Atoi(rule[8 : len(rule)-1]) + if str, ok := fieldValue.(string); ok && utf8.RuneCountInString(str) < min { + errors.Add([]string{field.Name}, ERR_MIN_SIZE, "MinSize") + break VALIDATE_RULES + } + if fieldVal.Kind() == reflect.Slice && fieldVal.Len() < min { + errors.Add([]string{field.Name}, ERR_MIN_SIZE, "MinSize") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "MaxSize("): + max, _ := strconv.Atoi(rule[8 : len(rule)-1]) + if str, ok := fieldValue.(string); ok && utf8.RuneCountInString(str) > max { + errors.Add([]string{field.Name}, ERR_MAX_SIZE, "MaxSize") + break VALIDATE_RULES + } + if fieldVal.Kind() == reflect.Slice && fieldVal.Len() > max { + errors.Add([]string{field.Name}, ERR_MAX_SIZE, "MaxSize") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "Range("): + nums := strings.Split(rule[6:len(rule)-1], ",") + if len(nums) != 2 { + break VALIDATE_RULES + } + val, _ := strconv.Atoi(fmt.Sprintf("%v", fieldValue)) + nums0, _ := strconv.Atoi(nums[0]) + nums1, _ := strconv.Atoi(nums[1]) + if val < nums0 || val > nums1 { + errors.Add([]string{field.Name}, ERR_RANGE, "Range") + break VALIDATE_RULES + } + case rule == "Email": + if !EmailPattern.MatchString(fmt.Sprintf("%v", fieldValue)) { + errors.Add([]string{field.Name}, ERR_EMAIL, "Email") + break VALIDATE_RULES + } + case rule == "Url": + str := fmt.Sprintf("%v", fieldValue) + if !isURL(str) { + errors.Add([]string{field.Name}, ERR_URL, "Url") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "In("): + if !in(fieldValue, rule[3:len(rule)-1]) { + errors.Add([]string{field.Name}, ERR_IN, "In") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "NotIn("): + if in(fieldValue, rule[6:len(rule)-1]) { + errors.Add([]string{field.Name}, ERR_NOT_INT, "NotIn") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "Include("): + if !strings.Contains(fmt.Sprintf("%v", fieldValue), rule[8:len(rule)-1]) { + errors.Add([]string{field.Name}, ERR_INCLUDE, "Include") + break VALIDATE_RULES + } + case strings.HasPrefix(rule, "Exclude("): + if strings.Contains(fmt.Sprintf("%v", fieldValue), rule[8:len(rule)-1]) { + errors.Add([]string{field.Name}, ERR_EXCLUDE, "Exclude") + break VALIDATE_RULES + } + default: + // Apply custom validation rules + var isValid bool + for i := range ruleMapper { + if ruleMapper[i].IsMatch(rule) { + isValid, errors = ruleMapper[i].IsValid(errors, field.Name, fieldValue) + if !isValid { + break VALIDATE_RULES + } + } + } + for i := range paramRuleMapper { + if paramRuleMapper[i].IsMatch(rule) { + isValid, errors = paramRuleMapper[i].IsValid(errors, rule, field.Name, fieldValue) + if !isValid { + break VALIDATE_RULES + } + } + } + } + } + return errors +} + +// NameMapper represents a form tag name mapper. +type NameMapper func(string) string + +var nameMapper = func(field string) string { + newstr := make([]rune, 0, len(field)) + for i, chr := range field { + if isUpper := 'A' <= chr && chr <= 'Z'; isUpper { + if i > 0 { + newstr = append(newstr, '_') + } + chr -= ('A' - 'a') + } + newstr = append(newstr, chr) + } + return string(newstr) +} + +// SetNameMapper sets name mapper. +func SetNameMapper(nm NameMapper) { + nameMapper = nm +} + +// Takes values from the form data and puts them into a struct +func mapForm(formStruct reflect.Value, form map[string][]string, + formfile map[string][]*multipart.FileHeader, errors Errors, +) Errors { + if formStruct.Kind() == reflect.Ptr { + formStruct = formStruct.Elem() + } + typ := formStruct.Type() + + for i := 0; i < typ.NumField(); i++ { + typeField := typ.Field(i) + structField := formStruct.Field(i) + + if typeField.Type.Kind() == reflect.Ptr && typeField.Anonymous { + structField.Set(reflect.New(typeField.Type.Elem())) + errors = mapForm(structField.Elem(), form, formfile, errors) + if reflect.DeepEqual(structField.Elem().Interface(), reflect.Zero(structField.Elem().Type()).Interface()) { + structField.Set(reflect.Zero(structField.Type())) + } + } else if typeField.Type.Kind() == reflect.Struct { + errors = mapForm(structField, form, formfile, errors) + } + + inputFieldName := parseFormName(typeField.Name, typeField.Tag.Get("form")) + if len(inputFieldName) == 0 || !structField.CanSet() { + continue + } + + preProcessorRules := strings.Split(typeField.Tag.Get("preprocess"), ";") + + inputValue, exists := form[inputFieldName] + if exists { + numElems := len(inputValue) + if structField.Kind() == reflect.Slice && numElems > 0 { + sliceOf := structField.Type().Elem().Kind() + slice := reflect.MakeSlice(structField.Type(), numElems, numElems) + for i := 0; i < numElems; i++ { + errors = setWithProperType(sliceOf, inputValue[i], slice.Index(i), inputFieldName, preProcessorRules, errors) + } + formStruct.Field(i).Set(slice) + } else { + errors = setWithProperType(typeField.Type.Kind(), inputValue[0], structField, inputFieldName, preProcessorRules, errors) + } + continue + } + + inputFile, exists := formfile[inputFieldName] + if !exists { + continue + } + fhType := reflect.TypeOf((*multipart.FileHeader)(nil)) + numElems := len(inputFile) + if structField.Kind() == reflect.Slice && numElems > 0 && structField.Type().Elem() == fhType { + slice := reflect.MakeSlice(structField.Type(), numElems, numElems) + for i := 0; i < numElems; i++ { + slice.Index(i).Set(reflect.ValueOf(inputFile[i])) + } + structField.Set(slice) + } else if structField.Type() == fhType { + structField.Set(reflect.ValueOf(inputFile[0])) + } + } + return errors +} + +// This sets the value in a struct of an indeterminate type to the +// matching value from the request (via Form middleware) in the +// same type, so that not all deserialized values have to be strings. +// Supported types are string, int, float, bool, and ptr of these types. +func setWithProperType(valueKind reflect.Kind, val string, structField reflect.Value, nameInTag string, preProcessorRules []string, errors Errors) Errors { + switch valueKind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val == "" { + val = "0" + } + intVal, err := strconv.ParseInt(val, 10, 64) + if err != nil { + errors.Add([]string{nameInTag}, ERR_INTERGER_TYPE, "Value could not be parsed as integer") + } else { + structField.SetInt(intVal) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val == "" { + val = "0" + } + uintVal, err := strconv.ParseUint(val, 10, 64) + if err != nil { + errors.Add([]string{nameInTag}, ERR_INTERGER_TYPE, "Value could not be parsed as unsigned integer") + } else { + structField.SetUint(uintVal) + } + case reflect.Bool: + if val == "on" { + structField.SetBool(true) + break + } + + if val == "" { + val = "false" + } + boolVal, err := strconv.ParseBool(val) + if err != nil { + errors.Add([]string{nameInTag}, ERR_BOOLEAN_TYPE, "Value could not be parsed as boolean") + } else if boolVal { + structField.SetBool(true) + } + case reflect.Float32: + if val == "" { + val = "0.0" + } + floatVal, err := strconv.ParseFloat(val, 32) + if err != nil { + errors.Add([]string{nameInTag}, ERR_FLOAT_TYPE, "Value could not be parsed as 32-bit float") + } else { + structField.SetFloat(floatVal) + } + case reflect.Float64: + if val == "" { + val = "0.0" + } + floatVal, err := strconv.ParseFloat(val, 64) + if err != nil { + errors.Add([]string{nameInTag}, ERR_FLOAT_TYPE, "Value could not be parsed as 64-bit float") + } else { + structField.SetFloat(floatVal) + } + case reflect.String: + if slices.Contains(preProcessorRules, "TrimSpace") { + val = strings.TrimSpace(val) + } + structField.SetString(val) + case reflect.Ptr: + newVal := reflect.New(structField.Type().Elem()) + errors = setWithProperType(structField.Type().Elem().Kind(), val, newVal.Elem(), nameInTag, preProcessorRules, errors) + structField.Set(newVal) + } + return errors +} + +// Pointers must be bind to. +func ensurePointer(obj any) { + if reflect.TypeOf(obj).Kind() != reflect.Ptr { + panic("Pointers are only accepted as binding models") + } +} + +type ( + // ErrorHandler is the interface that has custom error handling process. + ErrorHandler interface { + // Error handles validation errors with custom process. + Error(*http.Request, Errors) + } + + // Validator is the interface that handles some rudimentary + // request validation logic so your application doesn't have to. + Validator interface { + // Validate validates that the request is OK. It is recommended + // that validation be limited to checking values for syntax and + // semantics, enough to know that you can make sense of the request + // in your application. For example, you might verify that a credit + // card number matches a valid pattern, but you probably wouldn't + // perform an actual credit card authorization here. + Validate(*http.Request, Errors) Errors + } +) diff --git a/common_test.go b/common_test.go new file mode 100755 index 0000000..c36bd74 --- /dev/null +++ b/common_test.go @@ -0,0 +1,124 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "mime/multipart" + "net/http" +) + +// These types are mostly contrived examples, but they're used +// across many test cases. The idea is to cover all the scenarios +// that this binding package might encounter in actual use. +type ( + // For basic test cases with a required field + Post struct { + Title string `form:"title" json:"title" binding:"Required"` + Content string `form:"content" json:"content"` + } + + // To be used as a nested struct (with a required field) + Person struct { + Name string `form:"name" json:"name" binding:"Required"` + Email string `form:"email" json:"email"` + } + + // For advanced test cases: multiple values, embedded + // and nested structs, an ignored field, and single + // and multiple file uploads + // + //nolint:all + BlogPost struct { + Post + Id int `binding:"Required"` // JSON not specified here for test coverage + Ignored string `form:"-" json:"-"` + Ratings []int `form:"rating" json:"ratings"` + Author Person `json:"author"` + Coauthor *Person `json:"coauthor"` + HeaderImage *multipart.FileHeader + Pictures []*multipart.FileHeader `form:"picture"` + unexported string `form:"unexported"` //nolint:all + } + + EmbedPerson struct { + *Person + } + + SadForm struct { + AlphaDash string `form:"AlphaDash" binding:"AlphaDash"` + AlphaDashDot string `form:"AlphaDashDot" binding:"AlphaDashDot"` + Size string `form:"Size" binding:"Size(1)"` + SizeSlice []string `form:"SizeSlice" binding:"Size(1)"` + MinSize string `form:"MinSize" binding:"MinSize(5)"` + MinSizeSlice []string `form:"MinSizeSlice" binding:"MinSize(5)"` + MaxSize string `form:"MaxSize" binding:"MaxSize(1)"` + MaxSizeSlice []string `form:"MaxSizeSlice" binding:"MaxSize(1)"` + Range int `form:"Range" binding:"Range(1,2)"` + RangeInvalid int `form:"RangeInvalid" binding:"Range(1)"` + Email string `binding:"Email"` + URL string `form:"Url" binding:"Url"` + URLEmpty string `form:"UrlEmpty" binding:"Url"` + In string `form:"In" binding:"Default(0);In(1,2,3)"` + InInvalid string `form:"InInvalid" binding:"In(1,2,3)"` + NotIn string `form:"NotIn" binding:"NotIn(1,2,3)"` + Include string `form:"Include" binding:"Include(a)"` + Exclude string `form:"Exclude" binding:"Exclude(a)"` + Empty string + } + + Group struct { + Name string `json:"name" binding:"Required"` + People []Person `json:"people" binding:"MinSize(1)"` + } + + PointerForm struct { + ID int `form:"Id" binding:"Id"` + IDPointer *int `form:"IdPointer" binding:"IdPointer"` + URL string `form:"Url" binding:"Url"` + URLPointer *string `form:"UrlPointer" binding:"Url"` + AlphaDash string `form:"AlphaDash" binding:"AlphaDash"` + AlphaDashPointer *string `form:"AlphaDashPointer" binding:"AlphaDash"` + } + + CustomErrorHandle struct { + Rule `binding:"CustomRule"` + } + + TrimSpaceForm struct { + Title string `form:"title" preprocess:"TrimSpace"` + Description string `form:"description"` + Slug string `form:"slug" preprocess:"TrimSpace" binding:"Required;AlphaDash"` + } + + // The common function signature of the handlers going under test. + handlerFunc func(req *http.Request, obj any) Errors +) + +func (p Post) Validate(_ *http.Request, errs Errors) Errors { + if len(p.Title) < 10 { + errs = append(errs, Error{ + FieldNames: []string{"title"}, + Classification: "LengthError", + Message: "Life is too short", + }) + } + return errs +} + +const ( + testRoute = "/test" + formContentType = "application/x-www-form-urlencoded" +) diff --git a/errorhandler_test.go b/errorhandler_test.go new file mode 100755 index 0000000..e77f3ac --- /dev/null +++ b/errorhandler_test.go @@ -0,0 +1,161 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errorTestCases = []errorTestCase{ + { + description: "No errors", + errors: Errors{}, + expected: errorTestResult{ + statusCode: http.StatusOK, + }, + }, + { + description: "Deserialization error", + errors: Errors{ + { + Classification: ERR_DESERIALIZATION, + Message: "Some parser error here", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusBadRequest, + contentType: jsonContentType, + body: `[{"classification":"DeserializationError","message":"Some parser error here"}]`, + }, + }, + { + description: "Content-Type error", + errors: Errors{ + { + Classification: ERR_CONTENT_TYPE, + Message: "Empty Content-Type", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusUnsupportedMediaType, + contentType: jsonContentType, + body: `[{"classification":"ContentTypeError","message":"Empty Content-Type"}]`, + }, + }, + { + description: "Requirement error", + errors: Errors{ + { + FieldNames: []string{"some_field"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusUnprocessableEntity, + contentType: jsonContentType, + body: `[{"fieldNames":["some_field"],"classification":"RequiredError","message":"Required"}]`, + }, + }, + { + description: "Bad header error", + errors: Errors{ + { + Classification: "HeaderError", + Message: "The X-Something header must be specified", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusUnprocessableEntity, + contentType: jsonContentType, + body: `[{"classification":"HeaderError","message":"The X-Something header must be specified"}]`, + }, + }, + { + description: "Custom field error", + errors: Errors{ + { + FieldNames: []string{"month", "year"}, + Classification: "DateError", + Message: "The month and year must be in the future", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusUnprocessableEntity, + contentType: jsonContentType, + body: `[{"fieldNames":["month","year"],"classification":"DateError","message":"The month and year must be in the future"}]`, + }, + }, + { + description: "Multiple errors", + errors: Errors{ + { + FieldNames: []string{"foo"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + { + FieldNames: []string{"foo"}, + Classification: "LengthError", + Message: "The length of the 'foo' field is too short", + }, + }, + expected: errorTestResult{ + statusCode: http.StatusUnprocessableEntity, + contentType: jsonContentType, + body: `[{"fieldNames":["foo"],"classification":"RequiredError","message":"Required"},{"fieldNames":["foo"],"classification":"LengthError","message":"The length of the 'foo' field is too short"}]`, + }, + }, +} + +func Test_ErrorHandler(t *testing.T) { + for _, testCase := range errorTestCases { + performErrorTest(t, testCase) + } +} + +func performErrorTest(t *testing.T, testCase errorTestCase) { + resp := httptest.NewRecorder() + + errorHandler(testCase.errors, resp) + + assert.EqualValues(t, testCase.expected.statusCode, resp.Code) + assert.EqualValues(t, testCase.expected.contentType, resp.Header().Get("Content-Type")) + + actualBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, testCase.expected.body, string(actualBody)) +} + +type ( + errorTestCase struct { + description string + errors Errors + expected errorTestResult + } + + errorTestResult struct { + statusCode int + contentType string + body string + } +) diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..671b7f2 --- /dev/null +++ b/errors.go @@ -0,0 +1,160 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +//nolint:all +const ( + // Type mismatch errors. + ERR_CONTENT_TYPE = "ContentTypeError" + ERR_DESERIALIZATION = "DeserializationError" + ERR_INTERGER_TYPE = "IntegerTypeError" + ERR_BOOLEAN_TYPE = "BooleanTypeError" + ERR_FLOAT_TYPE = "FloatTypeError" + + // Validation errors. + ERR_REQUIRED = "RequiredError" + ERR_ALPHA_DASH = "AlphaDashError" + ERR_ALPHA_DASH_DOT = "AlphaDashDotError" + ERR_SIZE = "SizeError" + ERR_MIN_SIZE = "MinSizeError" + ERR_MAX_SIZE = "MaxSizeError" + ERR_RANGE = "RangeError" + ERR_EMAIL = "EmailError" + ERR_URL = "UrlError" + ERR_IN = "InError" + ERR_NOT_INT = "NotInError" + ERR_INCLUDE = "IncludeError" + ERR_EXCLUDE = "ExcludeError" + ERR_DEFAULT = "DefaultError" +) + +type ( + // Errors may be generated during deserialization, binding, + // or validation. This type is mapped to the context so you + // can inject it into your own handlers and use it in your + // application if you want all your errors to look the same. + Errors []Error + + Error struct { + // An error supports zero or more field names, because an + // error can morph three ways: (1) it can indicate something + // wrong with the request as a whole, (2) it can point to a + // specific problem with a particular input field, or (3) it + // can span multiple related input fields. + FieldNames []string `json:"fieldNames,omitempty"` + + // The classification is like an error code, convenient to + // use when processing or categorizing an error programmatically. + // It may also be called the "kind" of error. + Classification string `json:"classification,omitempty"` + + // Message should be human-readable and detailed enough to + // pinpoint and resolve the problem, but it should be brief. For + // example, a payload of 100 objects in a JSON array might have + // an error in the 41st object. The message should help the + // end user find and fix the error with their request. + Message string `json:"message,omitempty"` + } +) + +// Add adds an error associated with the fields indicated +// by fieldNames, with the given classification and message. +func (e *Errors) Add(fieldNames []string, classification, message string) { + *e = append(*e, Error{ + FieldNames: fieldNames, + Classification: classification, + Message: message, + }) +} + +// Len returns the number of errors. +func (e *Errors) Len() int { + return len(*e) +} + +// Has determines whether an Errors slice has an Error with +// a given classification in it; it does not search on messages +// or field names. +func (e *Errors) Has(class string) bool { + for _, err := range *e { + if err.Kind() == class { + return true + } + } + return false +} + +/* +// WithClass gets a copy of errors that are classified by the +// the given classification. +func (e *Errors) WithClass(classification string) Errors { + var errs Errors + for _, err := range *e { + if err.Kind() == classification { + errs = append(errs, err) + } + } + return errs +} + +// ForField gets a copy of errors that are associated with the +// field by the given name. +func (e *Errors) ForField(name string) Errors { + var errs Errors + for _, err := range *e { + for _, fieldName := range err.Fields() { + if fieldName == name { + errs = append(errs, err) + break + } + } + } + return errs +} + +// Get gets errors of a particular class for the specified +// field name. +func (e *Errors) Get(class, fieldName string) Errors { + var errs Errors + for _, err := range *e { + if err.Kind() == class { + for _, nameOfField := range err.Fields() { + if nameOfField == fieldName { + errs = append(errs, err) + break + } + } + } + } + return errs +} +*/ + +// Fields returns the list of field names this error is +// associated with. +func (e Error) Fields() []string { + return e.FieldNames +} + +// Kind returns this error's classification. +func (e Error) Kind() string { + return e.Classification +} + +// Error returns this error's message. +func (e Error) Error() string { + return e.Message +} diff --git a/errors_test.go b/errors_test.go new file mode 100755 index 0000000..4deae44 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,93 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ErrorsAdd(t *testing.T) { + var actual Errors + expected := Errors{ + Error{ + FieldNames: []string{"Field1", "Field2"}, + Classification: "ErrorClass", + Message: "Some message", + }, + } + + actual.Add(expected[0].FieldNames, expected[0].Classification, expected[0].Message) + + assert.Len(t, actual, 1) + assert.EqualValues(t, fmt.Sprintf("%#v", expected), fmt.Sprintf("%#v", actual)) +} + +func Test_ErrorsLen(t *testing.T) { + assert.EqualValues(t, len(errorsTestSet), errorsTestSet.Len()) +} + +func Test_ErrorsHas(t *testing.T) { + assert.True(t, errorsTestSet.Has("ClassA")) + assert.False(t, errorsTestSet.Has("ClassQ")) +} + +func Test_ErrorGetters(t *testing.T) { + err := Error{ + FieldNames: []string{"field1", "field2"}, + Classification: "ErrorClass", + Message: "The message", + } + + fieldsActual := err.Fields() + + assert.Len(t, fieldsActual, 2) + assert.EqualValues(t, "field1", fieldsActual[0]) + assert.EqualValues(t, "field2", fieldsActual[1]) + + assert.EqualValues(t, "ErrorClass", err.Kind()) + assert.EqualValues(t, "The message", err.Error()) +} + +var errorsTestSet = Errors{ + Error{ + FieldNames: []string{}, + Classification: "ClassA", + Message: "Foobar", + }, + Error{ + FieldNames: []string{}, + Classification: "ClassB", + Message: "Foo", + }, + Error{ + FieldNames: []string{"field1", "field2"}, + Classification: "ClassB", + Message: "Foobar", + }, + Error{ + FieldNames: []string{"field2"}, + Classification: "ClassA", + Message: "Foobar", + }, + Error{ + FieldNames: []string{"field2"}, + Classification: "ClassB", + Message: "Foobar", + }, +} diff --git a/file_test.go b/file_test.go new file mode 100755 index 0000000..8c9cd0e --- /dev/null +++ b/file_test.go @@ -0,0 +1,190 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +var fileTestCases = []fileTestCase{ + { + description: "Single file", + singleFile: &fileInfo{ + fileName: "message.txt", + data: "All your binding are belong to us", + }, + }, + { + description: "Multiple files", + multipleFiles: []*fileInfo{ + { + fileName: "cool-gopher-fact.txt", + data: "Did you know? https://plus.google.com/+MatthewHolt/posts/GmVfd6TPJ51", + }, + { + fileName: "gophercon2014.txt", + data: "@bradfitz has a Go time machine: https://twitter.com/mholt6/status/459463953395875840", + }, + }, + }, + { + description: "Single file and multiple files", + singleFile: &fileInfo{ + fileName: "social media.txt", + data: "Hey, you should follow @mholt6 (Twitter) or +MatthewHolt (Google+)", + }, + multipleFiles: []*fileInfo{ + { + fileName: "thank you!", + data: "Also, thanks to all the contributors of this package!", + }, + { + fileName: "btw...", + data: "This tool translates JSON into Go structs: http://mholt.github.io/json-to-go/", + }, + }, + }, +} + +func Test_FileUploads(t *testing.T) { + for _, testCase := range fileTestCases { + performFileTest(t, MultipartForm, testCase) + } +} + +func performFileTest(t *testing.T, binder handlerFunc, testCase fileTestCase) { + httpRecorder := httptest.NewRecorder() + c := chi.NewRouter() + + fileTestHandler := func(actual BlogPost, _ Errors) { + assertFileAsExpected(t, actual.HeaderImage, testCase.singleFile) + assert.Len(t, actual.Pictures, len(testCase.multipleFiles)) + + for i, expectedFile := range testCase.multipleFiles { + if i >= len(actual.Pictures) { + break + } + assertFileAsExpected(t, actual.Pictures[i], expectedFile) + } + } + + c.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + fileTestHandler(actual, errs) + }) + + c.ServeHTTP(httpRecorder, buildRequestWithFile(testCase)) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check methods and paths") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + } +} + +func assertFileAsExpected(t *testing.T, actual *multipart.FileHeader, expected *fileInfo) { + if expected == nil && actual == nil { + return + } + + if expected != nil && actual == nil { + assert.NotNil(t, actual) + return + } else if expected == nil && actual != nil { + assert.Nil(t, actual) + return + } + + assert.EqualValues(t, expected.fileName, actual.Filename) + assert.EqualValues(t, expected.data, unpackFileHeaderData(actual)) +} + +func buildRequestWithFile(testCase fileTestCase) *http.Request { + b := &bytes.Buffer{} + w := multipart.NewWriter(b) + + if testCase.singleFile != nil { + formFileSingle, err := w.CreateFormFile("header_image", testCase.singleFile.fileName) + if err != nil { + panic("Could not create FormFile (single file): " + err.Error()) + } + _, _ = formFileSingle.Write([]byte(testCase.singleFile.data)) + } + + for _, file := range testCase.multipleFiles { + formFileMultiple, err := w.CreateFormFile("picture", file.fileName) + if err != nil { + panic("Could not create FormFile (multiple files): " + err.Error()) + } + _, _ = formFileMultiple.Write([]byte(file.data)) + } + + err := w.Close() + if err != nil { + panic("Could not close multipart writer: " + err.Error()) + } + + req, err := http.NewRequest("POST", testRoute, b) + if err != nil { + panic("Could not create file upload request: " + err.Error()) + } + + req.Header.Set("Content-Type", w.FormDataContentType()) + + return req +} + +func unpackFileHeaderData(fh *multipart.FileHeader) string { + if fh == nil { + return "" + } + + f, err := fh.Open() + if err != nil { + panic("Could not open file header:" + err.Error()) + } + defer f.Close() + + var fb bytes.Buffer + _, err = fb.ReadFrom(f) + if err != nil { + panic("Could not read from file header:" + err.Error()) + } + + return fb.String() +} + +type ( + fileTestCase struct { + description string + singleFile *fileInfo + multipleFiles []*fileInfo + } + + fileInfo struct { + fileName string + data string + } +) diff --git a/form_test.go b/form_test.go new file mode 100755 index 0000000..41c40d8 --- /dev/null +++ b/form_test.go @@ -0,0 +1,333 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var formTestCases = []formTestCase{ + { + description: "Happy path", + shouldSucceed: true, + payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, + contentType: formContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Happy path with interface", + shouldSucceed: true, + withInterface: true, + payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, + contentType: formContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Empty payload", + shouldSucceed: false, + payload: ``, + contentType: formContentType, + expected: Post{}, + }, + { + description: "Empty content type", + shouldSucceed: false, + payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, + contentType: ``, + expected: Post{}, + }, + { + description: "Malformed form body", + shouldSucceed: false, + payload: `title=%2`, + contentType: formContentType, + expected: Post{}, + }, + { + description: "With nested and embedded structs", + shouldSucceed: true, + payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt`, + contentType: formContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required embedded struct field not specified", + shouldSucceed: false, + payload: `id=1&name=Matt+Holt`, + contentType: formContentType, + expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required nested struct field not specified", + shouldSucceed: false, + payload: `title=Glorious+Post+Title&id=1`, + contentType: formContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, + }, + { + description: "Multiple values into slice", + shouldSucceed: true, + payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&rating=4&rating=3&rating=5`, + contentType: formContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, Ratings: []int{4, 3, 5}}, + }, + { + description: "Unexported field", + shouldSucceed: true, + payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&unexported=foo`, + contentType: formContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Query string POST", + shouldSucceed: true, + payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, + contentType: formContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Query string with Content-Type (POST request)", + shouldSucceed: true, + queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", + payload: ``, + contentType: formContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Query string without Content-Type (GET request)", + shouldSucceed: true, + method: "GET", + queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", + payload: ``, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Embed struct pointer", + shouldSucceed: true, + deepEqual: true, + method: "GET", + queryString: "?name=Glorious+Post+Title&email=Lorem+ipsum+dolor+sit+amet", + payload: ``, + expected: EmbedPerson{&Person{Name: "Glorious Post Title", Email: "Lorem ipsum dolor sit amet"}}, + }, + { + description: "Embed struct pointer remain nil if not binded", + shouldSucceed: true, + deepEqual: true, + method: "GET", + queryString: "?", + payload: ``, + expected: EmbedPerson{nil}, + }, + { + description: "Custom error handler", + shouldSucceed: true, + deepEqual: true, + method: "GET", + queryString: "?", + payload: ``, + expected: CustomErrorHandle{}, + }, + { + description: "pointer form", + shouldSucceed: true, + deepEqual: true, + payload: fmt.Sprintf("Id=%d&IdPointer=%d&Url=%s&UrlPointer=%s&AlphaDash=%s&AlphaDashPointer=%s", idInt, idInt, urlStr, urlStr, alphaDashStr, alphaDashStr), + contentType: formContentType, + expected: PointerForm{ID: idInt, IDPointer: &idInt, URL: urlStr, URLPointer: &urlStr, AlphaDash: alphaDashStr, AlphaDashPointer: &alphaDashStr}, + }, + { + description: "Trim spacing", + shouldSucceed: true, + payload: `title=Glorious+Post+Title%20%20&description=A%20description%20with%20intentional%20spaces%20%20&slug=glorious-post%20`, + contentType: formContentType, + expected: TrimSpaceForm{Title: "Glorious Post Title", Description: "A description with intentional spaces ", Slug: "glorious-post"}, + }, + { + description: "Trim spacing and validating", + shouldSucceed: false, + payload: `title=Glorious+Post+Title%20%20&description=A%20description%20with%20intentional%20spaces%20%20&slug=%20`, + contentType: formContentType, + expected: TrimSpaceForm{Title: "Glorious Post Title", Description: "A description with intentional spaces "}, + }, +} + +func init() { + AddRule(&Rule{ + func(rule string) bool { + return rule == "CustomRule" + }, + func(errs Errors, _ string, _ any) (bool, Errors) { + return false, errs + }, + }) + SetNameMapper(nameMapper) +} + +func Test_Form(t *testing.T) { + for _, testCase := range formTestCases { + t.Run(testCase.description, func(t *testing.T) { + performFormTest(t, Form, testCase) + }) + } +} + +func performFormTest(t *testing.T, binder handlerFunc, testCase formTestCase) { + resp := httptest.NewRecorder() + m := chi.NewRouter() + + formTestHandler := func(actual any, errs Errors) { + if testCase.shouldSucceed { + assert.Empty(t, errs) + } else if !testCase.shouldSucceed { + assert.NotEmpty(t, errs) + } + expString := fmt.Sprintf("%+v", testCase.expected) + actString := fmt.Sprintf("%+v", actual) + if actString != expString && !(testCase.deepEqual && reflect.DeepEqual(testCase.expected, actual)) { + assert.EqualValues(t, expString, actString) + } + } + + switch p := testCase.expected.(type) { + case Post: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Post + errs := binder(req, &actual) + assert.EqualValues(t, p.Title, actual.Title) + formTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Post + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Post + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + } + + case BlogPost: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + assert.EqualValues(t, p.Title, actual.Title) + formTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + } + + case EmbedPerson: + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual EmbedPerson + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual EmbedPerson + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + case CustomErrorHandle: + m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual CustomErrorHandle + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + case PointerForm: + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual PointerForm + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + case TrimSpaceForm: + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual TrimSpaceForm + errs := binder(req, &actual) + formTestHandler(actual, errs) + }) + } + + if len(testCase.method) == 0 { + testCase.method = "POST" + } + + req, err := http.NewRequest(testCase.method, testRoute+testCase.queryString, strings.NewReader(testCase.payload)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", testCase.contentType) + + m.ServeHTTP(resp, req) + + switch resp.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check methods and paths") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + } +} + +type ( + formTestCase struct { + description string + shouldSucceed bool + deepEqual bool + withInterface bool + queryString string + payload string + contentType string + expected any + method string + } +) + +type defaultForm struct { + Default string `binding:"Default(hello world)"` +} + +func Test_Default(t *testing.T) { + m := chi.NewRouter() + m.Get("/", func(_ http.ResponseWriter, req *http.Request) { + var f defaultForm + Bind(req, &f) + assert.EqualValues(t, "hello world", f.Default) + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + require.NoError(t, err) + + m.ServeHTTP(resp, req) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51d2f2d --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module code.forgejo.org/go-chi/binding + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4d1e3e --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/json_test.go b/json_test.go new file mode 100755 index 0000000..23ffbaa --- /dev/null +++ b/json_test.go @@ -0,0 +1,269 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// Copyright 2024 The Forgejo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "runtime" + "strings" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +var jsonTestCases = []jsonTestCase{ + { + description: "Happy path", + shouldSucceedOnJSON: true, + payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, + contentType: jsonContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Happy path with interface", + shouldSucceedOnJSON: true, + withInterface: true, + payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, + contentType: jsonContentType, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Nil payload", + shouldSucceedOnJSON: false, + payload: `-nil-`, + contentType: jsonContentType, + expected: Post{}, + }, + { + description: "Empty payload", + shouldSucceedOnJSON: false, + payload: ``, + contentType: jsonContentType, + expected: Post{}, + }, + { + description: "Empty content type", + shouldSucceedOnJSON: true, + shouldFailOnBind: true, + payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, + contentType: ``, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Unsupported content type", + shouldSucceedOnJSON: true, + shouldFailOnBind: true, + payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, + contentType: `BoGuS`, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Malformed JSON", + shouldSucceedOnJSON: false, + payload: `{"title":"foo"`, + contentType: jsonContentType, + expected: Post{Title: "foo"}, + }, + { + description: "Deserialization with nested and embedded struct", + shouldSucceedOnJSON: true, + payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, + contentType: jsonContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Deserialization with nested and embedded struct with interface", + shouldSucceedOnJSON: true, + withInterface: true, + payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, + contentType: jsonContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required nested struct field not specified", + shouldSucceedOnJSON: false, + payload: `{"title":"Glorious Post Title", "id":1, "author":{}}`, + contentType: jsonContentType, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, + }, + { + description: "Required embedded struct field not specified", + shouldSucceedOnJSON: false, + payload: `{"id":1, "author":{"name":"Matt Holt"}}`, + contentType: jsonContentType, + expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Slice of Posts", + shouldSucceedOnJSON: true, + payload: `[{"title": "First Post"}, {"title": "Second Post"}]`, + contentType: jsonContentType, + expected: []Post{{Title: "First Post"}, {Title: "Second Post"}}, + }, + { + description: "Slice of structs", + shouldSucceedOnJSON: true, + payload: `{"name": "group1", "people": [{"name":"awoods"}, {"name": "anthony"}]}`, + contentType: jsonContentType, + expected: Group{Name: "group1", People: []Person{{Name: "awoods"}, {Name: "anthony"}}}, + }, +} + +func Test_Json(t *testing.T) { + for _, testCase := range jsonTestCases { + performJSONTest(t, JSON, testCase) + } +} + +func performJSONTest(t *testing.T, binder handlerFunc, testCase jsonTestCase) { + fnName := runtime.FuncForPC(reflect.ValueOf(binder).Pointer()).Name() + t.Run(testCase.description, func(t *testing.T) { + var payload io.Reader + httpRecorder := httptest.NewRecorder() + m := chi.NewRouter() + + jsonTestHandler := func(actual any, errs Errors) { + switch fnName { + case "JSON": + if testCase.shouldSucceedOnJSON { + assert.Empty(t, errs, errs) + assert.EqualValues(t, fmt.Sprintf("%+v", testCase.expected), fmt.Sprintf("%+v", actual)) + } else { + assert.NotEmpty(t, errs) + } + case "Bind": + if !testCase.shouldFailOnBind { + assert.Empty(t, errs, errs) + } else { + assert.NotEmpty(t, errs) + assert.EqualValues(t, fmt.Sprintf("%+v", testCase.expected), fmt.Sprintf("%+v", actual)) + } + } + } + + switch p := testCase.expected.(type) { + case []Post: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual []Post + errs := binder(req, &actual) + for i, a := range actual { + assert.EqualValues(t, p[i].Title, a.Title) + jsonTestHandler(a, errs) + } + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual []Post + errs := binder(req, &actual) + jsonTestHandler(actual, errs) + }) + } + + case Post: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Post + errs := binder(req, &actual) + assert.EqualValues(t, p.Title, actual.Title) + jsonTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Post + errs := binder(req, &actual) + jsonTestHandler(actual, errs) + }) + } + + case BlogPost: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + assert.EqualValues(t, p.Title, actual.Title) + jsonTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + jsonTestHandler(actual, errs) + }) + } + case Group: + if testCase.withInterface { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Group + errs := binder(req, &actual) + assert.EqualValues(t, p.Name, actual.Name) + jsonTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Group + errs := binder(req, &actual) + jsonTestHandler(actual, errs) + }) + } + } + + if testCase.payload == "-nil-" { + payload = nil + } else { + payload = strings.NewReader(testCase.payload) + } + + req, err := http.NewRequest("POST", testRoute, payload) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", testCase.contentType) + + m.ServeHTTP(httpRecorder, req) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check method and path") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + default: + if testCase.shouldSucceedOnJSON && + httpRecorder.Code != http.StatusOK && + !testCase.shouldFailOnBind { + assert.EqualValues(t, http.StatusOK, httpRecorder.Code) + } + } + }) +} + +type ( + jsonTestCase struct { + description string + withInterface bool + shouldSucceedOnJSON bool + shouldFailOnBind bool + payload string + contentType string + expected any + } +) diff --git a/misc_test.go b/misc_test.go new file mode 100755 index 0000000..bddc210 --- /dev/null +++ b/misc_test.go @@ -0,0 +1,122 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// Copyright 2024 The Forgejo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +// When binding from Form data, testing the type of data to bind +// and converting a string into that type is tedious, so these tests +// cover all those cases. +func Test_SetWithProperType(t *testing.T) { + testInputs := map[string]string{ + "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`, + "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`, + } + + expectedOutputs := map[string]Everything{ + "successful": { + Integer: -1, + Integer8: -8, + Integer16: -16, + Integer32: -32, + Integer64: -64, + Uinteger: 1, + Uinteger8: 8, + Uinteger16: 16, + Uinteger32: 32, + Uinteger64: 64, + Boolean1: true, + Fl32_1: 32.3232, + Fl64_1: -64.6464646464, + Str: "string", + }, + "errorful": {}, + } + + for key, testCase := range testInputs { + httpRecorder := httptest.NewRecorder() + m := chi.NewRouter() + + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual Everything + errs := Form(req, &actual) + assert.EqualValues(t, fmt.Sprintf("%+v", expectedOutputs[key]), fmt.Sprintf("%+v", actual)) + if key == "errorful" { + assert.Len(t, errs, 10) + } else { + assert.Empty(t, errs) + } + }) + req, err := http.NewRequest("POST", testRoute, strings.NewReader(testCase)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", formContentType) + m.ServeHTTP(httpRecorder, req) + } +} + +// Each binder middleware should assert that the struct passed in is not +// a pointer (to avoid race conditions) +func Test_EnsureNotPointer(t *testing.T) { + shouldPanic := func() { + defer func() { + assert.NotNil(t, recover()) + }() + ensureNotPointer(&Post{}) + } + + shouldNotPanic := func() { + defer func() { + assert.Nil(t, recover()) + }() + ensureNotPointer(Post{}) + } + + shouldPanic() + shouldNotPanic() +} + +// Used in testing setWithProperType; kind of clunky... +type Everything struct { + Integer int `form:"integer"` + Integer8 int8 `form:"integer8"` + Integer16 int16 `form:"integer16"` + Integer32 int32 `form:"integer32"` + Integer64 int64 `form:"integer64"` + Uinteger uint `form:"uinteger"` + Uinteger8 uint8 `form:"uinteger8"` + Uinteger16 uint16 `form:"uinteger16"` + Uinteger32 uint32 `form:"uinteger32"` + Uinteger64 uint64 `form:"uinteger64"` + Boolean1 bool `form:"boolean_1"` + Boolean2 bool `form:"boolean_2"` + Fl32_1 float32 `form:"fl32_1"` + Fl32_2 float32 `form:"fl32_2"` + Fl64_1 float64 `form:"fl64_1"` + Fl64_2 float64 `form:"fl64_2"` + Str string `form:"str"` +} diff --git a/multipart_test.go b/multipart_test.go new file mode 100755 index 0000000..19ca01c --- /dev/null +++ b/multipart_test.go @@ -0,0 +1,154 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +var multipartFormTestCases = []multipartFormTestCase{ + { + description: "Happy multipart form path", + shouldSucceed: true, + inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "FormValue called before req.MultipartReader(); see https://github.com/martini-contrib/csrf/issues/6", + shouldSucceed: true, + callFormValueBefore: true, + inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Empty payload", + shouldSucceed: false, + inputAndExpected: BlogPost{}, + }, + { + description: "Missing required field (Id)", + shouldSucceed: false, + inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required embedded struct field not specified", + shouldSucceed: false, + inputAndExpected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required nested struct field not specified", + shouldSucceed: false, + inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, + }, + { + description: "Multiple values", + shouldSucceed: true, + inputAndExpected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, Ratings: []int{3, 5, 4}}, + }, + { + description: "Bad multipart encoding", + shouldSucceed: false, + malformEncoding: true, + }, +} + +func Test_MultipartForm(t *testing.T) { + for _, testCase := range multipartFormTestCases { + performMultipartFormTest(t, MultipartForm, testCase) + } +} + +func performMultipartFormTest(t *testing.T, binder handlerFunc, testCase multipartFormTestCase) { + httpRecorder := httptest.NewRecorder() + m := chi.NewRouter() + + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + var actual BlogPost + errs := binder(req, &actual) + if testCase.shouldSucceed { + assert.Empty(t, errs) + } else if !testCase.shouldSucceed { + assert.NotEmpty(t, errs) + } + assert.EqualValues(t, fmt.Sprintf("%+v", testCase.inputAndExpected), fmt.Sprintf("%+v", actual)) + }) + + multipartPayload, mpWriter := makeMultipartPayload(testCase) + + req, err := http.NewRequest("POST", testRoute, multipartPayload) + if err != nil { + panic(err) + } + + req.Header.Add("Content-Type", mpWriter.FormDataContentType()) + + err = mpWriter.Close() + if err != nil { + panic(err) + } + + if testCase.callFormValueBefore { + req.FormValue("foo") + } + + m.ServeHTTP(httpRecorder, req) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check methods and paths") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + } +} + +// Writes the input from a test case into a buffer using the multipart writer. +func makeMultipartPayload(testCase multipartFormTestCase) (*bytes.Buffer, *multipart.Writer) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if testCase.malformEncoding { + // TODO: Break the multipart form parser which is apparently impervious!! + // (Get it to return an error. Trying to get 100% test coverage.) + body.Write([]byte(`--` + writer.Boundary() + `\nContent-Disposition: form-data; name="foo"\n\n--` + writer.Boundary() + `--`)) + return body, writer + } + _ = writer.WriteField("title", testCase.inputAndExpected.Title) + _ = writer.WriteField("content", testCase.inputAndExpected.Content) + _ = writer.WriteField("id", strconv.Itoa(testCase.inputAndExpected.Id)) + _ = writer.WriteField("ignored", testCase.inputAndExpected.Ignored) + for _, value := range testCase.inputAndExpected.Ratings { + _ = writer.WriteField("rating", strconv.Itoa(value)) + } + _ = writer.WriteField("name", testCase.inputAndExpected.Author.Name) + _ = writer.WriteField("email", testCase.inputAndExpected.Author.Email) + return body, writer +} + +type ( + multipartFormTestCase struct { + description string + shouldSucceed bool + inputAndExpected BlogPost + malformEncoding bool + callFormValueBefore bool + } +) diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f1121ea --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["go-chi/renovate-config"] +} \ No newline at end of file diff --git a/validate_test.go b/validate_test.go new file mode 100755 index 0000000..660bc20 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,613 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +var ( + emptyStr = "" + urlStr = "http://example.com/" + alphaDashStr = "aB-12" + idInt = 1 +) + +var validationTestCases = []validationTestCase{ + { + description: "No errors", + data: BlogPost{ + Id: 1, + Post: Post{ + Title: "Behold The Title!", + Content: "And some content", + }, + Author: Person{ + Name: "Matt Holt", + }, + }, + expectedErrors: Errors{}, + }, + { + description: "ID required", + data: BlogPost{ + Post: Post{ + Title: "Behold The Title!", + Content: "And some content", + }, + Author: Person{ + Name: "Matt Holt", + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"id"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + }, + }, + { + description: "Embedded struct field required", + data: BlogPost{ + Id: 1, + Post: Post{ + Content: "Content given, but title is required", + }, + Author: Person{ + Name: "Matt Holt", + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"title"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + Error{ + FieldNames: []string{"title"}, + Classification: "LengthError", + Message: "Life is too short", + }, + }, + }, + { + description: "Nested struct field required", + data: BlogPost{ + Id: 1, + Post: Post{ + Title: "Behold The Title!", + Content: "And some content", + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"name"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + }, + }, + { + description: "Required field missing in nested struct pointer", + data: BlogPost{ + Id: 1, + Post: Post{ + Title: "Behold The Title!", + Content: "And some content", + }, + Author: Person{ + Name: "Matt Holt", + }, + Coauthor: &Person{}, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"name"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + }, + }, + { + description: "All required fields specified in nested struct pointer", + data: BlogPost{ + Id: 1, + Post: Post{ + Title: "Behold The Title!", + Content: "And some content", + }, + Author: Person{ + Name: "Matt Holt", + }, + Coauthor: &Person{ + Name: "Jeremy Saenz", + }, + }, + expectedErrors: Errors{}, + }, + { + description: "Custom validation should put an error", + data: BlogPost{ + Id: 1, + Post: Post{ + Title: "Too short", + Content: "And some content", + }, + Author: Person{ + Name: "Matt Holt", + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"title"}, + Classification: "LengthError", + Message: "Life is too short", + }, + }, + }, + { + description: "List Validation", + data: []BlogPost{ + { + Id: 1, + Post: Post{ + Title: "First Post", + Content: "And some content", + }, + Author: Person{ + Name: "Leeor Aharon", + }, + }, + { + Id: 2, + Post: Post{ + Title: "Second Post", + Content: "And some content", + }, + Author: Person{ + Name: "Leeor Aharon", + }, + }, + }, + expectedErrors: Errors{}, + }, + { + description: "List Validation w/ Errors", + data: []BlogPost{ + { + Id: 1, + Post: Post{ + Title: "First Post", + Content: "And some content", + }, + Author: Person{ + Name: "Leeor Aharon", + }, + }, + { + Id: 2, + Post: Post{ + Title: "Too Short", + Content: "And some content", + }, + Author: Person{ + Name: "Leeor Aharon", + }, + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"title"}, + Classification: "LengthError", + Message: "Life is too short", + }, + }, + }, + { + description: "List of invalid custom validations", + data: []SadForm{ + { + AlphaDash: ",", + AlphaDashDot: ",", + Size: "123", + SizeSlice: []string{"1", "2", "3"}, + MinSize: ",", + MinSizeSlice: []string{",", ","}, + MaxSize: ",,", + MaxSizeSlice: []string{",", ","}, + Range: 3, + Email: ",", + URL: ",", + URLEmpty: "", + InInvalid: "4", + NotIn: "1", + Include: "def", + Exclude: "abc", + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"AlphaDash"}, + Classification: "AlphaDashError", + Message: "AlphaDash", + }, + Error{ + FieldNames: []string{"AlphaDashDot"}, + Classification: "AlphaDashDot", + Message: "AlphaDashDot", + }, + Error{ + FieldNames: []string{"Size"}, + Classification: "Size", + Message: "Size", + }, + Error{ + FieldNames: []string{"Size"}, + Classification: "Size", + Message: "Size", + }, + Error{ + FieldNames: []string{"MinSize"}, + Classification: "MinSize", + Message: "MinSize", + }, + Error{ + FieldNames: []string{"MinSize"}, + Classification: "MinSize", + Message: "MinSize", + }, + Error{ + FieldNames: []string{"MaxSize"}, + Classification: "MaxSize", + Message: "MaxSize", + }, + Error{ + FieldNames: []string{"MaxSize"}, + Classification: "MaxSize", + Message: "MaxSize", + }, + Error{ + FieldNames: []string{"Range"}, + Classification: "Range", + Message: "Range", + }, + Error{ + FieldNames: []string{"Email"}, + Classification: "Email", + Message: "Email", + }, + Error{ + FieldNames: []string{"Url"}, + Classification: "Url", + Message: "Url", + }, + Error{ + FieldNames: []string{"Default"}, + Classification: "Default", + Message: "Default", + }, + Error{ + FieldNames: []string{"InInvalid"}, + Classification: "In", + Message: "In", + }, + Error{ + FieldNames: []string{"NotIn"}, + Classification: "NotIn", + Message: "NotIn", + }, + Error{ + FieldNames: []string{"Include"}, + Classification: "Include", + Message: "Include", + }, + Error{ + FieldNames: []string{"Exclude"}, + Classification: "Exclude", + Message: "Exclude", + }, + }, + }, + { + description: "List of valid custom validations", + data: []SadForm{ + { + AlphaDash: "123-456", + AlphaDashDot: "123.456", + Size: "1", + SizeSlice: []string{"1"}, + MinSize: "12345", + MinSizeSlice: []string{"1", "2", "3", "4", "5"}, + MaxSize: "1", + MaxSizeSlice: []string{"1"}, + Range: 2, + In: "1", + InInvalid: "1", + Email: "123@456.com", + URL: "http://123.456", + Include: "abc", + }, + }, + }, + { + description: "slice of structs Validation", + data: Group{ + Name: "group1", + People: []Person{ + {Name: "anthony"}, + {Name: "awoods"}, + }, + }, + expectedErrors: Errors{}, + }, + { + description: "slice of structs Validation failer", + data: Group{ + Name: "group1", + People: []Person{ + {Name: "anthony"}, + {Name: ""}, + }, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"name"}, + Classification: ERR_REQUIRED, + Message: "Required", + }, + }, + }, + { + description: "email fail", + data: struct { + EmailValid string `binding:"Email"` + EmailFail string `binding:"Email"` + EmailFail2 string `binding:"Email"` + EmailFail3 string `binding:"Email"` + }{ + EmailValid: "123@asd.com", + EmailFail: "test 123@asd.com", + EmailFail2: "123@asd.com test", + EmailFail3: "test 123@asd.com test", + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"EmailFail"}, + Classification: ERR_EMAIL, + Message: "Email", + }, + Error{ + FieldNames: []string{"EmailFail2"}, + Classification: ERR_EMAIL, + Message: "Email", + }, + Error{ + FieldNames: []string{"EmailFail3"}, + Classification: ERR_EMAIL, + Message: "Email", + }, + }, + }, + { + description: "pointer form empty and nil", + data: PointerForm{}, + expectedErrors: Errors{}, + }, + { + description: "pointer form empty", + data: PointerForm{ + URL: "", + URLPointer: &emptyStr, + AlphaDash: "", + AlphaDashPointer: &emptyStr, + }, + expectedErrors: Errors{}, + }, + { + description: "no errors with not required fields", + data: []*struct { + AlphaDash string `binding:"AlphaDash"` + AlphaDashDot string `binding:"AlphaDashDot"` + Size string `binding:"Size(1)"` + SizeSlice []string `binding:"Size(1)"` + MinSize string `binding:"MinSize(5)"` + MinSizeSlice []string `binding:"MinSize(5)"` + MaxSize string `binding:"MaxSize(1)"` + MaxSizeSlice []string `binding:"MaxSize(1)"` + Range int `binding:"Range(1,2)"` + Email string `binding:"Email"` + URL string `binding:"Url"` + In string `binding:"Default(0);In(1,2,3)"` + NotIn string `binding:"NotIn(1,2,3)"` + }{ + {}, + }, + expectedErrors: Errors{}, + }, + { + description: "pointer form with valid data", + data: PointerForm{ + URL: urlStr, + URLPointer: &urlStr, + AlphaDash: alphaDashStr, + AlphaDashPointer: &alphaDashStr, + }, + expectedErrors: Errors{}, + }, + { + description: "pointer form with invalid data", + data: PointerForm{ + URL: alphaDashStr, + URLPointer: &alphaDashStr, + AlphaDash: urlStr, + AlphaDashPointer: &urlStr, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"Url", "UrlPointer"}, + Classification: "Url", + Message: "Url", + }, + Error{ + FieldNames: []string{"UrlPointer"}, + Classification: "Url", + Message: "Url", + }, + Error{ + FieldNames: []string{"AlphaDash"}, + Classification: "AlphaDash", + Message: "AlphaDash", + }, + Error{ + FieldNames: []string{"AlphaDashPointer"}, + Classification: "AlphaDash", + Message: "AlphaDash", + }, + }, + }, + { + description: "errors with required fields", + data: []*struct { + AlphaDash string `binding:"Required;AlphaDash"` + AlphaDashDot string `binding:"Required;AlphaDashDot"` + Size string `binding:"Required;Size(1)"` + SizeSlice []string `binding:"Required;Size(1)"` + MinSize string `binding:"Required;MinSize(5)"` + MinSizeSlice []string `binding:"Required;MinSize(5)"` + MaxSize string `binding:"Required;MaxSize(1)"` + MaxSizeSlice []string `binding:"Required;MaxSize(1)"` + Range int `binding:"Required;Range(1,2)"` + Email string `binding:"Required;Email"` + URL string `binding:"Required;Url"` + In string `binding:"Required;Default(0);In(1,2,3)"` + NotIn string `binding:"Required;NotIn(1,2,3)"` + }{ + {}, + }, + expectedErrors: Errors{ + Error{ + FieldNames: []string{"AlphaDash"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"AlphaDashDot"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"Size"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"SizeSlice"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"MinSize"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"MinSizeSlice"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"MaxSize"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"MaxSizeSlice"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"Range"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"Email"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"Url"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"In"}, + Classification: "Required", + Message: "Required", + }, + Error{ + FieldNames: []string{"NotIn"}, + Classification: "Required", + Message: "Required", + }, + }, + }, +} + +func Test_Validation(t *testing.T) { + for _, testCase := range validationTestCases { + performValidationTest(t, testCase) + } +} + +func performValidationTest(t *testing.T, testCase validationTestCase) { + httpRecorder := httptest.NewRecorder() + m := chi.NewRouter() + + m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { + actual := Validate(req, testCase.data) + assert.EqualValues(t, fmt.Sprintf("%+v", testCase.expectedErrors), fmt.Sprintf("%+v", actual), testCase.description) + }) + + req, err := http.NewRequest("POST", testRoute, nil) + if err != nil { + panic(err) + } + + m.ServeHTTP(httpRecorder, req) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check methods and paths") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + } +} + +type ( + validationTestCase struct { + description string + data any + expectedErrors Errors + } +)