diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..39839e0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gofiber/maintainers \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..78a037b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" # Location of package manifests + labels: + - "๐Ÿค– Dependencies" + schedule: + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + labels: + - "๐Ÿค– Dependencies" \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..8f1601c --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,26 @@ +# .github/release.yml + +changelog: + categories: + - title: 'โ— Breaking Changes' + labels: + - 'โ— BreakingChange' + - title: '๐Ÿš€ New Features' + labels: + - 'โœ๏ธ Feature' + - '๐Ÿ“ Proposal' + - title: '๐Ÿงน Updates' + labels: + - '๐Ÿงน Updates' + - title: '๐Ÿ› Bug Fixes' + labels: + - 'โ˜ข๏ธ Bug' + - title: '๐Ÿ› ๏ธ Maintenance' + labels: + - '๐Ÿค– Dependencies' + - title: '๐Ÿ“š Documentation' + labels: + - '๐Ÿ“’ Documentation' + - title: 'Other Changes' + labels: + - '*' diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..3e53e3f --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,111 @@ +on: + push: + branches: + - master + - main + paths-ignore: + - "**/*.md" + pull_request: + paths-ignore: + - "**/*.md" + +permissions: + # deployments permission to deploy GitHub pages website + deployments: write + # contents permission to update benchmark contents in gh-pages branch + contents: write + # allow posting comments to pull request + pull-requests: write + +name: Benchmark +jobs: + Compare: + runs-on: ubuntu-latest + steps: + - name: Fetch Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # to be able to retrieve the last commit in main + + - name: Install Go + uses: actions/setup-go@v5 + with: + # NOTE: Keep this in sync with the version from go.mod + go-version: "1.22.x" + + - name: Run Benchmark + run: set -o pipefail; go test ./... -benchmem -run=^$ -bench . | tee output.txt + + # NOTE: Benchmarks could change with different CPU types + - name: Get GitHub Runner System Information + uses: kenchan0130/actions-system-info@v1.3.0 + id: system-info + + - name: Get Main branch SHA + id: get-main-branch-sha + run: | + SHA=$(git rev-parse origin/main) + echo "sha=$SHA" >> $GITHUB_OUTPUT + + - name: Get Benchmark Results from main branch + id: cache + uses: actions/cache/restore@v4 + with: + path: ./cache + key: ${{ steps.get-main-branch-sha.outputs.sha }}-${{ runner.os }}-${{ steps.system-info.outputs.cpu-model }}-benchmark + + # This will only run if we have Benchmark Results from main branch + - name: Compare PR Benchmark Results with main branch + uses: benchmark-action/github-action-benchmark@v1.20.4 + if: steps.cache.outputs.cache-hit == 'true' + with: + tool: 'go' + output-file-path: output.txt + external-data-json-path: ./cache/benchmark-data.json + # Do not save the data (This allows comparing benchmarks) + save-data-file: false + fail-on-alert: true + # Comment on the PR if the branch is not a fork + comment-on-alert: ${{ github.event.pull_request.head.repo.fork == false }} + github-token: ${{ secrets.GITHUB_TOKEN }} + summary-always: true + alert-threshold: "150%" + + - name: Store Benchmark Results for main branch + uses: benchmark-action/github-action-benchmark@v1.20.4 + if: ${{ github.ref_name == 'main' }} + with: + tool: 'go' + output-file-path: output.txt + external-data-json-path: ./cache/benchmark-data.json + # Save the data to external file (cache) + save-data-file: true + fail-on-alert: false + github-token: ${{ secrets.GITHUB_TOKEN }} + summary-always: true + alert-threshold: "150%" + + - name: Publish Benchmark Results to GitHub Pages + uses: benchmark-action/github-action-benchmark@v1.20.4 + if: ${{ github.ref_name == 'main' }} + with: + tool: 'go' + output-file-path: output.txt + benchmark-data-dir-path: "benchmarks" + fail-on-alert: false + github-token: ${{ secrets.GITHUB_TOKEN }} + comment-on-alert: true + summary-always: true + # Save the data to external file (GitHub Pages) + save-data-file: true + alert-threshold: "150%" + # TODO: reactivate it later -> when v3 is the stable one + #auto-push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + auto-push: false + + - name: Update Benchmark Results cache + uses: actions/cache/save@v4 + if: ${{ github.ref_name == 'main' }} + with: + path: ./cache + key: ${{ steps.get-main-branch-sha.outputs.sha }}-${{ runner.os }}-${{ steps.system-info.outputs.cpu-model }}-benchmark \ No newline at end of file diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml deleted file mode 100644 index 768b05b..0000000 --- a/.github/workflows/issues.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Add all the issues created to the project. -name: Add issue or pull request to Project - -on: - issues: - types: - - opened - pull_request_target: - types: - - opened - - reopened - -jobs: - add-to-project: - runs-on: ubuntu-latest - steps: - - name: Add issue to project - uses: actions/add-to-project@v0.5.0 - with: - project-url: https://github.com/orgs/gorilla/projects/4 - github-token: ${{ secrets.ADD_TO_PROJECT_TOKEN }} diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..9f5dd28 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,40 @@ +# Adapted from https://github.com/golangci/golangci-lint-action/blob/b56f6f529003f1c81d4d759be6bd5f10bf9a0fa0/README.md#how-to-use + +name: golangci-lint +on: + push: + branches: + - master + - main + paths-ignore: + - "**/*.md" + pull_request: + paths-ignore: + - "**/*.md" + +permissions: + # Required: allow read access to the content for analysis. + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + # Optional: Allow write access to checks to allow the action to annotate code in the PR. + checks: write + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + # NOTE: Keep this in sync with the version from go.mod + go-version: "1.22.x" + cache: false + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + # NOTE: Keep this in sync with the version from .golangci.yml + version: v1.62.2 \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index ff4a613..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Security -on: - push: - branches: - - main - pull_request: - branches: - - main -permissions: - contents: read -jobs: - scan: - strategy: - matrix: - go: ['1.20','1.21'] - fail-fast: true - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Setup Go ${{ matrix.go }} - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go }} - cache: false - - - name: Run GoSec - uses: securego/gosec@master - with: - args: -exclude-dir examples ./... - - - name: Run GoVulnCheck - uses: golang/govulncheck-action@v1 - with: - go-version-input: ${{ matrix.go }} - go-package: ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50a3946..0568e66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,35 +1,54 @@ name: Test + on: push: branches: + - master - main + paths-ignore: + - "**/*.md" pull_request: - branches: - - main -permissions: - contents: read + paths-ignore: + - "**/*.md" + jobs: unit: strategy: matrix: - go: ['1.20','1.21'] - os: [ubuntu-latest, macos-latest, windows-latest] - fail-fast: true - runs-on: ${{ matrix.os }} + go-version: [1.22.x, 1.23.x, 1.24.x] + platform: [ubuntu-latest, windows-latest, macos-latest, macos-13] + runs-on: ${{ matrix.platform }} steps: - - name: Checkout Code - uses: actions/checkout@v3 + - name: Fetch Repository + uses: actions/checkout@v4 - - name: Setup Go ${{ matrix.go }} - uses: actions/setup-go@v4 + - name: Install Go + uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go }} - cache: false + go-version: ${{ matrix.go-version }} - - name: Run Tests - run: go test -race -cover -coverprofile=coverage -covermode=atomic -v ./... + - name: Test + run: go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=1 -coverprofile=coverage.txt -covermode=atomic -shuffle=on - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + - name: Upload coverage reports to Codecov + if: ${{ matrix.platform == 'ubuntu-latest' && matrix.go-version == '1.23.x' }} + uses: codecov/codecov-action@v5.4.3 with: - files: ./coverage + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.txt + flags: unittests + slug: gofiber/schema + + repeated: + runs-on: ubuntu-latest + steps: + - name: Fetch Repository + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Test + run: go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=15 -shuffle=on diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index a3eb74b..0000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Verify -on: - push: - branches: - - main - pull_request: - branches: - - main -permissions: - contents: read -jobs: - lint: - strategy: - matrix: - go: ['1.20','1.21'] - fail-fast: true - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Setup Go ${{ matrix.go }} - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go }} - cache: false - - - name: Run GolangCI-Lint - uses: golangci/golangci-lint-action@v3 - with: - version: v1.53 - args: --timeout=5m diff --git a/.gitignore b/.gitignore index 84039fe..0cafd84 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ coverage.coverprofile +vendor + +.idea diff --git a/Makefile b/Makefile index 98f5ab7..2d10a13 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,65 @@ -GO_LINT=$(shell which golangci-lint 2> /dev/null || echo '') -GO_LINT_URI=github.com/golangci/golangci-lint/cmd/golangci-lint@latest - -GO_SEC=$(shell which gosec 2> /dev/null || echo '') -GO_SEC_URI=github.com/securego/gosec/v2/cmd/gosec@latest - -GO_VULNCHECK=$(shell which govulncheck 2> /dev/null || echo '') -GO_VULNCHECK_URI=golang.org/x/vuln/cmd/govulncheck@latest - -.PHONY: golangci-lint -golangci-lint: - $(if $(GO_LINT), ,go install $(GO_LINT_URI)) - @echo "##### Running golangci-lint" - golangci-lint run -v - -.PHONY: gosec -gosec: - $(if $(GO_SEC), ,go install $(GO_SEC_URI)) - @echo "##### Running gosec" - gosec ./... - -.PHONY: govulncheck -govulncheck: - $(if $(GO_VULNCHECK), ,go install $(GO_VULNCHECK_URI)) - @echo "##### Running govulncheck" - govulncheck ./... - -.PHONY: verify -verify: golangci-lint gosec govulncheck +## help: ๐Ÿ’ก Display available commands +.PHONY: help +help: + @echo 'โšก๏ธ GoFiber/Fiber Development:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' +## audit: ๐Ÿš€ Conduct quality checks +.PHONY: audit +audit: + go mod verify + go vet ./... + go run golang.org/x/vuln/cmd/govulncheck@latest ./... + +## benchmark: ๐Ÿ“ˆ Benchmark code performance +.PHONY: benchmark +benchmark: + go test ./... -benchmem -bench=. -count=4 -run=^Benchmark_$ + +## coverage: โ˜‚๏ธ Generate coverage report +.PHONY: coverage +coverage: + go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=1 -coverprofile=/tmp/coverage.out -covermode=atomic + go tool cover -html=/tmp/coverage.out + +## format: ๐ŸŽจ Fix code format issues +.PHONY: format +format: + go run mvdan.cc/gofumpt@latest -w -l . + +## markdown: ๐ŸŽจ Find markdown format issues (Requires markdownlint-cli2) +.PHONY: markdown +markdown: + markdownlint-cli2 "**/*.md" "#vendor" + +## lint: ๐Ÿšจ Run lint checks +.PHONY: lint +lint: + go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 run ./... + +## test: ๐Ÿšฆ Execute all tests .PHONY: test test: - @echo "##### Running tests" - go test -race -cover -coverprofile=coverage.coverprofile -covermode=atomic -v ./... \ No newline at end of file + go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=1 -shuffle=on + +## longtest: ๐Ÿšฆ Execute all tests 10x +.PHONY: longtest +longtest: + go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=15 -shuffle=on + +## tidy: ๐Ÿ“Œ Clean and tidy dependencies +.PHONY: tidy +tidy: + go mod tidy -v + +## betteralign: ๐Ÿ“ Optimize alignment of fields in structs +.PHONY: betteralign +betteralign: + go run github.com/dkorunic/betteralign/cmd/betteralign@latest -test_files -generated_files -apply ./... + +## generate: โšก๏ธ Generate msgp && interface implementations +.PHONY: generate +generate: + go install github.com/tinylib/msgp@latest + go install github.com/vburenin/ifacemaker@975a95966976eeb2d4365a7fb236e274c54da64c + go generate ./... diff --git a/README.md b/README.md index 58786ba..a40bc3e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,6 @@ -# gorilla/schema +# gofiber/schema -![testing](https://github.com/gorilla/schema/actions/workflows/test.yml/badge.svg) -[![codecov](https://codecov.io/github/gorilla/schema/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/schema) -[![godoc](https://godoc.org/github.com/gorilla/schema?status.svg)](https://godoc.org/github.com/gorilla/schema) -[![sourcegraph](https://sourcegraph.com/github.com/gorilla/schema/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/schema?badge) - - -![Gorilla Logo](https://github.com/gorilla/.github/assets/53367916/d92caabf-98e0-473e-bfbf-ab554ba435e5) - -Package gorilla/schema converts structs to and from form values. +Package gofiber/schema converts structs to and from form values. ## Example diff --git a/cache.go b/cache.go index 065b8d6..a8ea5f8 100644 --- a/cache.go +++ b/cache.go @@ -63,13 +63,14 @@ func (c *cache) parsePath(p string, t reflect.Type) ([]pathPart, error) { } // Valid field. Append index. path = append(path, field.name) - if field.isSliceOfStructs && (!field.unmarshalerInfo.IsValid || (field.unmarshalerInfo.IsValid && field.unmarshalerInfo.IsSliceElement)) { + if field.isSliceOfStructs && !isMultipartField(field.typ) && (!field.unmarshalerInfo.IsValid || (field.unmarshalerInfo.IsValid && field.unmarshalerInfo.IsSliceElement)) { // Parse a special case: slices of structs. // i+1 must be the slice index. // // Now that struct can implements TextUnmarshaler interface, // we don't need to force the struct's fields to appear in the path. // So checking i+2 is not necessary anymore. + // We can skip this part if the type is multipart.FileHeader. It is another special case too. i++ if i+1 > len(keys) { return nil, errInvalidPath @@ -309,9 +310,8 @@ func (o tagOptions) Contains(option string) bool { func (o tagOptions) getDefaultOptionValue() string { for _, s := range o { if strings.HasPrefix(s, "default:") { - return strings.Split(s, ":")[1] + return strings.SplitN(s, ":", 2)[1] } } - return "" } diff --git a/decoder.go b/decoder.go index 54c88ec..9cd6920 100644 --- a/decoder.go +++ b/decoder.go @@ -8,6 +8,7 @@ import ( "encoding" "errors" "fmt" + "mime/multipart" "reflect" "strings" ) @@ -79,27 +80,57 @@ func (d *Decoder) RegisterConverter(value interface{}, converterFunc Converter) // Keys are "paths" in dotted notation to the struct fields and nested structs. // // See the package documentation for a full explanation of the mechanics. -func (d *Decoder) Decode(dst interface{}, src map[string][]string) error { +func (d *Decoder) Decode(dst interface{}, src map[string][]string, files ...map[string][]*multipart.FileHeader) (err error) { + var multipartFiles map[string][]*multipart.FileHeader + + if len(files) > 0 { + multipartFiles = files[0] + } + + // Add files as empty string values to src in order to make path parsing work easily + for path := range multipartFiles { + src[path] = []string{""} + } + v := reflect.ValueOf(dst) if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { return errors.New("schema: interface must be a pointer to struct") } + + // Catch panics from the decoder and return them as an error. + // This is needed because the decoder calls reflect and reflect panics + defer func() { + if r := recover(); r != nil { + if e, ok := r.(error); ok { + err = e + } else { + err = fmt.Errorf("schema: panic while decoding: %v", r) + } + } + }() + v = v.Elem() t := v.Type() - errors := MultiError{} + multiErrors := MultiError{} for path, values := range src { if parts, err := d.cache.parsePath(path, t); err == nil { - if err = d.decode(v, path, parts, values); err != nil { - errors[path] = err + if filesSlice, ok := multipartFiles[path]; ok { + if err = d.decode(v, path, parts, values, filesSlice); err != nil { + multiErrors[path] = err + } + } else { + if err = d.decode(v, path, parts, values, nil); err != nil { + multiErrors[path] = err + } } } else if !d.ignoreUnknownKeys { - errors[path] = UnknownKeyError{Key: path} + multiErrors[path] = UnknownKeyError{Key: path} } } - errors.merge(d.setDefaults(t, v)) - errors.merge(d.checkRequired(t, src)) - if len(errors) > 0 { - return errors + multiErrors.merge(d.setDefaults(t, v)) + multiErrors.merge(d.checkRequired(t, src)) + if len(multiErrors) > 0 { + return multiErrors } return nil } @@ -252,7 +283,17 @@ func isEmptyFields(fields []fieldWithPrefix, src map[string][]string) bool { return false } for key := range src { - if !isEmpty(f.typ, src[key]) && strings.HasPrefix(key, path) { + nested := strings.IndexByte(key, '.') != -1 + + // for non required nested structs + c1 := strings.HasSuffix(f.prefix, ".") && key == path + + // for required nested structs + c2 := f.prefix == "" && nested && strings.HasPrefix(key, path) + + // for non nested fields + c3 := f.prefix == "" && !nested && key == path + if !isEmpty(f.typ, src[key]) && (c1 || c2 || c3) { return false } } @@ -273,8 +314,80 @@ func isEmpty(t reflect.Type, value []string) bool { return false } +var ( + multipartFileHeaderPointerType = reflect.TypeOf(&multipart.FileHeader{}) + sliceMultipartFileHeaderPointerType = reflect.TypeOf([]*multipart.FileHeader{}) +) + +// Supported multiple types: +// *multipart.FileHeader, *[]multipart.FileHeader, []*multipart.FileHeader +func handleMultipartField(field reflect.Value, files []*multipart.FileHeader) bool { + fieldType := field.Type() + if !isMultipartField(fieldType) { + return false + } + + // Skip if files are empty and field is multipart + if len(files) == 0 { + return true + } + + // Check for *multipart.FileHeader + if fieldType == multipartFileHeaderPointerType { + field.Set(reflect.ValueOf(files[0])) + return true + } + + // Check for []*multipart.FileHeader + if fieldType == sliceMultipartFileHeaderPointerType { + field.Set(reflect.ValueOf(files)) + return true + } + + // Check for *[]*multipart.FileHeader + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + + if field.IsNil() { + field.Set(reflect.New(fieldType)) + } + + if fieldType == sliceMultipartFileHeaderPointerType { + field.Elem().Set(reflect.ValueOf(files)) + return true + } + } + + return false +} + +// Supported multiple types: +// *multipart.FileHeader, *[]multipart.FileHeader, []*multipart.FileHeader +func isMultipartField(typ reflect.Type) bool { + // Check for *multipart.FileHeader + if typ == multipartFileHeaderPointerType { + return true + } + + // Check for []*multipart.FileHeader + if typ == sliceMultipartFileHeaderPointerType { + return true + } + + // Check for *[]*multipart.FileHeader + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + + if typ == sliceMultipartFileHeaderPointerType { + return true + } + } + + return false +} + // decode fills a struct field using a parsed path. -func (d *Decoder) decode(v reflect.Value, path string, parts []pathPart, values []string) error { +func (d *Decoder) decode(v reflect.Value, path string, parts []pathPart, values []string, files []*multipart.FileHeader) error { // Get the field walking the struct fields by index. for _, name := range parts[0].path { if v.Type().Kind() == reflect.Ptr { @@ -296,11 +409,17 @@ func (d *Decoder) decode(v reflect.Value, path string, parts []pathPart, values v = v.FieldByName(name) } + // Don't even bother for unexported fields. if !v.CanSet() { return nil } + // Check multipart files + if mp := handleMultipartField(v, files); mp { + return nil + } + // Dereference if needed. t := v.Type() if t.Kind() == reflect.Ptr { @@ -326,7 +445,7 @@ func (d *Decoder) decode(v reflect.Value, path string, parts []pathPart, values } v.Set(value) } - return d.decode(v.Index(idx), path, parts[1:], values) + return d.decode(v.Index(idx), path, parts[1:], values, files) } // Get the converter early in case there is one for a slice type. @@ -359,7 +478,7 @@ func (d *Decoder) decode(v reflect.Value, path string, parts []pathPart, values } else if m.IsValid { u := reflect.New(elemT) if m.IsSliceElementPtr { - u = reflect.New(reflect.PtrTo(elemT).Elem()) + u = reflect.New(reflect.PointerTo(elemT).Elem()) } if err := u.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(value)); err != nil { return ConversionError{ @@ -513,7 +632,7 @@ func isTextUnmarshaler(v reflect.Value) unmarshaler { // encoding.TextUnmarshaler m.IsSliceElement = true if t = t.Elem(); t.Kind() == reflect.Ptr { - t = reflect.PtrTo(t.Elem()) + t = reflect.PointerTo(t.Elem()) v = reflect.Zero(t) m.IsSliceElementPtr = true m.Unmarshaler, m.IsValid = v.Interface().(encoding.TextUnmarshaler) diff --git a/decoder_test.go b/decoder_test.go index d01569e..c30f7a4 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "errors" "fmt" + "mime/multipart" "reflect" "strings" "testing" @@ -56,6 +57,32 @@ type S1 struct { F21 []*rudeBool `schema:"f21"` } +type LargeStructForBenchmark struct { + F1 string `schema:"f1"` + F2 string `schema:"f2"` + F3 int `schema:"f3"` + F4 int `schema:"f4"` + F5 []string `schema:"f5"` + F6 []int `schema:"f6"` + F7 float64 `schema:"f7"` + F8 bool `schema:"f8"` + F9 struct { + N1 time.Time `schema:"n1"` + N2 string `schema:"n2"` + } `schema:"f9"` +} + +// A simple struct for demonstration benchmarks +type SimpleStructForBenchmark struct { + A string `schema:"a"` + B int `schema:"b"` + C bool `schema:"c"` + D float64 `schema:"d"` + E struct { + F float64 `schema:"f"` + } `schema:"e"` +} + type S2 struct { F01 *[]*int `schema:"f1"` } @@ -133,34 +160,34 @@ func TestAll(t *testing.T) { }, F09: 0, F10: []S1{ - S1{ + { F10: []S1{ - S1{F06: &[]*int{&f101, &f102}}, - S1{F06: &[]*int{&f103, &f104}}, + {F06: &[]*int{&f101, &f102}}, + {F06: &[]*int{&f103, &f104}}, }, }, }, F11: []*S1{ - &S1{ + { F11: []*S1{ - &S1{F06: &[]*int{&f111, &f112}}, - &S1{F06: &[]*int{&f113, &f114}}, + {F06: &[]*int{&f111, &f112}}, + {F06: &[]*int{&f113, &f114}}, }, }, }, F12: &[]S1{ - S1{ + { F12: &[]S1{ - S1{F06: &[]*int{&f121, &f122}}, - S1{F06: &[]*int{&f123, &f124}}, + {F06: &[]*int{&f121, &f122}}, + {F06: &[]*int{&f123, &f124}}, }, }, }, F13: &[]*S1{ - &S1{ + { F13: &[]*S1{ - &S1{F06: &[]*int{&f131, &f132}}, - &S1{F06: &[]*int{&f133, &f134}}, + {F06: &[]*int{&f131, &f132}}, + {F06: &[]*int{&f133, &f134}}, }, }, }, @@ -409,11 +436,11 @@ func BenchmarkAll(b *testing.B) { "f13.0.f13.1.f6": {"133", "134"}, } + decoder := NewDecoder() b.ResetTimer() for i := 0; i < b.N; i++ { - s := &S1{} - _ = NewDecoder().Decode(s, v) + _ = decoder.Decode(S1{}, v) } } @@ -598,8 +625,10 @@ func TestSimpleExample(t *testing.T) { S05: "S5", Str: "Str", }, - Bif: []Baz{{ - F99: []string{"A", "B", "C"}}, + Bif: []Baz{ + { + F99: []string{"A", "B", "C"}, + }, }, } @@ -939,34 +968,34 @@ func TestAllNT(t *testing.T) { }, F9: 0, F10: []S1{ - S1{ + { F10: []S1{ - S1{F06: &[]*int{&f101, &f102}}, - S1{F06: &[]*int{&f103, &f104}}, + {F06: &[]*int{&f101, &f102}}, + {F06: &[]*int{&f103, &f104}}, }, }, }, F11: []*S1{ - &S1{ + { F11: []*S1{ - &S1{F06: &[]*int{&f111, &f112}}, - &S1{F06: &[]*int{&f113, &f114}}, + {F06: &[]*int{&f111, &f112}}, + {F06: &[]*int{&f113, &f114}}, }, }, }, F12: &[]S1{ - S1{ + { F12: &[]S1{ - S1{F06: &[]*int{&f121, &f122}}, - S1{F06: &[]*int{&f123, &f124}}, + {F06: &[]*int{&f121, &f122}}, + {F06: &[]*int{&f123, &f124}}, }, }, }, F13: &[]*S1{ - &S1{ + { F13: &[]*S1{ - &S1{F06: &[]*int{&f131, &f132}}, - &S1{F06: &[]*int{&f133, &f134}}, + {F06: &[]*int{&f131, &f132}}, + {F06: &[]*int{&f133, &f134}}, }, }, }, @@ -1287,7 +1316,7 @@ func TestRegisterConverterSlice(t *testing.T) { expected := []string{"one", "two", "three"} err := decoder.Decode(&result, map[string][]string{ - "multiple": []string{"one,two,three"}, + "multiple": {"one,two,three"}, }) if err != nil { t.Fatalf("Failed to decode: %v", err) @@ -1319,7 +1348,7 @@ func TestRegisterConverterMap(t *testing.T) { }{} err := decoder.Decode(&result, map[string][]string{ - "multiple": []string{"a:one,b:two"}, + "multiple": {"a:one,b:two"}, }) if err != nil { t.Fatal(err) @@ -1366,9 +1395,9 @@ type S16 struct { func TestCustomTypeSlice(t *testing.T) { data := map[string][]string{ - "Value.0": []string{"Louisa May Alcott"}, - "Value.1": []string{"Florence Nightingale"}, - "Value.2": []string{"Clara Barton"}, + "Value.0": {"Louisa May Alcott"}, + "Value.1": {"Florence Nightingale"}, + "Value.2": {"Clara Barton"}, } s := S13{} @@ -1394,9 +1423,9 @@ func TestCustomTypeSlice(t *testing.T) { func TestCustomTypeSliceWithError(t *testing.T) { data := map[string][]string{ - "Value.0": []string{"Louisa May Alcott"}, - "Value.1": []string{"Florence Nightingale"}, - "Value.2": []string{"Clara"}, + "Value.0": {"Louisa May Alcott"}, + "Value.1": {"Florence Nightingale"}, + "Value.2": {"Clara"}, } s := S13{} @@ -1409,9 +1438,9 @@ func TestCustomTypeSliceWithError(t *testing.T) { func TestNoTextUnmarshalerTypeSlice(t *testing.T) { data := map[string][]string{ - "Value.0": []string{"Louisa May Alcott"}, - "Value.1": []string{"Florence Nightingale"}, - "Value.2": []string{"Clara Barton"}, + "Value.0": {"Louisa May Alcott"}, + "Value.1": {"Florence Nightingale"}, + "Value.2": {"Clara Barton"}, } s := S15{} @@ -1434,7 +1463,7 @@ type S18 struct { func TestCustomType(t *testing.T) { data := map[string][]string{ - "Value": []string{"Louisa May Alcott"}, + "Value": {"Louisa May Alcott"}, } s := S17{} @@ -1451,7 +1480,7 @@ func TestCustomType(t *testing.T) { func TestCustomTypeWithError(t *testing.T) { data := map[string][]string{ - "Value": []string{"Louisa"}, + "Value": {"Louisa"}, } s := S17{} @@ -1464,7 +1493,7 @@ func TestCustomTypeWithError(t *testing.T) { func TestNoTextUnmarshalerType(t *testing.T) { data := map[string][]string{ - "Value": []string{"Louisa May Alcott"}, + "Value": {"Louisa May Alcott"}, } s := S18{} @@ -1477,9 +1506,9 @@ func TestNoTextUnmarshalerType(t *testing.T) { func TestExpectedType(t *testing.T) { data := map[string][]string{ - "bools": []string{"1", "a"}, - "date": []string{"invalid"}, - "Foo.Bar": []string{"a", "b"}, + "bools": {"1", "a"}, + "date": {"invalid"}, + "Foo.Bar": {"a", "b"}, } type B struct { @@ -1524,11 +1553,11 @@ type R1 struct { func TestRequiredField(t *testing.T) { var a R1 v := map[string][]string{ - "a": []string{"bbb"}, - "b.c": []string{"88"}, - "b.d": []string{"9"}, - "f": []string{""}, - "h": []string{"true"}, + "a": {"bbb"}, + "b.c": {"88"}, + "b.d": {"9"}, + "f": {""}, + "h": {"true"}, } err := NewDecoder().Decode(&a, v) if err == nil { @@ -1595,7 +1624,7 @@ type R2 struct { func TestRequiredStructFiled(t *testing.T) { v := map[string][]string{ - "a.b": []string{"3"}, + "a.b": {"3"}, } var a R2 err := NewDecoder().Decode(&a, v) @@ -1604,6 +1633,23 @@ func TestRequiredStructFiled(t *testing.T) { } } +type Node struct { + Value int `schema:"val,required"` + Next *Node `schema:"next,required"` +} + +func TestRecursiveStruct(t *testing.T) { + v := map[string][]string{ + "val": {"1"}, + "next.val": {"2"}, + } + var a Node + err := NewDecoder().Decode(&a, v) + if err != nil { + t.Errorf("error: %v", err) + } +} + func TestRequiredFieldIsMissingCorrectError(t *testing.T) { type RM1S struct { A string `schema:"rm1aa,required"` @@ -1927,7 +1973,7 @@ func (s *S20) UnmarshalText(text []byte) error { // implementations by its elements. func TestTextUnmarshalerTypeSlice(t *testing.T) { data := map[string][]string{ - "Value": []string{"a,b,c"}, + "Value": {"a,b,c"}, } s := struct { Value S20 @@ -1963,7 +2009,7 @@ type S21B []S21E // requirements imposed on a slice of structs. func TestTextUnmarshalerTypeSliceOfStructs(t *testing.T) { data := map[string][]string{ - "Value": []string{"raw a"}, + "Value": {"raw a"}, } // Implements encoding.TextUnmarshaler, should not throw invalid path // error. @@ -2001,7 +2047,7 @@ func (s *S22) UnmarshalText(text []byte) error { // especially including simply setting the zero value. func TestTextUnmarshalerEmpty(t *testing.T) { data := map[string][]string{ - "Value": []string{""}, // empty value + "Value": {""}, // empty value } // Implements encoding.TextUnmarshaler, should use the type's // UnmarshalText method. @@ -2032,8 +2078,8 @@ type S23 []*S23e func TestUnmashalPointerToEmbedded(t *testing.T) { data := map[string][]string{ - "A.0.F2": []string{"raw a"}, - "A.0.F3": []string{"raw b"}, + "A.0.F2": {"raw a"}, + "A.0.F3": {"raw b"}, } // Implements encoding.TextUnmarshaler, should not throw invalid path @@ -2122,7 +2168,6 @@ func TestDoubleEmbedded(t *testing.T) { if !reflect.DeepEqual(expected, s) { t.Errorf("Expected %v errors, got %v", expected, s) } - } func TestDefaultValuesAreSet(t *testing.T) { @@ -2280,7 +2325,6 @@ func TestRequiredFieldsCannotHaveDefaults(t *testing.T) { if err == nil || !strings.Contains(err.Error(), expected) { t.Errorf("decoding should fail with error msg %s got %q", expected, err) } - } func TestInvalidDefaultElementInSliceRaiseError(t *testing.T) { @@ -2337,7 +2381,7 @@ func TestInvalidDefaultsValuesHaveNoEffect(t *testing.T) { type D struct { B bool `schema:"b,default:invalid"` C *float32 `schema:"c,default:notAFloat"` - //uint types + // uint types D uint `schema:"d,default:notUint"` E uint8 `schema:"e,default:notUint"` F uint16 `schema:"f,default:notUint"` @@ -2376,7 +2420,6 @@ func TestInvalidDefaultsValuesHaveNoEffect(t *testing.T) { decoder := NewDecoder() err := decoder.Decode(&d, data) - if err != nil { t.Errorf("decoding should succeed but got error: %q", err) } @@ -2413,6 +2456,23 @@ func TestDefaultsAreNotSupportedForStructsAndStructSlices(t *testing.T) { } } +func TestDefaultValueWithColon(t *testing.T) { + t.Parallel() + type D struct { + URL string `schema:"url,default:http://localhost:8080"` + } + + var d D + decoder := NewDecoder() + if err := decoder.Decode(&d, map[string][]string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if d.URL != "http://localhost:8080" { + t.Errorf("expected default url to be http://localhost:8080, got %s", d.URL) + } +} + func TestDecoder_MaxSize(t *testing.T) { t.Parallel() @@ -2507,7 +2567,6 @@ func TestDecoder_MaxSize(t *testing.T) { } func TestDecoder_SetMaxSize(t *testing.T) { - t.Run("default maxsize should be equal to given constant", func(t *testing.T) { t.Parallel() dec := NewDecoder() @@ -2527,3 +2586,672 @@ func TestDecoder_SetMaxSize(t *testing.T) { } }) } + +func TestTimeDurationDecoding(t *testing.T) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout"` + } + + // Prepare the input data + input := map[string][]string{ + "timeout": {"2s"}, + } + + // Create a decoder with a converter for time.Duration + decoder := NewDecoder() + decoder.RegisterConverter(time.Duration(0), func(s string) reflect.Value { + d, err := time.ParseDuration(s) + if err != nil { + return reflect.Value{} + } + return reflect.ValueOf(d) + }) + + var result DurationStruct + err := decoder.Decode(&result, input) + if err != nil { + t.Fatalf("Failed to decode duration: %v", err) + } + + // Expect 2 seconds + if result.Timeout != 2*time.Second { + t.Errorf("Expected 2s, got %v", result.Timeout) + } +} + +func TestTimeDurationDecodingInvalid(t *testing.T) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout"` + } + + // Prepare the input data + input := map[string][]string{ + "timeout": {"invalid-duration"}, + } + + // Create a decoder with a converter for time.Duration + decoder := NewDecoder() + decoder.RegisterConverter(time.Duration(0), func(s string) reflect.Value { + // Attempt to parse the duration + d, err := time.ParseDuration(s) + if err != nil { + // Return an invalid reflect.Value to trigger a conversion error + return reflect.Value{} + } + return reflect.ValueOf(d) + }) + + var result DurationStruct + err := decoder.Decode(&result, input) + if err == nil { + t.Error("Expected an error decoding invalid duration, got nil") + } +} + +func TestMultipleConversionErrors(t *testing.T) { + type Fields struct { + IntField int `schema:"int_field"` + BoolField bool `schema:"bool_field"` + Duration time.Duration `schema:"duration_field"` + } + + input := map[string][]string{ + "int_field": {"invalid-int"}, + "bool_field": {"invalid-bool"}, + "duration_field": {"invalid-duration"}, + } + + decoder := NewDecoder() + decoder.RegisterConverter(time.Duration(0), func(s string) reflect.Value { + d, err := time.ParseDuration(s) + if err != nil { + return reflect.Value{} + } + return reflect.ValueOf(d) + }) + + var s Fields + err := decoder.Decode(&s, input) + if err == nil { + t.Fatal("Expected multiple conversion errors, got nil") + } + + // Check that all errors are reported (at least 3). + mErr, ok := err.(MultiError) + if !ok { + t.Fatalf("Expected MultiError, got %T", err) + } + if len(mErr) < 3 { + t.Errorf("Expected at least 3 errors, got %d: %v", len(mErr), mErr) + } +} + +func TestDecoderMultipartFiles(t *testing.T) { + type S struct { + A string `schema:"a,required"` + B int `schema:"b,required"` + C bool `schema:"c,required"` + D struct { + E float64 `schema:"e,required"` + F *multipart.FileHeader `schema:"f,required"` + F2 []*multipart.FileHeader `schema:"f2,required"` + F3 *[]*multipart.FileHeader `schema:"f3,required"` + F4 *multipart.FileHeader `schema:"f4,required"` + } `schema:"d,required"` + G *[]*multipart.FileHeader `schema:"g,required"` + J []struct { + K *[]*multipart.FileHeader `schema:"k,required"` + } `schema:"j,required"` + } + s := S{} + data := map[string][]string{ + "a": {"abc"}, + "b": {"123"}, + "c": {"true"}, + "d.e": {"3.14"}, + } + + // Create dummy file headers for testing + dummyFile := &multipart.FileHeader{ + Filename: "test.txt", + Size: 4, + } + + dummyFile2 := &multipart.FileHeader{ + Filename: "test2.txt", + Size: 4, + } + + dummyFile3 := &multipart.FileHeader{ + Filename: "test3.txt", + Size: 4, + } + + // Create slice for file headers + fileHeaders := map[string][]*multipart.FileHeader{ + "d.f": {dummyFile, dummyFile2}, + "d.f2": {dummyFile2, dummyFile3}, + "d.f3": {dummyFile, dummyFile2, dummyFile3}, + "d.f4": {}, + "g": {dummyFile, dummyFile2}, + "j.0.k": {dummyFile, dummyFile2}, + "j.1.k": {dummyFile2, dummyFile3}, + } + + decoder := NewDecoder() + err := decoder.Decode(&s, data, fileHeaders) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + if s.A != "abc" { + t.Errorf("Expected A to be 'abc', got %s", s.A) + } + + if s.B != 123 { + t.Errorf("Expected B to be 123, got %d", s.B) + } + + if s.C != true { + t.Errorf("Expected C to be true, got %t", s.C) + } + + if s.D.E != 3.14 { + t.Errorf("Expected D.E to be 3.14, got %f", s.D.E) + } + + if s.D.F == nil { + t.Error("Expected D.F to be a file header, got nil") + } + + if s.D.F2 == nil { + t.Error("Expected D.F2 to be a slice of file headers, got nil") + } + + if s.D.F3 == nil { + t.Error("Expected D.F3 to be a pointer to a slice of file headers, got nil") + } + + if s.D.F4 != nil { + fmt.Print(s.D.F4) + t.Error("Expected D.F4 to be nil, got a file header") + } + + if s.G == nil { + t.Error("Expected G to be a pointer to a slice of file headers, got nil") + } + + if len(s.D.F2) != 2 { + t.Errorf("Expected D.F2 to have 2 file headers, got %d", len(s.D.F2)) + } + + if len(*s.D.F3) != 3 { + t.Errorf("Expected D.F3 to have 3 file headers, got %d", len(*s.D.F3)) + } + + if len(*s.G) != 2 { + t.Errorf("Expected G to have 2 file headers, got %d", len(*s.G)) + } + + if s.D.F.Filename != "test.txt" { + t.Errorf("Expected D.F.Filename to be 'test.txt', got %s", s.D.F.Filename) + } + + if s.D.F2[0].Filename != "test2.txt" { + t.Errorf("Expected D.F2[0].Filename to be 'test2.txt', got %s", s.D.F2[0].Filename) + } + + if s.D.F2[1].Filename != "test3.txt" { + t.Errorf("Expected D.F2[1].Filename to be 'test3.txt', got %s", s.D.F2[1].Filename) + } + + if (*s.D.F3)[0].Filename != "test.txt" { + t.Errorf("Expected D.F3[0].Filename to be 'test.txt', got %s", (*s.D.F3)[0].Filename) + } + + if (*s.D.F3)[1].Filename != "test2.txt" { + t.Errorf("Expected D.F3[1].Filename to be 'test2.txt', got %s", (*s.D.F3)[1].Filename) + } + + if (*s.D.F3)[2].Filename != "test3.txt" { + t.Errorf("Expected D.F3[2].Filename to be 'test3.txt', got %s", (*s.D.F3)[2].Filename) + } + + if (*s.G)[0].Filename != "test.txt" { + t.Errorf("Expected G[0].Filename to be 'test.txt', got %s", (*s.G)[0].Filename) + } + + if (*s.G)[1].Filename != "test2.txt" { + t.Errorf("Expected G[1].Filename to be 'test2.txt', got %s", (*s.G)[1].Filename) + } + + if s.J[0].K == nil { + t.Error("Expected J[0].K to be a pointer to a slice of file headers, got nil") + } + + if s.J[1].K == nil { + t.Error("Expected J[1].K to be a pointer to a slice of file headers, got nil") + } + + if len(*s.J[0].K) != 2 { + t.Errorf("Expected J[0].K to have 2 file headers, got %d", len(*s.J[0].K)) + } + + if len(*s.J[1].K) != 2 { + t.Errorf("Expected J[1].K to have 2 file headers, got %d", len(*s.J[1].K)) + } + + if (*s.J[0].K)[0].Filename != "test.txt" { + t.Errorf("Expected J[0].K[0].Filename to be 'test.txt', got %s", (*s.J[0].K)[0].Filename) + } + + if (*s.J[0].K)[1].Filename != "test2.txt" { + t.Errorf("Expected J[0].K[1].Filename to be 'test2.txt', got %s", (*s.J[0].K)[1].Filename) + } + + if (*s.J[1].K)[0].Filename != "test2.txt" { + t.Errorf("Expected J[1].K[0].Filename to be 'test2.txt', got %s", (*s.J[1].K)[0].Filename) + } + + if (*s.J[1].K)[1].Filename != "test3.txt" { + t.Errorf("Expected J[1].K[1].Filename to be 'test3.txt', got %s", (*s.J[1].K)[1].Filename) + } +} + +func BenchmarkDecoderMultipartFiles(b *testing.B) { + type S struct { + A string `schema:"a,required"` + B int `schema:"b,required"` + C bool `schema:"c,required"` + D struct { + E float64 `schema:"e,required"` + F *multipart.FileHeader `schema:"f,required"` + F2 []*multipart.FileHeader `schema:"f2,required"` + } `schema:"d,required"` + G *[]*multipart.FileHeader `schema:"g,required"` + } + s := S{} + data := map[string][]string{ + "a": {"abc"}, + "b": {"123"}, + "c": {"true"}, + "d.e": {"3.14"}, + } + + // Create dummy file headers for testing + dummyFile := &multipart.FileHeader{ + Filename: "test.txt", + Size: 4, + } + + dummyFile2 := &multipart.FileHeader{ + Filename: "test2.txt", + Size: 4, + } + + dummyFile3 := &multipart.FileHeader{ + Filename: "test3.txt", + Size: 4, + } + + // Create slice for file headers + fileHeaders := map[string][]*multipart.FileHeader{ + "d.f": {dummyFile, dummyFile2}, + "d.f2": {dummyFile2, dummyFile3}, + "g": {dummyFile, dummyFile2}, + } + + decoder := NewDecoder() + b.ResetTimer() + + var err error + for i := 0; i < b.N; i++ { + err = decoder.Decode(&s, data, fileHeaders) + } + + if err != nil { + b.Fatalf("Failed to decode: %v", err) + } +} + +func TestIsMultipartFile(t *testing.T) { + t.Parallel() + + tc := []struct { + typ reflect.Type + input map[string][]string + expected bool + }{ + { + typ: reflect.TypeOf(string("")), + expected: false, + }, + { + typ: reflect.TypeOf([]string{}), + expected: false, + }, + { + typ: reflect.TypeOf([]*multipart.FileHeader{}), + expected: true, + }, + { + typ: reflect.TypeOf(multipart.FileHeader{}), + expected: false, + }, + { + typ: reflect.TypeOf(&multipart.FileHeader{}), + expected: true, + }, + { + typ: reflect.TypeOf([]multipart.FileHeader{}), + expected: false, + }, + { + typ: reflect.TypeOf(&[]*multipart.FileHeader{}), + expected: true, + }, + } + + for _, tt := range tc { + if isMultipartField(tt.typ) != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, isMultipartField(tt.typ)) + } + } +} + +func BenchmarkIsMultipartFile(b *testing.B) { + cases := []struct { + typ reflect.Type + }{ + { + typ: reflect.TypeOf(string("")), + }, + { + typ: reflect.TypeOf([]string{}), + }, + { + typ: reflect.TypeOf([]*multipart.FileHeader{}), + }, + { + typ: reflect.TypeOf(multipart.FileHeader{}), + }, + { + typ: reflect.TypeOf(&multipart.FileHeader{}), + }, + { + typ: reflect.TypeOf([]multipart.FileHeader{}), + }, + { + typ: reflect.TypeOf(&[]*multipart.FileHeader{}), + }, + } + + for i, bc := range cases { + b.Run(fmt.Sprintf("IsMultipartFile-%d", i), func(b *testing.B) { + for i := 0; i < b.N; i++ { + isMultipartField(bc.typ) + } + }) + } +} + +func TestHandleMultipartField(t *testing.T) { + t.Parallel() + + // Create dummy file headers for testing + dummyFile := &multipart.FileHeader{ + Filename: "test.txt", + Size: 4, + } + + files := map[string][]*multipart.FileHeader{ + "f": {dummyFile}, + } + + type S struct { + F *multipart.FileHeader `schema:"f,required"` + F2 []*multipart.FileHeader `schema:"f2,required"` + F3 *[]*multipart.FileHeader `schema:"f3,required"` + F4 string `schema:"f4,required"` + } + + s := S{} + rv := reflect.ValueOf(&s).Elem() + + ok := handleMultipartField(rv.FieldByName("F"), files["f"]) + if !ok { + t.Error("Expected handleMultipartField to return true") + } + + ok = handleMultipartField(rv.FieldByName("F2"), files["f"]) + if !ok { + t.Error("Expected handleMultipartField to return true") + } + + ok = handleMultipartField(rv.FieldByName("F3"), files["f"]) + if !ok { + t.Error("Expected handleMultipartField to return true") + } + + ok = handleMultipartField(rv.FieldByName("F4"), files["f"]) + if ok { + t.Error("Expected handleMultipartField to return false") + } + + if s.F == nil { + t.Error("Expected F to be a file header, got nil") + } + + if s.F2 == nil { + t.Error("Expected F2 to be a slice of file headers, got nil") + } + + if s.F3 == nil { + t.Error("Expected F3 to be a pointer to a slice of file headers, got nil") + } + + if len(s.F2) != 1 { + t.Errorf("Expected F2 to have 1 file header, got %d", len(s.F2)) + } + + if len(*s.F3) != 1 { + t.Errorf("Expected F3 to have 1 file header, got %d", len(*s.F3)) + } + + if s.F.Filename != "test.txt" { + t.Errorf("Expected F.Filename to be 'test.txt', got %s", s.F.Filename) + } + + if s.F2[0].Filename != "test.txt" { + t.Errorf("Expected F2[0].Filename to be 'test.txt', got %s", s.F2[0].Filename) + } + + if (*s.F3)[0].Filename != "test.txt" { + t.Errorf("Expected F3[0].Filename to be 'test.txt', got %s", (*s.F3)[0].Filename) + } +} + +func TestDecodePanicIsCaughtAndReturnedAsError(t *testing.T) { + type R struct { + N1 []*struct { + Value string + } + } + // Simulate a path that uses an invalid (e.g. negative) slice index, + // which can trigger a panic (e.g. reflect: slice index out of range). + data := map[string][]string{ + "n1.-1.value": {"Foo"}, + } + + s := new(R) + decoder := NewDecoder() + err := decoder.Decode(s, data) + if err == nil { + t.Fatal("Expected an error when a panic occurs") + } + + expected := "schema: panic while decoding: reflect: slice index out of range" + if err.Error() != expected { + t.Fatalf("Expected panic error message %q, got: %v", expected, err) + } +} + +func BenchmarkHandleMultipartField(b *testing.B) { + // Create dummy file headers for testing + dummyFile := &multipart.FileHeader{ + Filename: "test.txt", + Size: 4, + } + + files := map[string][]*multipart.FileHeader{ + "f": {dummyFile}, + } + + type S struct { + F *multipart.FileHeader `schema:"f,required"` + F2 []*multipart.FileHeader `schema:"f2,required"` + F3 *[]*multipart.FileHeader `schema:"f3,required"` + F4 string `schema:"f4,required"` + } + + s := S{} + rv := reflect.ValueOf(&s).Elem() + + f := rv.FieldByName("F") + f2 := rv.FieldByName("F2") + f3 := rv.FieldByName("F3") + f4 := rv.FieldByName("F4") + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + handleMultipartField(f, files["f"]) + handleMultipartField(f2, files["f"]) + handleMultipartField(f3, files["f"]) + handleMultipartField(f4, files["f"]) + } +} + +func BenchmarkLargeStructDecode(b *testing.B) { + data := map[string][]string{ + "f1": {"Lorem"}, + "f2": {"Ipsum"}, + "f3": {"123"}, + "f4": {"456"}, + "f5": {"A", "B", "C", "D"}, + "f6": {"10", "20", "30", "40"}, + "f7": {"3.14159"}, + "f8": {"true"}, + "f9.n2": {"NestedStringValue"}, + } + + decoder := NewDecoder() + s := &LargeStructForBenchmark{} + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = decoder.Decode(s, data) + } +} + +func BenchmarkLargeStructDecodeParallel(b *testing.B) { + data := map[string][]string{ + "f1": {"Lorem"}, + "f2": {"Ipsum"}, + "f3": {"123"}, + "f4": {"456"}, + "f5": {"A", "B", "C", "D"}, + "f6": {"10", "20", "30", "40"}, + "f7": {"3.14159"}, + "f8": {"true"}, + "f9.n2": {"NestedStringValue"}, + } + + decoder := NewDecoder() + s := &LargeStructForBenchmark{} + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = decoder.Decode(s, data) + } + }) +} + +func BenchmarkSimpleStructDecode(b *testing.B) { + type S struct { + A string `schema:"a"` + B int `schema:"b"` + C bool `schema:"c"` + D float64 `schema:"d"` + E struct { + F float64 `schema:"f"` + } `schema:"e"` + } + s := S{} + data := map[string][]string{ + "a": {"abc"}, + "b": {"123"}, + "c": {"true"}, + "d": {"3.14"}, + "e.f": {"3.14"}, + } + decoder := NewDecoder() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = decoder.Decode(&s, data) + } +} + +func BenchmarkCheckRequiredFields(b *testing.B) { + type S struct { + A string `schema:"a,required"` + B int `schema:"b,required"` + C bool `schema:"c,required"` + D struct { + E float64 `schema:"e,required"` + } `schema:"d,required"` + } + s := S{} + data := map[string][]string{ + "a": {"abc"}, + "b": {"123"}, + "c": {"true"}, + "d.e": {"3.14"}, + } + decoder := NewDecoder() + b.ResetTimer() + + v := reflect.ValueOf(s) + // v = v.Elem() + t := v.Type() + + for i := 0; i < b.N; i++ { + _ = decoder.checkRequired(t, data) + } +} + +func BenchmarkTimeDurationDecoding(b *testing.B) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout"` + } + + // Sample input for decoding + input := map[string][]string{ + "timeout": {"2s"}, + } + + decoder := NewDecoder() + decoder.RegisterConverter(time.Duration(0), func(s string) reflect.Value { + d, _ := time.ParseDuration(s) + return reflect.ValueOf(d) + }) + + var ds DurationStruct + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = decoder.Decode(&ds, input) + } +} diff --git a/doc.go b/doc.go index aae9f33..50faaac 100644 --- a/doc.go +++ b/doc.go @@ -3,7 +3,7 @@ // license that can be found in the LICENSE file. /* -Package gorilla/schema fills a struct with form values. +Package gofiber/schema fills a struct with form values. The basic usage is really simple. Given this struct: @@ -60,14 +60,14 @@ certain fields, use a dash for the name and it will be ignored: The supported field types in the destination struct are: - * bool - * float variants (float32, float64) - * int variants (int, int8, int16, int32, int64) - * string - * uint variants (uint, uint8, uint16, uint32, uint64) - * struct - * a pointer to one of the above types - * a slice or a pointer to a slice of one of the above types + - bool + - float variants (float32, float64) + - int variants (int, int8, int16, int32, int64) + - string + - uint variants (uint, uint8, uint16, uint32, uint64) + - struct + - a pointer to one of the above types + - a slice or a pointer to a slice of one of the above types Non-supported types are simply ignored, however custom types can be registered to be converted. diff --git a/encoder_test.go b/encoder_test.go index 092f0de..df3872b 100644 --- a/encoder_test.go +++ b/encoder_test.go @@ -24,6 +24,16 @@ type inner struct { F12 int } +type SimpleStructForBenchmarkEncode struct { + A string `schema:"a"` + B int `schema:"b"` + C bool `schema:"c"` + D float64 `schema:"d"` + E struct { + F float64 `schema:"f"` + } `schema:"e"` +} + func TestFilled(t *testing.T) { f07 := "seven" var f08 int8 = 8 @@ -523,3 +533,265 @@ func TestRegisterEncoderWithPtrType(t *testing.T) { valExists(t, "DateStart", ss.DateStart.time.String(), vals) valExists(t, "DateEnd", "", vals) } + +func TestTimeDurationEncoding(t *testing.T) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout"` + } + + vals := map[string][]string{} + testData := DurationStruct{ + Timeout: 3 * time.Minute, + } + + enc := NewEncoder() + enc.RegisterEncoder(time.Duration(0), func(v reflect.Value) string { + d := v.Interface().(time.Duration) + return d.String() // "3m0s" + }) + + err := enc.Encode(&testData, vals) + if err != nil { + t.Fatalf("Failed to encode time.Duration: %v", err) + } + + got, ok := vals["timeout"] + if !ok || len(got) < 1 { + t.Fatalf("Encoded map missing key 'timeout'") + } + if got[0] != (3 * time.Minute).String() { + t.Errorf("Expected %q, got %q", (3 * time.Minute).String(), got[0]) + } +} + +// Test for omitempty with zero time.Duration. +func TestTimeDurationOmitEmpty(t *testing.T) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout,omitempty"` + } + + vals := map[string][]string{} + testData := DurationStruct{ + Timeout: 0, + } + + enc := NewEncoder() + enc.RegisterEncoder(time.Duration(0), func(v reflect.Value) string { + return v.Interface().(time.Duration).String() + }) + + err := enc.Encode(&testData, vals) + if err != nil { + t.Fatalf("Failed to encode time.Duration: %v", err) + } + // Should be omitted since 0 for time.Duration is "zero" and tagged as omitempty + if _, found := vals["timeout"]; found { + t.Errorf("Expected 'timeout' to be omitted, but it was present: %v", vals["timeout"]) + } +} + +func TestEncoderZeroAndNonZeroFields(t *testing.T) { + type ZeroTestStruct struct { + A string `schema:"a,omitempty"` + B int `schema:"b,omitempty"` + C float64 `schema:"c,omitempty"` + D bool `schema:"d,omitempty"` + E *int `schema:"e,omitempty"` + F *string `schema:"f,omitempty"` + G string `schema:"g"` // no omitempty + } + + vals := map[string][]string{} + intVal := 42 + strVal := "Hello" + s := ZeroTestStruct{ + A: "", + B: 0, + C: 0.0, + D: false, + E: &intVal, + F: &strVal, + G: "MustEncode", + } + + enc := NewEncoder() + err := enc.Encode(&s, vals) + if err != nil { + t.Fatalf("Encoding error: %v", err) + } + + // Fields A, B, C, D are zero and should be omitted + if _, found := vals["a"]; found { + t.Errorf("Expected 'a' to be omitted for zero string") + } + if _, found := vals["b"]; found { + t.Errorf("Expected 'b' to be omitted for zero int") + } + if _, found := vals["c"]; found { + t.Errorf("Expected 'c' to be omitted for zero float") + } + if _, found := vals["d"]; found { + t.Errorf("Expected 'd' to be omitted for false bool") + } + + // E is a pointer to an int, so it should appear + gotE, found := vals["e"] + if !found { + t.Error("Expected 'e' to be present") + } else if len(gotE) != 1 || gotE[0] != "42" { + t.Errorf("Expected '42', got %v", gotE) + } + + // F is a pointer to string, so it should appear + gotF, found := vals["f"] + if !found { + t.Error("Expected 'f' to be present") + } else if len(gotF) != 1 || gotF[0] != "Hello" { + t.Errorf("Expected 'Hello', got %v", gotF) + } + + // G has no omitempty tag and must be encoded + gotG, found := vals["g"] + if !found { + t.Error("Expected 'g' to be present") + } else if len(gotG) != 1 || gotG[0] != "MustEncode" { + t.Errorf("Expected 'MustEncode', got %v", gotG) + } +} + +func BenchmarkSimpleStructEncode(b *testing.B) { + s := SimpleStructForBenchmarkEncode{ + A: "abc", + B: 123, + C: true, + D: 3.14, + E: struct { + F float64 `schema:"f"` + }{F: 6.28}, + } + enc := NewEncoder() + + vals := map[string][]string{} + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = enc.Encode(&s, vals) + } +} + +func BenchmarkSimpleStructEncodeParallel(b *testing.B) { + s := SimpleStructForBenchmarkEncode{ + A: "abc", + B: 123, + C: true, + D: 3.14, + E: struct { + F float64 `schema:"f"` + }{F: 6.28}, + } + enc := NewEncoder() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + vals := map[string][]string{} + for pb.Next() { + _ = enc.Encode(&s, vals) + } + }) +} + +type LargeStructForBenchmarkEncode struct { + F1 string `schema:"f1"` + F2 string `schema:"f2"` + F3 int `schema:"f3"` + F4 int `schema:"f4"` + F5 []string `schema:"f5"` + F6 []int `schema:"f6"` + F7 float64 `schema:"f7"` + F8 bool `schema:"f8"` + F9 struct { + N1 time.Time `schema:"n1"` + N2 string `schema:"n2"` + } `schema:"f9"` +} + +func BenchmarkLargeStructEncode(b *testing.B) { + s := LargeStructForBenchmarkEncode{ + F1: "Lorem", F2: "Ipsum", F3: 123, F4: 456, + F5: []string{"A", "B", "C", "D"}, + F6: []int{10, 20, 30, 40}, + F7: 3.14159, F8: true, + F9: struct { + N1 time.Time `schema:"n1"` + N2 string `schema:"n2"` + }{ + N1: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + N2: "NestedStringValue", + }, + } + enc := NewEncoder() + + // Optionally register a custom encoder for time.Time + enc.RegisterEncoder(time.Time{}, func(v reflect.Value) string { + tVal := v.Interface().(time.Time) + return tVal.Format(time.RFC3339) + }) + + vals := map[string][]string{} + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = enc.Encode(&s, vals) + } +} + +func BenchmarkLargeStructEncodeParallel(b *testing.B) { + s := LargeStructForBenchmarkEncode{ + F1: "Lorem", F2: "Ipsum", F3: 123, F4: 456, + F5: []string{"A", "B", "C", "D"}, + F6: []int{10, 20, 30, 40}, + F7: 3.14159, F8: true, + F9: struct { + N1 time.Time `schema:"n1"` + N2 string `schema:"n2"` + }{ + N1: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + N2: "NestedStringValue", + }, + } + enc := NewEncoder() + enc.RegisterEncoder(time.Time{}, func(v reflect.Value) string { + tVal := v.Interface().(time.Time) + return tVal.Format(time.RFC3339) + }) + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + vals := map[string][]string{} + for pb.Next() { + _ = enc.Encode(&s, vals) + } + }) +} + +func BenchmarkTimeDurationEncoding(b *testing.B) { + type DurationStruct struct { + Timeout time.Duration `schema:"timeout"` + } + + testData := DurationStruct{ + Timeout: 5 * time.Second, + } + + enc := NewEncoder() + enc.RegisterEncoder(time.Duration(0), func(v reflect.Value) string { + return v.Interface().(time.Duration).String() + }) + + vals := map[string][]string{} + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = enc.Encode(&testData, vals) + } +} diff --git a/go.mod b/go.mod index c18d1bd..0e70784 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/gorilla/schema +module github.com/gofiber/schema -go 1.20 +go 1.22