Adding upstream version 1.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
5ca5eb8df5
commit
4a5c02e4d1
19 changed files with 3254 additions and 0 deletions
23
.forgejo/workflows/test.yml
Normal file
23
.forgejo/workflows/test.yml
Normal file
|
@ -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 ./...
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.idea
|
191
LICENSE
Normal file
191
LICENSE
Normal file
|
@ -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.
|
5
README.md
Normal file
5
README.md
Normal file
|
@ -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.
|
46
bind_test.go
Normal file
46
bind_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
739
binding.go
Normal file
739
binding.go
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
124
common_test.go
Executable file
124
common_test.go
Executable file
|
@ -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"
|
||||||
|
)
|
161
errorhandler_test.go
Executable file
161
errorhandler_test.go
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
160
errors.go
Normal file
160
errors.go
Normal file
|
@ -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
|
||||||
|
}
|
93
errors_test.go
Executable file
93
errors_test.go
Executable file
|
@ -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",
|
||||||
|
},
|
||||||
|
}
|
190
file_test.go
Executable file
190
file_test.go
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
333
form_test.go
Executable file
333
form_test.go
Executable file
|
@ -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)
|
||||||
|
}
|
14
go.mod
Normal file
14
go.mod
Normal file
|
@ -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
|
||||||
|
)
|
12
go.sum
Normal file
12
go.sum
Normal file
|
@ -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=
|
269
json_test.go
Executable file
269
json_test.go
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
122
misc_test.go
Executable file
122
misc_test.go
Executable file
|
@ -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"`
|
||||||
|
}
|
154
multipart_test.go
Executable file
154
multipart_test.go
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
4
renovate.json
Normal file
4
renovate.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["go-chi/renovate-config"]
|
||||||
|
}
|
613
validate_test.go
Executable file
613
validate_test.go
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue