mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-03 00:02:43 +02:00
refactor: move go-chi/binding into Gitea (#39528)
The `gitea.com/go-chi/binding` package only exists for Gitea, so it moves into `modules/web/binding` to fix its bugs directly. Split out of https://github.com/go-gitea/gitea/pull/39504. - GET and HEAD always bind the query - JSON `null` slice elements and nested `TrimSpace` fields bind correctly - Integer fields reject out-of-range values instead of wrapping - An empty JSON body binds nothing and an unknown binding rule is an error Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
17 files changed
+748
-72
No files matched your search
Generated
-5
@@ -14,11 +14,6 @@
|
||||
"path": "filippo.io/edwards25519/LICENSE",
|
||||
"licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
|
||||
},
|
||||
{
|
||||
"name": "gitea.com/go-chi/binding",
|
||||
"path": "gitea.com/go-chi/binding/LICENSE",
|
||||
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
|
||||
},
|
||||
{
|
||||
"name": "gitea.com/go-chi/cache",
|
||||
"path": "gitea.com/go-chi/cache/LICENSE",
|
||||
|
||||
@@ -6,7 +6,6 @@ toolchain go1.27.1
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.21.0
|
||||
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981
|
||||
gitea.com/go-chi/cache v0.2.1
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
|
||||
|
||||
@@ -8,8 +8,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981 h1:LmdlwGbzgZFZA3bK3R1q8QrNabaSz3KpnOJBLmzhN6E=
|
||||
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981/go.mod h1:q1SSPpkC9A0gfNnoqqZ3My6kEHIpKN6QsTI+Zx73B/o=
|
||||
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
|
||||
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
|
||||
|
||||
@@ -53,7 +53,7 @@ type EditUserOption struct {
|
||||
// Whether the user must change password on next login
|
||||
MustChangePassword *bool `json:"must_change_password"`
|
||||
// The user's personal website URL
|
||||
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
|
||||
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
|
||||
// The user's location or address
|
||||
Location *string `json:"location" binding:"MaxSize(50)"`
|
||||
// The user's personal description or bio
|
||||
|
||||
@@ -5,8 +5,7 @@ package structs
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/translation/i18n"
|
||||
|
||||
"gitea.com/go-chi/binding" //nolint:depguard // avoid cycle import
|
||||
"gitea.dev/modules/web/binding"
|
||||
)
|
||||
|
||||
// ValidateContext is a special context for form validation middleware
|
||||
|
||||
@@ -63,7 +63,7 @@ type CreateTeamOption struct {
|
||||
// Whether the team can create repositories in the organization
|
||||
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
||||
// Team visibility within the organization. Defaults to "private".
|
||||
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
|
||||
Visibility TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
|
||||
}
|
||||
|
||||
// EditTeamOption options for editing a team
|
||||
@@ -85,5 +85,5 @@ type EditTeamOption struct {
|
||||
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
|
||||
// Team visibility within the organization. When omitted, visibility is
|
||||
// left unchanged.
|
||||
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
|
||||
Visibility *TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
|
||||
}
|
||||
@@ -173,7 +173,7 @@ type CreateRepoOption struct {
|
||||
type EditRepoOption struct {
|
||||
// name of the repository
|
||||
// unique: true
|
||||
Name *string `json:"name,omitempty" binding:"OmitEmpty;AlphaDashDot;MaxSize(100);"`
|
||||
Name *string `json:"name,omitempty" binding:"AlphaDashDot;MaxSize(100)"`
|
||||
// a short description of the repository.
|
||||
Description *string `json:"description,omitempty" binding:"MaxSize(2048)"`
|
||||
// a URL with more information about the repository.
|
||||
|
||||
@@ -89,7 +89,7 @@ type UserSettings struct {
|
||||
// swagger:model
|
||||
type UserSettingsOptions struct {
|
||||
FullName *string `json:"full_name" binding:"MaxSize(100)"`
|
||||
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
|
||||
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
|
||||
Description *string `json:"description" binding:"MaxSize(255)"`
|
||||
Location *string `json:"location" binding:"MaxSize(50)"`
|
||||
Language *string `json:"language"`
|
||||
|
||||
@@ -5,7 +5,6 @@ package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -14,14 +13,13 @@ import (
|
||||
"gitea.dev/modules/auth"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
|
||||
"gitea.dev/modules/web/binding"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrCustomMessage = "CustomMessage"
|
||||
ErrEmail = "EmailError"
|
||||
ErrURL = "UrlError"
|
||||
ErrGitRefName = "GitRefNameError"
|
||||
ErrGlobPattern = "GlobPattern"
|
||||
ErrRegexPattern = "RegexPattern"
|
||||
@@ -30,35 +28,19 @@ const (
|
||||
ErrInvalidBadgeSlug = "InvalidBadgeSlug"
|
||||
)
|
||||
|
||||
type jsonProvider struct{}
|
||||
|
||||
func (j jsonProvider) Marshal(v any) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
func (j jsonProvider) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
|
||||
|
||||
func (j jsonProvider) NewDecoder(reader io.Reader) binding.JSONDecoder {
|
||||
return json.NewDecoder(reader)
|
||||
}
|
||||
|
||||
func (j jsonProvider) NewEncoder(writer io.Writer) binding.JSONEncoder {
|
||||
return json.NewEncoder(writer)
|
||||
}
|
||||
|
||||
func newFieldError(field reflect.StructField, cls, msg string) *BindingError {
|
||||
return &BindingError{FieldNames: []string{field.Name}, Classification: cls, Message: msg}
|
||||
}
|
||||
|
||||
func AddValidationError(errs BindingErrors, fieldName, errorMsg string) BindingErrors {
|
||||
errs.Add([]string{fieldName}, ErrCustomMessage, errorMsg)
|
||||
return errs
|
||||
return append(errs, BindingError{FieldNames: []string{fieldName}, Classification: ErrCustomMessage, Message: errorMsg})
|
||||
}
|
||||
|
||||
// AddBindingRules adds additional binding rules
|
||||
func AddBindingRules(b *binding.Binder) {
|
||||
b.ClearRules("Email")
|
||||
b.AddRuleNonZero("Email", func(_ context.Context, f *binding.ValidationField) *binding.Error {
|
||||
if !IsEmailAddressValid(f.ValueMustString()) {
|
||||
return newFieldError(f.StructField, binding.ERR_EMAIL, "invalid email")
|
||||
return newFieldError(f.StructField, ErrEmail, "invalid email")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -71,13 +53,13 @@ func AddBindingRules(b *binding.Binder) {
|
||||
})
|
||||
b.AddRuleNonZero("ValidUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
|
||||
if !IsValidURL(f.ValueMustString()) {
|
||||
return newFieldError(f.StructField, binding.ERR_URL, "Url")
|
||||
return newFieldError(f.StructField, ErrURL, "Url")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
b.AddRuleNonZero("ValidSiteUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
|
||||
if !IsValidSiteURL(f.ValueMustString()) {
|
||||
return newFieldError(f.StructField, binding.ERR_URL, "Url")
|
||||
return newFieldError(f.StructField, ErrURL, "Url")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -152,7 +134,7 @@ func validPort(p string) bool {
|
||||
}
|
||||
|
||||
var Binder = sync.OnceValue(func() *binding.Binder {
|
||||
b := binding.NewBinder().WithJSONProvider(jsonProvider{}).WithDefaultRules().WithNameMapper(util.ToSnakeCase)
|
||||
b := binding.NewBinder()
|
||||
AddBindingRules(b)
|
||||
return b
|
||||
})
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright 2014 Martini Authors
|
||||
// Copyright 2014 The Macaron Authors
|
||||
// Copyright 2020 The Gitea Authors
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package binding binds form, multipart and JSON request data to structs and validates them by their "binding" tags.
|
||||
package binding
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
errContentType = "ContentTypeError"
|
||||
errDeserialization = "DeserializationError"
|
||||
errTypeCast = "TypeCastError"
|
||||
errRule = "RuleError"
|
||||
|
||||
ErrRequired = "RequiredError"
|
||||
ErrAlphaDashDot = "AlphaDashDotError"
|
||||
ErrMinSize = "MinSizeError"
|
||||
ErrMaxSize = "MaxSizeError"
|
||||
ErrRange = "RangeError"
|
||||
ErrIn = "InError"
|
||||
ErrInclude = "IncludeError"
|
||||
)
|
||||
|
||||
const multipartMaxMemory = 10 * 1024 * 1024
|
||||
|
||||
type (
|
||||
Errors []Error
|
||||
|
||||
Error struct {
|
||||
FieldNames []string
|
||||
Classification string
|
||||
Message string
|
||||
}
|
||||
)
|
||||
|
||||
func (e Error) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *Errors) addDeserializationError(err error) {
|
||||
*e = append(*e, Error{Classification: errDeserialization, Message: err.Error()})
|
||||
}
|
||||
|
||||
func (e *Errors) addOptional(err *Error) {
|
||||
if err != nil {
|
||||
*e = append(*e, *err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFieldError(field reflect.StructField, classification, message string) *Error {
|
||||
return &Error{FieldNames: []string{field.Name}, Classification: classification, Message: message}
|
||||
}
|
||||
|
||||
type ValidationField struct {
|
||||
StructField reflect.StructField
|
||||
reflectValue reflect.Value
|
||||
ruleArgs []string
|
||||
}
|
||||
|
||||
func (f *ValidationField) valueAsString() string {
|
||||
return fmt.Sprint(reflect.Indirect(f.reflectValue).Interface())
|
||||
}
|
||||
|
||||
func (f *ValidationField) ValueMustString() string {
|
||||
value := reflect.Indirect(f.reflectValue)
|
||||
if value.Kind() != reflect.String {
|
||||
panic("field value must be a string")
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
|
||||
func (f *ValidationField) valueSize() int {
|
||||
value := reflect.Indirect(f.reflectValue)
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
return utf8.RuneCountInString(value.String())
|
||||
case reflect.Slice:
|
||||
return value.Len()
|
||||
}
|
||||
panic("unsupported type: " + value.Kind().String())
|
||||
}
|
||||
|
||||
func (f *ValidationField) assignValue(newValue any) {
|
||||
if f.reflectValue.Kind() == reflect.Pointer {
|
||||
ptr := reflect.New(f.StructField.Type.Elem())
|
||||
ptr.Elem().Set(reflect.ValueOf(newValue).Convert(ptr.Elem().Type()))
|
||||
f.reflectValue.Set(ptr)
|
||||
return
|
||||
}
|
||||
f.reflectValue.Set(reflect.ValueOf(newValue).Convert(f.reflectValue.Type()))
|
||||
}
|
||||
|
||||
type RuleValidator func(ctx context.Context, field *ValidationField) *Error
|
||||
|
||||
type ruleValidatorItem struct {
|
||||
forZeroValue bool
|
||||
validatorFn RuleValidator
|
||||
}
|
||||
|
||||
type Binder struct {
|
||||
rules map[string]ruleValidatorItem
|
||||
}
|
||||
|
||||
func NewBinder() *Binder {
|
||||
binder := &Binder{rules: map[string]ruleValidatorItem{}}
|
||||
binder.AddRuleNonZero("TrimSpace", func(_ context.Context, field *ValidationField) *Error {
|
||||
stringType := reflect.TypeFor[string]()
|
||||
value := reflect.Indirect(field.reflectValue)
|
||||
if !value.CanConvert(stringType) {
|
||||
return newFieldError(field.StructField, errTypeCast, "TrimSpace")
|
||||
}
|
||||
field.assignValue(strings.TrimSpace(value.Convert(stringType).String()))
|
||||
return nil
|
||||
})
|
||||
binder.rules["Required"] = ruleValidatorItem{forZeroValue: true, validatorFn: func(_ context.Context, field *ValidationField) *Error {
|
||||
// a pointer field is optional, so "Required" only applies once a value is provided
|
||||
if field.reflectValue.Kind() == reflect.Pointer && field.reflectValue.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return newFieldError(field.StructField, ErrRequired, "Required")
|
||||
}}
|
||||
binder.AddRuleNonZero("AlphaDashDot", func(_ context.Context, field *ValidationField) *Error {
|
||||
if nonAlphaDashDotPattern().MatchString(field.ValueMustString()) {
|
||||
return newFieldError(field.StructField, ErrAlphaDashDot, "AlphaDashDot")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
binder.AddRuleNonZero("MinSize", func(_ context.Context, field *ValidationField) *Error {
|
||||
minSize, _ := strconv.Atoi(field.ruleArgs[0])
|
||||
if field.valueSize() < minSize {
|
||||
return newFieldError(field.StructField, ErrMinSize, "MinSize")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
binder.AddRuleNonZero("MaxSize", func(_ context.Context, field *ValidationField) *Error {
|
||||
maxSize, _ := strconv.Atoi(field.ruleArgs[0])
|
||||
if field.valueSize() > maxSize {
|
||||
return newFieldError(field.StructField, ErrMaxSize, "MaxSize")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
binder.AddRuleNonZero("Range", func(_ context.Context, field *ValidationField) *Error {
|
||||
value, _ := strconv.Atoi(field.valueAsString())
|
||||
minValue, _ := strconv.Atoi(field.ruleArgs[0])
|
||||
maxValue, _ := strconv.Atoi(field.ruleArgs[1])
|
||||
if value < minValue || value > maxValue {
|
||||
return newFieldError(field.StructField, ErrRange, "Range")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
binder.AddRuleNonZero("In", func(_ context.Context, field *ValidationField) *Error {
|
||||
if !slices.Contains(field.ruleArgs, field.valueAsString()) {
|
||||
return newFieldError(field.StructField, ErrIn, "In")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
binder.AddRuleNonZero("Include", func(_ context.Context, field *ValidationField) *Error {
|
||||
if !strings.Contains(field.ValueMustString(), strings.Join(field.ruleArgs, ",")) {
|
||||
return newFieldError(field.StructField, ErrInclude, "Include")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return binder
|
||||
}
|
||||
|
||||
func (b *Binder) AddRuleNonZero(name string, ruleValidator RuleValidator) {
|
||||
b.rules[name] = ruleValidatorItem{validatorFn: ruleValidator}
|
||||
}
|
||||
|
||||
func (b *Binder) Bind(req *http.Request, obj any) Errors {
|
||||
ensurePointer(obj)
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
if req.Method == http.MethodGet || req.Method == http.MethodHead ||
|
||||
(contentType == "" && req.Method != http.MethodPost && req.Method != http.MethodPut) {
|
||||
return b.bindForm(req, obj)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(contentType, "form-urlencoded"):
|
||||
return b.bindForm(req, obj)
|
||||
case strings.Contains(contentType, "multipart/form-data"):
|
||||
return b.bindMultipartForm(req, obj)
|
||||
case strings.Contains(contentType, "json"):
|
||||
return b.bindJSON(req, obj)
|
||||
}
|
||||
return Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
|
||||
}
|
||||
|
||||
func (b *Binder) bindForm(req *http.Request, formStruct any) (errs Errors) {
|
||||
if err := req.ParseForm(); err != nil {
|
||||
errs.addDeserializationError(err)
|
||||
}
|
||||
errs = mapForm(reflect.ValueOf(formStruct), req.Form, nil, errs)
|
||||
return append(errs, b.Validate(req.Context(), formStruct)...)
|
||||
}
|
||||
|
||||
func (b *Binder) bindMultipartForm(req *http.Request, formStruct any) (errs Errors) {
|
||||
if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
|
||||
errs.addDeserializationError(err)
|
||||
}
|
||||
if req.MultipartForm != nil {
|
||||
errs = mapForm(reflect.ValueOf(formStruct), req.MultipartForm.Value, req.MultipartForm.File, errs)
|
||||
}
|
||||
return append(errs, b.Validate(req.Context(), formStruct)...)
|
||||
}
|
||||
|
||||
func (b *Binder) bindJSON(req *http.Request, jsonStruct any) (errs Errors) {
|
||||
err := json.NewDecoder(req.Body).Decode(jsonStruct)
|
||||
if err != nil && !errors.Is(err, io.EOF) { // an empty body binds nothing
|
||||
errs.addDeserializationError(err)
|
||||
}
|
||||
return append(errs, b.Validate(req.Context(), jsonStruct)...)
|
||||
}
|
||||
|
||||
func (b *Binder) Validate(ctx context.Context, obj any) Errors {
|
||||
ensurePointer(obj)
|
||||
return b.validateStruct(ctx, nil, reflect.ValueOf(obj).Elem())
|
||||
}
|
||||
|
||||
func (b *Binder) validateStruct(ctx context.Context, errs Errors, structValue reflect.Value) Errors {
|
||||
structType := structValue.Type()
|
||||
for i := range structType.NumField() {
|
||||
field := structType.Field(i)
|
||||
fieldValue := structValue.Field(i)
|
||||
if field.Tag.Get("form") == "-" || !fieldValue.CanInterface() {
|
||||
continue
|
||||
}
|
||||
if field.Type.Kind() == reflect.Struct ||
|
||||
(field.Type.Kind() == reflect.Pointer && !fieldValue.IsNil() && field.Type.Elem().Kind() == reflect.Struct) {
|
||||
errs = b.validateStruct(ctx, errs, reflect.Indirect(fieldValue))
|
||||
continue
|
||||
}
|
||||
errs = b.validateField(ctx, errs, &ValidationField{StructField: field, reflectValue: fieldValue})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func (b *Binder) validateField(ctx context.Context, errs Errors, field *ValidationField) Errors {
|
||||
if field.reflectValue.Kind() == reflect.Slice {
|
||||
for i := range field.reflectValue.Len() {
|
||||
if elem := reflect.Indirect(field.reflectValue.Index(i)); elem.Kind() == reflect.Struct {
|
||||
errs = b.validateStruct(ctx, errs, elem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for rule := range strings.SplitSeq(field.StructField.Tag.Get("binding"), ";") {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
ruleName, ruleArgs, _ := strings.Cut(rule, "(")
|
||||
field.ruleArgs = nil
|
||||
if ruleArgs != "" {
|
||||
field.ruleArgs = strings.Split(strings.TrimSuffix(ruleArgs, ")"), ",")
|
||||
}
|
||||
item, ok := b.rules[ruleName]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("Invalid binding rule: %q", ruleName))
|
||||
}
|
||||
value := reflect.Indirect(field.reflectValue)
|
||||
if item.forZeroValue != (!value.IsValid() || value.IsZero()) {
|
||||
continue
|
||||
}
|
||||
if err := item.validatorFn(ctx, field); err != nil {
|
||||
return append(errs, *err)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
var nonAlphaDashDotPattern = sync.OnceValue(func() *regexp.Regexp {
|
||||
return regexp.MustCompile(`[^\w-.]`)
|
||||
})
|
||||
|
||||
func mapForm(formStruct reflect.Value, form map[string][]string, formFiles map[string][]*multipart.FileHeader, errs Errors) Errors {
|
||||
formStruct = reflect.Indirect(formStruct)
|
||||
structType := formStruct.Type()
|
||||
|
||||
for fieldIdx := range structType.NumField() {
|
||||
typeField := structType.Field(fieldIdx)
|
||||
fieldValue := formStruct.Field(fieldIdx)
|
||||
|
||||
if typeField.Type.Kind() == reflect.Pointer && typeField.Anonymous {
|
||||
fieldValue.Set(reflect.New(typeField.Type.Elem()))
|
||||
errs = mapForm(fieldValue.Elem(), form, formFiles, errs)
|
||||
if fieldValue.Elem().IsZero() {
|
||||
fieldValue.SetZero()
|
||||
}
|
||||
} else if typeField.Type.Kind() == reflect.Struct {
|
||||
errs = mapForm(fieldValue, form, formFiles, errs)
|
||||
}
|
||||
|
||||
inputFieldName := typeField.Tag.Get("form")
|
||||
if inputFieldName == "-" || !typeField.IsExported() {
|
||||
continue
|
||||
}
|
||||
if inputFieldName == "" {
|
||||
inputFieldName = util.ToSnakeCase(typeField.Name)
|
||||
}
|
||||
|
||||
if inputValues, exists := form[inputFieldName]; exists {
|
||||
if fieldValue.Kind() == reflect.Slice && len(inputValues) > 0 {
|
||||
slice := reflect.MakeSlice(fieldValue.Type(), len(inputValues), len(inputValues))
|
||||
for elemIdx, inputValue := range inputValues {
|
||||
errs.addOptional(setWithProperType(typeField, inputValue, slice.Index(elemIdx)))
|
||||
}
|
||||
fieldValue.Set(slice)
|
||||
} else {
|
||||
errs.addOptional(setWithProperType(typeField, inputValues[0], fieldValue))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
inputFiles, exists := formFiles[inputFieldName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
fileHeaderType := reflect.TypeFor[*multipart.FileHeader]()
|
||||
if fieldValue.Kind() == reflect.Slice && len(inputFiles) > 0 && fieldValue.Type().Elem() == fileHeaderType {
|
||||
fieldValue.Set(reflect.ValueOf(slices.Clone(inputFiles)))
|
||||
} else if fieldValue.Type() == fileHeaderType {
|
||||
fieldValue.Set(reflect.ValueOf(inputFiles[0]))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func setWithProperType(structField reflect.StructField, val string, fieldValue reflect.Value) *Error {
|
||||
switch fieldValue.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
intVal, err := strconv.ParseInt(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
|
||||
if err != nil {
|
||||
return newFieldError(structField, errTypeCast, "Value could not be parsed as integer")
|
||||
}
|
||||
fieldValue.SetInt(intVal)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
uintVal, err := strconv.ParseUint(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
|
||||
if err != nil {
|
||||
return newFieldError(structField, errTypeCast, "Value could not be parsed as unsigned integer")
|
||||
}
|
||||
fieldValue.SetUint(uintVal)
|
||||
case reflect.Bool:
|
||||
if val == "on" {
|
||||
fieldValue.SetBool(true)
|
||||
break
|
||||
}
|
||||
boolVal, err := strconv.ParseBool(cmp.Or(val, "false"))
|
||||
if err != nil {
|
||||
return newFieldError(structField, errTypeCast, "Value could not be parsed as boolean")
|
||||
}
|
||||
fieldValue.SetBool(boolVal)
|
||||
case reflect.String:
|
||||
fieldValue.SetString(val)
|
||||
case reflect.Pointer:
|
||||
newValue := reflect.New(fieldValue.Type().Elem())
|
||||
if err := setWithProperType(structField, val, newValue.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
fieldValue.Set(newValue)
|
||||
default:
|
||||
return newFieldError(structField, errDeserialization, "unsupported type: "+fieldValue.Kind().String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePointer(obj any) {
|
||||
if reflect.TypeOf(obj).Kind() != reflect.Pointer {
|
||||
panic("Pointers are only accepted as binding models")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Copyright 2014 Martini Authors
|
||||
// Copyright 2014 The Macaron Authors
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
Post struct {
|
||||
Title string `form:"title" json:"title" binding:"Required"`
|
||||
Content string `form:"content" json:"content"`
|
||||
}
|
||||
|
||||
Person struct {
|
||||
Name string `form:"name" json:"name" binding:"Required"`
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
BlogPost struct {
|
||||
Post
|
||||
ID int `json:"id" binding:"Required"`
|
||||
Ignored string `form:"-" json:"-"`
|
||||
Ratings []int `form:"rating" json:"ratings"`
|
||||
Author Person `json:"author"`
|
||||
Coauthor *Person `json:"coauthor"`
|
||||
HeaderImage *multipart.FileHeader `json:"-"`
|
||||
Pictures []*multipart.FileHeader `form:"picture" json:"-"`
|
||||
}
|
||||
|
||||
EmbedPerson struct {
|
||||
*Person
|
||||
}
|
||||
|
||||
Group struct {
|
||||
Name string `json:"name" binding:"Required"`
|
||||
People []*Person `json:"people" binding:"MinSize(1)"`
|
||||
}
|
||||
|
||||
Everything struct {
|
||||
Int int
|
||||
Int8 int8
|
||||
Int16 int16
|
||||
Uint uint
|
||||
Uint8 uint8
|
||||
Uint64 uint64
|
||||
Bool bool
|
||||
BoolOn bool
|
||||
String string
|
||||
IntPointer *int
|
||||
Map map[string]string
|
||||
}
|
||||
|
||||
Rules struct {
|
||||
AlphaDashDot string `binding:"AlphaDashDot"`
|
||||
MinSize string `binding:"MinSize(3)"`
|
||||
MinSizeSlice []string `binding:"MinSize(2)"`
|
||||
MaxSize string `binding:"MaxSize(1)"`
|
||||
MaxSizeSlice []string `binding:"MaxSize(1)"`
|
||||
Range int `binding:"Range(1,2)"`
|
||||
In string `binding:"In(a,b)"`
|
||||
Include string `binding:"Include(x)"`
|
||||
Pointer *string `binding:"MaxSize(1)"`
|
||||
}
|
||||
|
||||
RequiredForm struct {
|
||||
String string `binding:"Required"`
|
||||
Int int `binding:"Required"`
|
||||
Bool bool `binding:"Required"`
|
||||
Slice []int `binding:"Required"`
|
||||
Pointer *string `binding:"Required"`
|
||||
PointerToZero *string `binding:"Required"`
|
||||
}
|
||||
|
||||
AnyString string
|
||||
|
||||
Trimmed struct {
|
||||
Value AnyString `binding:"TrimSpace;Required"`
|
||||
Pointer *AnyString `binding:"TrimSpace;Required"`
|
||||
}
|
||||
|
||||
TrimForm struct {
|
||||
Trimmed
|
||||
Nested Trimmed
|
||||
Items []Trimmed
|
||||
PointerItems []*Trimmed
|
||||
}
|
||||
|
||||
InvalidRule struct {
|
||||
Field string `binding:"InvalidRule"`
|
||||
}
|
||||
)
|
||||
|
||||
func TestBind(t *testing.T) {
|
||||
const formType, jsonType = "application/x-www-form-urlencoded", "application/json"
|
||||
requiredTitle := Error{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"}
|
||||
unsupported := Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
|
||||
assert.Equal(t, "Required", requiredTitle.Error())
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
expected any
|
||||
errs Errors
|
||||
}{
|
||||
{name: "form missing required", contentType: formType, body: "content=C", expected: Post{Content: "C"}, errs: Errors{requiredTitle}},
|
||||
{
|
||||
name: "form malformed", contentType: formType, body: "title=%2", expected: Post{},
|
||||
errs: Errors{{Classification: errDeserialization, Message: `invalid URL escape "%2"`}, requiredTitle},
|
||||
},
|
||||
{
|
||||
name: "form nested and embedded", contentType: formType, body: "title=T&content=C&id=1&name=N&rating=4&rating=3&-=x&ignored=x",
|
||||
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
|
||||
},
|
||||
{name: "query on POST", target: "/?title=T", contentType: formType, expected: Post{Title: "T"}},
|
||||
{name: "query on GET", method: http.MethodGet, target: "/?title=T&content=C", expected: Post{Title: "T", Content: "C"}},
|
||||
{name: "query on GET with content type", method: http.MethodGet, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
|
||||
{name: "query on HEAD with content type", method: http.MethodHead, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
|
||||
{name: "embedded pointer", method: http.MethodGet, target: "/?name=N&email=E", expected: EmbedPerson{&Person{Name: "N", Email: "E"}}},
|
||||
{name: "embedded pointer not bound", method: http.MethodGet, target: "/", expected: EmbedPerson{}},
|
||||
{name: "DELETE without content type", method: http.MethodDelete, target: "/?title=T", expected: Post{Title: "T"}},
|
||||
{name: "POST without content type", target: "/?title=T", expected: Post{}, errs: unsupported},
|
||||
{name: "unsupported content type", method: http.MethodPatch, contentType: "text/plain", body: "title=T", expected: Post{}, errs: unsupported},
|
||||
{
|
||||
name: "json ignores form tags", method: http.MethodPut, contentType: jsonType, body: `{"title":"T","content":"C","id":1,"rating":[1],"ratings":[4,3],"author":{"name":"N"}}`,
|
||||
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
|
||||
},
|
||||
{
|
||||
name: "json malformed", contentType: jsonType, body: `{"title":"T"`, expected: Post{Title: "T"},
|
||||
errs: Errors{{Classification: errDeserialization, Message: "jsontext: unexpected EOF after offset 12"}},
|
||||
},
|
||||
{
|
||||
name: "json null slice element", contentType: jsonType, body: `{"name":"G","people":[null,{"name":"N"}]}`,
|
||||
expected: Group{Name: "G", People: []*Person{nil, {Name: "N"}}},
|
||||
},
|
||||
{
|
||||
name: "json slice element required", contentType: jsonType, body: `{"name":"G","people":[{"email":"E"}]}`,
|
||||
expected: Group{Name: "G", People: []*Person{{Email: "E"}}},
|
||||
errs: Errors{{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"}},
|
||||
},
|
||||
{
|
||||
name: "form type conversion", contentType: formType,
|
||||
body: "int=-1&int8=-8&int16=&uint=1&uint8=8&uint64=64&bool=true&bool_on=on&string=s&int_pointer=7",
|
||||
expected: Everything{Int: -1, Int8: -8, Uint: 1, Uint8: 8, Uint64: 64, Bool: true, BoolOn: true, String: "s", IntPointer: new(7)},
|
||||
},
|
||||
{
|
||||
name: "form type conversion errors", contentType: formType,
|
||||
body: "int=x&int8=128&uint=-1&uint8=256&bool=maybe&int_pointer=x&map=x",
|
||||
expected: Everything{},
|
||||
errs: Errors{
|
||||
{FieldNames: []string{"Int"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
|
||||
{FieldNames: []string{"Int8"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
|
||||
{FieldNames: []string{"Uint"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
|
||||
{FieldNames: []string{"Uint8"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
|
||||
{FieldNames: []string{"Bool"}, Classification: errTypeCast, Message: "Value could not be parsed as boolean"},
|
||||
{FieldNames: []string{"IntPointer"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
|
||||
{FieldNames: []string{"Map"}, Classification: errDeserialization, Message: "unsupported type: map"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(cmp.Or(tc.method, http.MethodPost), cmp.Or(tc.target, "/"), strings.NewReader(tc.body))
|
||||
if tc.contentType != "" {
|
||||
req.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
actual := reflect.New(reflect.TypeOf(tc.expected))
|
||||
assert.Equal(t, tc.errs, NewBinder().Bind(req, actual.Interface()))
|
||||
assert.Equal(t, tc.expected, actual.Elem().Interface())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindMultipartForm(t *testing.T) {
|
||||
readFile := func(t *testing.T, fileHeader *multipart.FileHeader) string {
|
||||
file, err := fileHeader.Open()
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
return string(content)
|
||||
}
|
||||
|
||||
for _, parsedBefore := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("parsed before %v", parsedBefore), func(t *testing.T) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
for _, field := range [][2]string{{"title", "T"}, {"id", "1"}, {"rating", "3"}, {"rating", "5"}, {"name", "N"}} {
|
||||
require.NoError(t, writer.WriteField(field[0], field[1]))
|
||||
}
|
||||
for _, file := range [][2]string{{"header_image", "header.txt"}, {"picture", "a.txt"}, {"picture", "b.txt"}} {
|
||||
fileWriter, err := writer.CreateFormFile(file[0], file[1])
|
||||
require.NoError(t, err)
|
||||
_, err = fileWriter.Write([]byte("content of " + file[1]))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
if parsedBefore {
|
||||
assert.Equal(t, "T", req.FormValue("title"))
|
||||
}
|
||||
var actual BlogPost
|
||||
assert.Empty(t, NewBinder().Bind(req, &actual))
|
||||
assert.Equal(t, Post{Title: "T"}, actual.Post)
|
||||
assert.Equal(t, 1, actual.ID)
|
||||
assert.Equal(t, []int{3, 5}, actual.Ratings)
|
||||
assert.Equal(t, Person{Name: "N"}, actual.Author)
|
||||
assert.Equal(t, "content of header.txt", readFile(t, actual.HeaderImage))
|
||||
require.Len(t, actual.Pictures, 2)
|
||||
assert.Equal(t, "content of a.txt", readFile(t, actual.Pictures[0]))
|
||||
assert.Equal(t, "content of b.txt", readFile(t, actual.Pictures[1]))
|
||||
})
|
||||
}
|
||||
|
||||
for name, contentType := range map[string]string{"malformed body": "multipart/form-data; boundary=x", "missing boundary": "multipart/form-data"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("--x\r\nbroken"))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
var actual Post
|
||||
errs := NewBinder().Bind(req, &actual)
|
||||
require.Len(t, errs, 2)
|
||||
assert.Equal(t, errDeserialization, errs[0].Classification)
|
||||
assert.Equal(t, ErrRequired, errs[1].Classification)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data any
|
||||
expected any
|
||||
errs Errors
|
||||
}{
|
||||
{name: "rules skip zero values", data: &Rules{}},
|
||||
{
|
||||
name: "rules with valid values",
|
||||
data: &Rules{AlphaDashDot: "a-b_c.d", MinSize: "abc", MinSizeSlice: []string{"a", "b"}, MaxSize: "é", MaxSizeSlice: []string{"a"}, Range: 2, In: "b", Include: "axb", Pointer: new("a")},
|
||||
},
|
||||
{
|
||||
name: "rules with invalid values",
|
||||
data: &Rules{AlphaDashDot: "a,b", MinSize: "ab", MinSizeSlice: []string{"a"}, MaxSize: "ab", MaxSizeSlice: []string{"a", "b"}, Range: 3, In: "c", Include: "abc", Pointer: new("ab")},
|
||||
errs: Errors{
|
||||
{FieldNames: []string{"AlphaDashDot"}, Classification: ErrAlphaDashDot, Message: "AlphaDashDot"},
|
||||
{FieldNames: []string{"MinSize"}, Classification: ErrMinSize, Message: "MinSize"},
|
||||
{FieldNames: []string{"MinSizeSlice"}, Classification: ErrMinSize, Message: "MinSize"},
|
||||
{FieldNames: []string{"MaxSize"}, Classification: ErrMaxSize, Message: "MaxSize"},
|
||||
{FieldNames: []string{"MaxSizeSlice"}, Classification: ErrMaxSize, Message: "MaxSize"},
|
||||
{FieldNames: []string{"Range"}, Classification: ErrRange, Message: "Range"},
|
||||
{FieldNames: []string{"In"}, Classification: ErrIn, Message: "In"},
|
||||
{FieldNames: []string{"Include"}, Classification: ErrInclude, Message: "Include"},
|
||||
{FieldNames: []string{"Pointer"}, Classification: ErrMaxSize, Message: "MaxSize"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "required",
|
||||
data: &RequiredForm{PointerToZero: new("")},
|
||||
errs: Errors{
|
||||
{FieldNames: []string{"String"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Int"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Bool"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Slice"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"PointerToZero"}, Classification: ErrRequired, Message: "Required"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested embedded and pointer structs",
|
||||
data: &BlogPost{Coauthor: &Person{}},
|
||||
errs: Errors{
|
||||
{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"ID"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TrimSpace in nested embedded and slice elements",
|
||||
data: &TrimForm{Trimmed: Trimmed{Value: " a ", Pointer: new(AnyString(" b "))}, Nested: Trimmed{Value: " "}, Items: []Trimmed{{Value: " c "}}, PointerItems: []*Trimmed{{Value: " d ", Pointer: new(AnyString(" "))}}},
|
||||
expected: &TrimForm{Trimmed: Trimmed{Value: "a", Pointer: new(AnyString("b"))}, Nested: Trimmed{Value: ""}, Items: []Trimmed{{Value: "c"}}, PointerItems: []*Trimmed{{Value: "d", Pointer: new(AnyString(""))}}},
|
||||
errs: Errors{
|
||||
{FieldNames: []string{"Value"}, Classification: ErrRequired, Message: "Required"},
|
||||
{FieldNames: []string{"Pointer"}, Classification: ErrRequired, Message: "Required"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TrimSpace on non-string",
|
||||
data: &struct {
|
||||
Float float64 `binding:"TrimSpace"`
|
||||
}{Float: 1.5},
|
||||
errs: Errors{{FieldNames: []string{"Float"}, Classification: errTypeCast, Message: "TrimSpace"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.errs, NewBinder().Validate(t.Context(), tc.data))
|
||||
if tc.expected != nil {
|
||||
assert.Equal(t, tc.expected, tc.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assert.PanicsWithValue(t, `Invalid binding rule: "InvalidRule"`, func() {
|
||||
NewBinder().Validate(t.Context(), &InvalidRule{})
|
||||
})
|
||||
}
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
|
||||
"gitea.dev/modules/web/binding"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -125,7 +124,7 @@ func getFieldDisplayNameForMessage(f any, l translation.Locale, fieldNames []str
|
||||
}
|
||||
|
||||
func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs validation.BindingErrors) (errorMessage, errorFieldName string, fieldNames []string) {
|
||||
if bindingErrs.Len() == 0 {
|
||||
if len(bindingErrs) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
bindingErr := bindingErrs[0]
|
||||
@@ -137,30 +136,28 @@ func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs valida
|
||||
|
||||
errorFieldName = field.Name
|
||||
switch classification {
|
||||
case binding.ERR_REQUIRED:
|
||||
case binding.ErrRequired:
|
||||
errorMessage = l.TrString("form.require_error", fieldDisplayName)
|
||||
case binding.ERR_ALPHA_DASH:
|
||||
errorMessage = l.TrString("form.alpha_dash_error", fieldDisplayName)
|
||||
case binding.ERR_ALPHA_DASH_DOT:
|
||||
case binding.ErrAlphaDashDot:
|
||||
errorMessage = l.TrString("form.alpha_dash_dot_error", fieldDisplayName)
|
||||
case binding.ERR_MIN_SIZE:
|
||||
case binding.ErrMinSize:
|
||||
errorMessage = l.TrString("form.min_size_error", fieldDisplayName, getRuleBody(field, "MinSize"))
|
||||
case binding.ERR_MAX_SIZE:
|
||||
case binding.ErrMaxSize:
|
||||
errorMessage = l.TrString("form.max_size_error", fieldDisplayName, getRuleBody(field, "MaxSize"))
|
||||
case binding.ERR_RANGE:
|
||||
case binding.ErrRange:
|
||||
rangeMin, rangeMax, _ := strings.Cut(getRuleBody(field, "Range"), ",")
|
||||
errorMessage = l.TrString("form.range_error", fieldDisplayName, rangeMin, rangeMax)
|
||||
case binding.ERR_EMAIL:
|
||||
case validation.ErrEmail:
|
||||
errorMessage = l.TrString("form.email_error", fieldDisplayName)
|
||||
case binding.ERR_URL:
|
||||
case validation.ErrURL:
|
||||
errorMessage = l.TrString("form.url_error", fieldDisplayName)
|
||||
case binding.ERR_IN:
|
||||
case binding.ErrIn:
|
||||
ruleBody := getRuleBody(field, "In")
|
||||
if strings.HasPrefix(ruleBody, ",") {
|
||||
ruleBody = "(empty)" + ruleBody
|
||||
}
|
||||
errorMessage = l.TrString("form.in_error", fieldDisplayName, ruleBody)
|
||||
case binding.ERR_INCLUDE:
|
||||
case binding.ErrInclude:
|
||||
errorMessage = l.TrString("form.include_error", fieldDisplayName, getRuleBody(field, "Include"))
|
||||
|
||||
case validation.ErrCustomMessage:
|
||||
|
||||
@@ -527,10 +527,8 @@
|
||||
"form.SSPISeparatorReplacement": "Separator",
|
||||
"form.SSPIDefaultLanguage": "Default Language",
|
||||
"form.require_error": "%s cannot be empty.",
|
||||
"form.alpha_dash_error": "%s should contain only alphanumeric, dash ('-') and underscore ('_') characters.",
|
||||
"form.alpha_dash_dot_error": "%s should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.",
|
||||
"form.git_ref_name_error": "%s must be a well-formed Git reference name.",
|
||||
"form.size_error": "%[1]s must be size %[2]s.",
|
||||
"form.min_size_error": "%[1]s must contain at least %[2]s characters.",
|
||||
"form.max_size_error": "%[1]s must contain at most %[2]s characters.",
|
||||
"form.range_error": "%[1]s must be a number from %[2]s to %[3]s.",
|
||||
|
||||
@@ -65,6 +65,6 @@ type AdminEditUserForm struct {
|
||||
// AdminDashboardForm form for admin dashboard operations
|
||||
type AdminDashboardForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Op string `binding:"required"`
|
||||
Op string `binding:"Required"`
|
||||
From string
|
||||
}
|
||||
@@ -57,5 +57,5 @@ type CreateTeamForm struct {
|
||||
Permission string
|
||||
RepoAccess string
|
||||
CanCreateOrgRepo bool
|
||||
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
|
||||
Visibility string `binding:"In(public,limited,private)"`
|
||||
}
|
||||
@@ -350,7 +350,7 @@ type CreateIssueForm struct {
|
||||
type CreateCommentForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Content string
|
||||
Status string `binding:"OmitEmpty;In(reopen,close)"`
|
||||
Status string `binding:"In(reopen,close)"`
|
||||
Files []string
|
||||
}
|
||||
|
||||
@@ -571,12 +571,6 @@ type AddTimeManuallyForm struct {
|
||||
Minutes int `binding:"Range(0,1000)"`
|
||||
}
|
||||
|
||||
// SaveTopicForm form for save topics for repository
|
||||
type SaveTopicForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Topics []string `binding:"topics;Required;"`
|
||||
}
|
||||
|
||||
// AddDeployTokenForm form for adding a deploy token to a repository
|
||||
type AddDeployTokenForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
|
||||
@@ -38,7 +38,7 @@ type InstallForm struct {
|
||||
SMTPAddr string `binding:"TrimSpace"`
|
||||
SMTPPort string `binding:"TrimSpace"`
|
||||
SMTPFrom string `binding:"TrimSpace"`
|
||||
SMTPUser string `binding:"TrimSpace;OmitEmpty;MaxSize(254)" locale:"install.mailer_user"`
|
||||
SMTPUser string `binding:"TrimSpace;MaxSize(254)" locale:"install.mailer_user"`
|
||||
SMTPPasswd string
|
||||
RegisterConfirm bool
|
||||
MailNotify bool
|
||||
@@ -183,7 +183,7 @@ type AvatarForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Source string
|
||||
Avatar *multipart.FileHeader
|
||||
Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"`
|
||||
Gravatar string `binding:"Email;MaxSize(254)"`
|
||||
}
|
||||
|
||||
// AddEmailForm form for adding new email
|
||||
@@ -215,12 +215,12 @@ type AddOpenIDForm struct {
|
||||
// AddKeyForm form for adding SSH/GPG key
|
||||
type AddKeyForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Type string `binding:"OmitEmpty"`
|
||||
Type string
|
||||
Title string `binding:"Required;MaxSize(50)"`
|
||||
Content string `binding:"Required"`
|
||||
Signature string `binding:"OmitEmpty"`
|
||||
KeyID string `binding:"OmitEmpty"`
|
||||
Fingerprint string `binding:"OmitEmpty"`
|
||||
Signature string
|
||||
KeyID string
|
||||
Fingerprint string
|
||||
IsWritable bool
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user