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..2879cb9 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] + 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.1.2 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 \ No newline at end of file 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..2a29d13 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ coverage.coverprofile +vendor diff --git a/Makefile b/Makefile index 98f5ab7..669b3fb 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=. -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/decoder.go b/decoder.go index 54c88ec..4523a83 100644 --- a/decoder.go +++ b/decoder.go @@ -252,7 +252,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 } } @@ -359,7 +369,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 +523,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..d3d5c5e 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -56,6 +56,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 +159,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}}, }, }, }, @@ -598,8 +624,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 +967,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 +1315,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 +1347,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 +1394,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 +1422,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 +1437,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 +1462,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 +1479,7 @@ func TestCustomType(t *testing.T) { func TestCustomTypeWithError(t *testing.T) { data := map[string][]string{ - "Value": []string{"Louisa"}, + "Value": {"Louisa"}, } s := S17{} @@ -1464,7 +1492,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 +1505,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 +1552,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 +1623,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 +1632,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 +1972,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 +2008,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 +2046,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 +2077,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 +2167,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 +2324,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 +2380,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 +2419,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) } @@ -2507,7 +2549,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 +2568,247 @@ 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 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.n1": {"2025-01-01T12:00:00Z"}, + "f9.n2": {"NestedStringValue"}, + } + + decoder := NewDecoder() + decoder.RegisterConverter(time.Time{}, func(s string) reflect.Value { + tm, err := time.Parse(time.RFC3339, s) + if err != nil { + return reflect.Value{} + } + return reflect.ValueOf(tm) + }) + + 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.n1": {"2025-01-01T12:00:00Z"}, + "f9.n2": {"NestedStringValue"}, + } + + decoder := NewDecoder() + decoder.RegisterConverter(time.Time{}, func(s string) reflect.Value { + tm, err := time.Parse(time.RFC3339, s) + if err != nil { + return reflect.Value{} + } + return reflect.ValueOf(tm) + }) + + 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() + var errs MultiError + for i := 0; i < b.N; i++ { + errs = decoder.checkRequired(t, data) + } + + if len(errs) != 0 { + b.Fatalf("unexpected errors: %v", errs) + } +} + +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) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var ds DurationStruct + _ = 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