diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 864787ca..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,49 +0,0 @@ -- With issues: - - Use the search tool before opening a new issue. - - Please provide source code and commit sha if you found a bug. - - Review existing issues and provide feedback or react to them. - -## Description - - - -## How to reproduce - - -``` -package main - -import ( - "github.com/gin-gonic/gin" -) - -func main() { - g := gin.Default() - g.GET("/hello/:name", func(c *gin.Context) { - c.String(200, "Hello %s", c.Param("name")) - }) - g.Run(":9000") -} -``` - -## Expectations - - -``` -$ curl http://localhost:9000/hello/world -Hello world -``` - -## Actual result - - -``` -$ curl -i http://localhost:9000/hello/world - -``` - -## Environment - -- go version: -- gin version (or commit ref): -- operating system: diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml new file mode 100644 index 00000000..2cf2f362 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -0,0 +1,60 @@ +name: Bug Report +description: Found something you weren't expecting? Report it here! +labels: ["type/bug"] +body: + - type: markdown + attributes: + value: | + NOTE: If your issue is a security concern, please send an email to appleboy.tw@gmail.com instead of opening a public issue. + - type: markdown + attributes: + value: | + 1. Please speak English, this is the language all maintainers can speak and write. + 2. Please ask questions problems on our Discussions Forum (https://github.com/gin-gonic/gin/discussions). + 3. Make sure you are using the latest release and + take a moment to check that your issue hasn't been reported before. + - type: textarea + id: description + attributes: + label: Description + description: | + Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below) + - type: input + id: gin-ver + attributes: + label: Gin Version + description: Gin version (or commit reference) of your instance + validations: + required: true + - type: dropdown + id: can-reproduce + attributes: + label: Can you reproduce the bug? + description: | + If so, please write the steps to reproduce the bug. + options: + - "Yes" + - "No" + validations: + required: true + - type: markdown + attributes: + value: | + It's really important to provide pertinent logs + Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help + In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini + - type: textarea + id: source-code + attributes: + label: Source Code + description: If this issue involves source code, please provide a minimal reproducible example + - type: input + id: go-ver + attributes: + label: Go Version + description: The version of Go running on the server + - type: input + id: os-ver + attributes: + label: Operating System + description: The operating system you are using to run Gin diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ceff9fe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Go.dev API Documentation + url: https://pkg.go.dev/github.com/gin-gonic/gin + about: Comprehensive API documentation for Gin. + - name: Gin User Guides + url: https://gin-gonic.com/ + about: In-depth user guides and tutorials for using Gin. + - name: Discussions Forum + url: https://github.com/gin-gonic/gin/discussions + about: Questions and configuration or deployment problems can also be discussed. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 00000000..a40215aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,18 @@ +name: Feature Request +description: Got an idea for a feature that Gin doesn't have currently? Submit your idea here! +labels: ["type/proposal"] +body: + - type: markdown + attributes: + value: | + 1. Please speak English, this is the language all maintainers can speak and write. + 2. Please ask questions problems on our Discussions Forum (https://github.com/gin-gonic/gin/discussions). + 3. Please take a moment to check that your feature hasn't already been suggested. + - type: textarea + id: description + attributes: + label: Feature Description + placeholder: | + I think it would be great if Gin had... + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 96e70bba..846c04fb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,10 @@ -- With pull requests: - - Open your pull request against `master` - - Your pull request should have no more than two commits, if not you should squash them. - - It should pass all tests in the available continuous integration systems such as GitHub Actions. - - You should add/modify tests to cover your proposed code changes. - - If your pull request contains a new feature, please document it on the README. +# Pull Request Checklist +Please ensure your pull request meets the following requirements: + +- [ ] Open your pull request against the `master` branch. +- [ ] All tests pass in available continuous integration systems (e.g., GitHub Actions). +- [ ] Tests are added or modified as needed to cover code changes. +- [ ] If the pull request introduces a new feature, the feature is documented in the `docs/doc.md`. + +Thank you for contributing! diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 632e8eb2..ab644980 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,14 @@ version: 2 updates: - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - package-ecosystem: gomod directory: / schedule: - interval: weekly + interval: daily + - package-ecosystem: github-actions + directory: / + groups: + actions: + patterns: + - "*" + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9a4c40d7..9ec3700e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,11 +33,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -46,4 +46,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/gin.yml b/.github/workflows/gin.yml index 947abf9c..8bca364d 100644 --- a/.github/workflows/gin.yml +++ b/.github/workflows/gin.yml @@ -16,26 +16,32 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "^1" - name: Setup golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: - version: v1.58.1 + version: v2.6 args: --verbose test: needs: lint strategy: matrix: os: [ubuntu-latest, macos-latest] - go: ["1.21", "1.22"] + go: ["1.24", "1.25"] test-tags: - ["", "-tags nomsgpack", '-tags "sonic avx"', "-tags go_json", "-race"] + [ + "", + "-tags nomsgpack", + '--ldflags="-checklinkname=0" -tags sonic', + "-tags go_json", + "-race", + ] include: - os: ubuntu-latest go-build: ~/.cache/go-build @@ -49,13 +55,13 @@ jobs: GOPROXY: https://proxy.golang.org steps: - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} cache: false - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.ref }} @@ -72,10 +78,6 @@ jobs: run: make test - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: flags: ${{ matrix.os }},go-${{ matrix.go }},${{ matrix.test-tags }} - - - name: Format - if: matrix.go-version == '1.22.x' - run: diff -u <(echo -n) <(gofmt -d .) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 8ae11823..37dfb5bb 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -13,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "^1" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + uses: goreleaser/goreleaser-action@v6 with: # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser @@ -29,3 +29,8 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger Go module reindex (pkg.go.dev) + run: | + echo "Triggering Go module reindex at proxy.golang.org" + curl -sSf "https://proxy.golang.org/github.com/${GITHUB_REPOSITORY,,}/@v/${GITHUB_REF_NAME}.info" diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 00000000..da31dd59 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,56 @@ +name: Trivy Security Scan + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + # Run daily at 00:00 UTC + - cron: '0 0 * * *' + workflow_dispatch: # Allow manual trigger + +permissions: + contents: read + security-events: write # Required for uploading SARIF results + +jobs: + trivy-scan: + name: Trivy Security Scan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run Trivy vulnerability scanner (source code) + uses: aquasecurity/trivy-action@0.33.1 + with: + scan-type: 'fs' + scan-ref: '.' + scanners: 'vuln,secret,misconfig' + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH,MEDIUM' + ignore-unfixed: true + + - name: Upload Trivy results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4 + if: always() + with: + sarif_file: 'trivy-results.sarif' + + - name: Run Trivy scanner (table output for logs) + uses: aquasecurity/trivy-action@0.33.1 + if: always() + with: + scan-type: 'fs' + scan-ref: '.' + scanners: 'vuln,secret,misconfig' + format: 'table' + severity: 'CRITICAL,HIGH,MEDIUM' + ignore-unfixed: true + exit-code: '1' diff --git a/.golangci.yml b/.golangci.yml index 8d58c989..f0898565 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,63 +1,75 @@ -run: - timeout: 5m +version: "2" linters: enable: - asciicheck + - copyloopvar - dogsled - durationcheck - - errcheck - errorlint - - exportloopref - - gci - - gofmt - - goimports - gosec - misspell - nakedret - nilerr - nolintlint + - perfsprint - revive - testifylint + - usestdlibvars - wastedassign - -linters-settings: - gosec: - # To select a subset of rules to run. - # Available rules: https://github.com/securego/gosec#available-rules - # Default: [] - means include all rules - includes: - - G102 - - G106 - - G108 - - G109 - - G111 - - G112 - - G201 - - G203 - testifylint: - enable-all: true - -issues: - exclude-rules: - - linters: - - structcheck - - unused - text: "`data` is unused" - - linters: - - staticcheck - text: "SA1019:" - - linters: - - revive - text: "var-naming:" - - linters: - - revive - text: "exported:" - - path: _test\.go - linters: - - gosec # security is not make sense in tests - - linters: - - revive - path: _test\.go - - path: gin.go - linters: - - gci + settings: + gosec: + excludes: + - G115 + perfsprint: + int-conversion: true + err-error: true + errorf: true + sprintf1: true + strconcat: true + testifylint: + enable-all: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - structcheck + - unused + text: '`data` is unused' + - linters: + - staticcheck + text: 'SA1019:' + - linters: + - revive + text: 'var-naming:' + - linters: + - revive + text: 'exported:' + - linters: + - gosec + path: _test\.go + - linters: + - revive + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - gofumpt + - goimports + settings: + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + exclusions: + generated: lax + paths: + - gin.go diff --git a/CHANGELOG.md b/CHANGELOG.md index de47c750..9451db39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,99 @@ # Gin ChangeLog +## Gin v1.11.0 + +### Features + +* feat(gin): Experimental support for HTTP/3 using quic-go/quic-go ([#3210](https://github.com/gin-gonic/gin/pull/3210)) +* feat(form): add array collection format in form binding ([#3986](https://github.com/gin-gonic/gin/pull/3986)), add custom string slice for form tag unmarshal ([#3970](https://github.com/gin-gonic/gin/pull/3970)) +* feat(binding): add BindPlain ([#3904](https://github.com/gin-gonic/gin/pull/3904)) +* feat(fs): Export, test and document OnlyFilesFS ([#3939](https://github.com/gin-gonic/gin/pull/3939)) +* feat(binding): add support for unixMilli and unixMicro ([#4190](https://github.com/gin-gonic/gin/pull/4190)) +* feat(form): Support default values for collections in form binding ([#4048](https://github.com/gin-gonic/gin/pull/4048)) +* feat(context): GetXxx added support for more go native types ([#3633](https://github.com/gin-gonic/gin/pull/3633)) + +### Enhancements + +* perf(context): optimize getMapFromFormData performance ([#4339](https://github.com/gin-gonic/gin/pull/4339)) +* refactor(tree): replace string(/) with "/" in node.insertChild ([#4354](https://github.com/gin-gonic/gin/pull/4354)) +* refactor(render): remove headers parameter from writeHeader ([#4353](https://github.com/gin-gonic/gin/pull/4353)) +* refactor(context): simplify "GetType()" functions ([#4080](https://github.com/gin-gonic/gin/pull/4080)) +* refactor(slice): simplify SliceValidationError Error method ([#3910](https://github.com/gin-gonic/gin/pull/3910)) +* refactor(context):Avoid using filepath.Dir twice in SaveUploadedFile ([#4181](https://github.com/gin-gonic/gin/pull/4181)) +* refactor(context): refactor context handling and improve test robustness ([#4066](https://github.com/gin-gonic/gin/pull/4066)) +* refactor(binding): use strings.Cut to replace strings.Index ([#3522](https://github.com/gin-gonic/gin/pull/3522)) +* refactor(context): add an optional permission parameter to SaveUploadedFile ([#4068](https://github.com/gin-gonic/gin/pull/4068)) +* refactor(context): verify URL is Non-nil in initQueryCache() ([#3969](https://github.com/gin-gonic/gin/pull/3969)) +* refactor(context): YAML judgment logic in Negotiate ([#3966](https://github.com/gin-gonic/gin/pull/3966)) +* tree: replace the self-defined 'min' to official one ([#3975](https://github.com/gin-gonic/gin/pull/3975)) +* context: Remove redundant filepath.Dir usage ([#4181](https://github.com/gin-gonic/gin/pull/4181)) + +### Bug Fixes + +* fix: prevent middleware re-entry issue in HandleContext ([#3987](https://github.com/gin-gonic/gin/pull/3987)) +* fix(binding): prevent duplicate decoding and add validation in decodeToml ([#4193](https://github.com/gin-gonic/gin/pull/4193)) +* fix(gin): Do not panic when handling method not allowed on empty tree ([#4003](https://github.com/gin-gonic/gin/pull/4003)) +* fix(gin): data race warning for gin mode ([#1580](https://github.com/gin-gonic/gin/pull/1580)) +* fix(context): verify URL is Non-nil in initQueryCache() ([#3969](https://github.com/gin-gonic/gin/pull/3969)) +* fix(context): YAML judgment logic in Negotiate ([#3966](https://github.com/gin-gonic/gin/pull/3966)) +* fix(context): check handler is nil ([#3413](https://github.com/gin-gonic/gin/pull/3413)) +* fix(readme): fix broken link to English documentation ([#4222](https://github.com/gin-gonic/gin/pull/4222)) +* fix(tree): Keep panic infos consistent when wildcard type build faild ([#4077](https://github.com/gin-gonic/gin/pull/4077)) + +### Build process updates / CI + +* ci: integrate Trivy vulnerability scanning into CI workflow ([#4359](https://github.com/gin-gonic/gin/pull/4359)) +* ci: support Go 1.25 in CI/CD ([#4341](https://github.com/gin-gonic/gin/pull/4341)) +* build(deps): upgrade github.com/bytedance/sonic from v1.13.2 to v1.14.0 ([#4342](https://github.com/gin-gonic/gin/pull/4342)) +* ci: add Go version 1.24 to GitHub Actions ([#4154](https://github.com/gin-gonic/gin/pull/4154)) +* build: update Gin minimum Go version to 1.21 ([#3960](https://github.com/gin-gonic/gin/pull/3960)) +* ci(lint): enable new linters (testifylint, usestdlibvars, perfsprint, etc.) ([#4010](https://github.com/gin-gonic/gin/pull/4010), [#4091](https://github.com/gin-gonic/gin/pull/4091), [#4090](https://github.com/gin-gonic/gin/pull/4090)) +* ci(lint): update workflows and improve test request consistency ([#4126](https://github.com/gin-gonic/gin/pull/4126)) + +### Dependency updates + +* chore(deps): bump google.golang.org/protobuf from 1.36.6 to 1.36.9 ([#4346](https://github.com/gin-gonic/gin/pull/4346), [#4356](https://github.com/gin-gonic/gin/pull/4356)) +* chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.1 ([#4347](https://github.com/gin-gonic/gin/pull/4347)) +* chore(deps): bump actions/setup-go from 5 to 6 ([#4351](https://github.com/gin-gonic/gin/pull/4351)) +* chore(deps): bump github.com/quic-go/quic-go from 0.53.0 to 0.54.0 ([#4328](https://github.com/gin-gonic/gin/pull/4328)) +* chore(deps): bump golang.org/x/net from 0.33.0 to 0.38.0 ([#4178](https://github.com/gin-gonic/gin/pull/4178), [#4221](https://github.com/gin-gonic/gin/pull/4221)) +* chore(deps): bump github.com/go-playground/validator/v10 from 10.20.0 to 10.22.1 ([#4052](https://github.com/gin-gonic/gin/pull/4052)) + +### Documentation updates + +* docs(changelog): update release notes for Gin v1.10.1 ([#4360](https://github.com/gin-gonic/gin/pull/4360)) +* docs: Fixing English grammar mistakes and awkward sentence structure in doc/doc.md ([#4207](https://github.com/gin-gonic/gin/pull/4207)) +* docs: update documentation and release notes for Gin v1.10.0 ([#3953](https://github.com/gin-gonic/gin/pull/3953)) +* docs: fix typo in Gin Quick Start ([#3997](https://github.com/gin-gonic/gin/pull/3997)) +* docs: fix comment and link issues ([#4205](https://github.com/gin-gonic/gin/pull/4205), [#3938](https://github.com/gin-gonic/gin/pull/3938)) +* docs: fix route group example code ([#4020](https://github.com/gin-gonic/gin/pull/4020)) +* docs(readme): add Portuguese documentation ([#4078](https://github.com/gin-gonic/gin/pull/4078)) +* docs(context): fix some function names in comment ([#4079](https://github.com/gin-gonic/gin/pull/4079)) + +--- + +## Gin v1.10.1 + +### Features + +* refactor: strengthen HTTPS security and improve code organization +* feat(binding): Support custom BindUnmarshaler for binding. (#3933) + +### Enhancements + +* chore(deps): bump github.com/bytedance/sonic from 1.11.3 to 1.11.6 (#3940) +* chore(deps): bump golangci/golangci-lint-action from 4 to 5 (#3941) +* chore: update external dependencies to latest versions (#3950) +* chore: update various Go dependencies to latest versions (#3901) +* chore: refactor configuration files for better readability (#3951) +* chore: update changelog categories and improve documentation (#3917) +* feat: update version constant to v1.10.0 (#3952) + +### Build process updates + +* ci(release): refactor changelog regex patterns and exclusions (#3914) +* ci(Makefile): vet command add .PHONY (#3915) + ## Gin v1.10.0 ### Features @@ -26,7 +120,7 @@ * fix(uri): query binding bug (#3236) (@illiafox) * fix: Add pointer support for url query params (#3659) (#3666) (@omkar-foss) * fix: protect Context.Keys map when call Copy method (#3873) (@kingcanfish) - + ### Enhancements * chore(CI): update release args (#3595) (@qloog) @@ -488,7 +582,7 @@ - [FIX] Refactor render - [FIX] Reworked tests - [FIX] logger now supports cygwin -- [FIX] Use X-Forwarded-For before X-Real-Ip +- [FIX] Use X-Forwarded-For before X-Real-IP - [FIX] time.Time binding (#904) ## Gin 1.1.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1c723c6..9703d6b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,41 @@ -## Contributing +# Contributing -- With issues: - - Use the search tool before opening a new issue. - - Please provide source code and commit sha if you found a bug. +We welcome both issue reports and pull requests! Please follow these guidelines to help maintainers respond effectively. + +## Issues + +- **Before opening a new issue:** + - Use the search tool to check for existing issues or feature requests. - Review existing issues and provide feedback or react to them. + - Use English for all communications — it is the language all maintainers read and write. + - For questions, configuration or deployment problems, please use the [Discussions Forum](https://github.com/gin-gonic/gin/discussions). + - For bug reports involving sensitive security issues, email instead of posting publicly. -- With pull requests: - - Open your pull request against `master` - - Your pull request should have no more than two commits, if not you should squash them. - - It should pass all tests in the available continuous integration systems such as GitHub Actions. - - You should add/modify tests to cover your proposed code changes. - - If your pull request contains a new feature, please document it on the README. +- **Reporting a bug:** + - Please provide a clear description of your issue, and a minimal reproducible code example if possible. + - Include the Gin version (or commit reference), Go version, and operating system. + - Indicate whether you can reproduce the bug and describe steps to do so. + - Attach relevant logs per [Logging Documentation](https://docs.gitea.com/administration/logging-config#collecting-logs-for-help). + +- **Feature requests:** + - Before opening a request, check that a similar idea hasn’t already been suggested. + - Clearly describe your proposed feature and its benefits. + +_For API Documentation, User Guides, and more, see:_ + +- [Go.dev API Documentation](https://pkg.go.dev/github.com/gin-gonic/gin) +- [Gin User Guides](https://gin-gonic.com/) +- [Discussions Forum](https://github.com/gin-gonic/gin/discussions) + +## Pull Requests + +Please ensure your pull request meets the following requirements: + +- Open your pull request against the `master` branch. +- Your pull request should have no more than two commits — squash them if necessary. +- All tests pass in available continuous integration systems (e.g., GitHub Actions). +- Add or modify tests to cover your code changes. +- If your pull request introduces a new feature, document it in [`docs/doc.md`](docs/doc.md), not in the README. +- Follow the checklist in the [Pull Request Template](.github/PULL_REQUEST_TEMPLATE.md:1). + +Thank you for contributing! diff --git a/README.md b/README.md index faeb4952..1b9ab808 100644 --- a/README.md +++ b/README.md @@ -2,117 +2,153 @@ -[![Build Status](https://github.com/gin-gonic/gin/workflows/Run%20Tests/badge.svg?branch=master)](https://github.com/gin-gonic/gin/actions?query=branch%3Amaster) +[![Build Status](https://github.com/gin-gonic/gin/actions/workflows/gin.yml/badge.svg?branch=master)](https://github.com/gin-gonic/gin/actions/workflows/gin.yml) +[![Trivy Security Scan](https://github.com/gin-gonic/gin/actions/workflows/trivy-scan.yml/badge.svg)](https://github.com/gin-gonic/gin/actions/workflows/trivy-scan.yml) [![codecov](https://codecov.io/gh/gin-gonic/gin/branch/master/graph/badge.svg)](https://codecov.io/gh/gin-gonic/gin) [![Go Report Card](https://goreportcard.com/badge/github.com/gin-gonic/gin)](https://goreportcard.com/report/github.com/gin-gonic/gin) [![Go Reference](https://pkg.go.dev/badge/github.com/gin-gonic/gin?status.svg)](https://pkg.go.dev/github.com/gin-gonic/gin?tab=doc) [![Sourcegraph](https://sourcegraph.com/github.com/gin-gonic/gin/-/badge.svg)](https://sourcegraph.com/github.com/gin-gonic/gin?badge) [![Open Source Helpers](https://www.codetriage.com/gin-gonic/gin/badges/users.svg)](https://www.codetriage.com/gin-gonic/gin) [![Release](https://img.shields.io/github/release/gin-gonic/gin.svg?style=flat-square)](https://github.com/gin-gonic/gin/releases) -[![TODOs](https://badgen.net/https/api.tickgit.com/badgen/github.com/gin-gonic/gin)](https://www.tickgit.com/browse?repo=github.com/gin-gonic/gin) -Gin is a web framework written in [Go](https://go.dev/). It features a martini-like API with performance that is up to 40 times faster thanks to [httprouter](https://github.com/julienschmidt/httprouter). -If you need performance and good productivity, you will love Gin. +## 📰 [Announcing Gin 1.11.0!](https://gin-gonic.com/en/blog/news/gin-1-11-0-release-announcement/) -**Gin's key features are:** +Read about the latest features and improvements in Gin 1.11.0 on our official blog. -- Zero allocation router -- Speed -- Middleware support -- Crash-free -- JSON validation -- Route grouping -- Error management -- Built-in rendering -- Extensible +--- -## Getting started +Gin is a high-performance HTTP web framework written in [Go](https://go.dev/). It provides a Martini-like API but with significantly better performance—up to 40 times faster—thanks to [httprouter](https://github.com/julienschmidt/httprouter). Gin is designed for building REST APIs, web applications, and microservices where speed and developer productivity are essential. + +**Why choose Gin?** + +Gin combines the simplicity of Express.js-style routing with Go's performance characteristics, making it ideal for: + +- Building high-throughput REST APIs +- Developing microservices that need to handle many concurrent requests +- Creating web applications that require fast response times +- Prototyping web services quickly with minimal boilerplate + +**Gin's key features:** + +- **Zero allocation router** - Extremely memory-efficient routing with no heap allocations +- **High performance** - Benchmarks show superior speed compared to other Go web frameworks +- **Middleware support** - Extensible middleware system for authentication, logging, CORS, etc. +- **Crash-free** - Built-in recovery middleware prevents panics from crashing your server +- **JSON validation** - Automatic request/response JSON binding and validation +- **Route grouping** - Organize related routes and apply common middleware +- **Error management** - Centralized error handling and logging +- **Built-in rendering** - Support for JSON, XML, HTML templates, and more +- **Extensible** - Large ecosystem of community middleware and plugins + +## Getting Started ### Prerequisites -Gin requires [Go](https://go.dev/) version [1.21](https://go.dev/doc/devel/release#go1.21.0) or above. +- **Go version**: Gin requires [Go](https://go.dev/) version [1.24](https://go.dev/doc/devel/release#go1.24.0) or above +- **Basic Go knowledge**: Familiarity with Go syntax and package management is helpful -### Getting Gin +### Installation -With [Go's module support](https://go.dev/wiki/Modules#how-to-use-modules), `go [build|run|test]` automatically fetches the necessary dependencies when you add the import in your code: +With [Go's module support](https://go.dev/wiki/Modules#how-to-use-modules), simply import Gin in your code and Go will automatically fetch it during build: -```sh +```go import "github.com/gin-gonic/gin" ``` -Alternatively, use `go get`: +### Your First Gin Application -```sh -go get -u github.com/gin-gonic/gin -``` - -### Running Gin - -A basic example: +Here's a complete example that demonstrates Gin's simplicity: ```go package main import ( + "log" "net/http" "github.com/gin-gonic/gin" ) func main() { + // Create a Gin router with default middleware (logger and recovery) r := gin.Default() + + // Define a simple GET endpoint r.GET("/ping", func(c *gin.Context) { + // Return JSON response c.JSON(http.StatusOK, gin.H{ "message": "pong", }) }) - r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080") + + // Start server on port 8080 (default) + // Server will listen on 0.0.0.0:8080 (localhost:8080 on Windows) + if err := r.Run(); err != nil { + log.Fatalf("failed to run server: %v", err) + } } ``` -To run the code, use the `go run` command, like: +**Running the application:** -```sh -$ go run example.go -``` +1. Save the code above as `main.go` +2. Run the application: -Then visit [`0.0.0.0:8080/ping`](http://0.0.0.0:8080/ping) in your browser to see the response! + ```sh + go run main.go + ``` -### See more examples +3. Open your browser and visit [`http://localhost:8080/ping`](http://localhost:8080/ping) +4. You should see: `{"message":"pong"}` -#### Quick Start +**What this example demonstrates:** -Learn and practice with the [Gin Quick Start](docs/doc.md), which includes API examples and builds tag. +- Creating a Gin router with default middleware +- Defining HTTP endpoints with simple handler functions +- Returning JSON responses +- Starting an HTTP server -#### Examples +### Next Steps -A number of ready-to-run examples demonstrating various use cases of Gin are available in the [Gin examples](https://github.com/gin-gonic/examples) repository. +After running your first Gin application, explore these resources to learn more: -## Documentation +#### 📚 Learning Resources -See the [API documentation on go.dev](https://pkg.go.dev/github.com/gin-gonic/gin). +- **[Gin Quick Start Guide](docs/doc.md)** - Comprehensive tutorial with API examples and build configurations +- **[Example Repository](https://github.com/gin-gonic/examples)** - Ready-to-run examples demonstrating various Gin use cases: + - REST API development + - Authentication & middleware + - File uploads and downloads + - WebSocket connections + - Template rendering -The documentation is also available on [gin-gonic.com](https://gin-gonic.com) in several languages: +## 📖 Documentation -- [English](https://gin-gonic.com/docs/) -- [简体中文](https://gin-gonic.com/zh-cn/docs/) -- [繁體中文](https://gin-gonic.com/zh-tw/docs/) -- [日本語](https://gin-gonic.com/ja/docs/) -- [Español](https://gin-gonic.com/es/docs/) -- [한국어](https://gin-gonic.com/ko-kr/docs/) -- [Turkish](https://gin-gonic.com/tr/docs/) -- [Persian](https://gin-gonic.com/fa/docs/) +### API Reference -### Articles +- **[Go.dev API Documentation](https://pkg.go.dev/github.com/gin-gonic/gin)** - Complete API reference with examples -- [Tutorial: Developing a RESTful API with Go and Gin](https://go.dev/doc/tutorial/web-service-gin) +### User Guides -## Benchmarks +The comprehensive documentation is available on [gin-gonic.com](https://gin-gonic.com) in multiple languages: -Gin uses a custom version of [HttpRouter](https://github.com/julienschmidt/httprouter), [see all benchmarks](/BENCHMARKS.md). +- [English](https://gin-gonic.com/en/docs/) | [简体中文](https://gin-gonic.com/zh-cn/docs/) | [繁體中文](https://gin-gonic.com/zh-tw/docs/) +- [日本語](https://gin-gonic.com/ja/docs/) | [한국어](https://gin-gonic.com/ko-kr/docs/) | [Español](https://gin-gonic.com/es/docs/) +- [Turkish](https://gin-gonic.com/tr/docs/) | [Persian](https://gin-gonic.com/fa/docs/) | [Português](https://gin-gonic.com/pt/docs/) +- [Russian](https://gin-gonic.com/ru/docs/) | [Indonesian](https://gin-gonic.com/id/docs/) + +### Official Tutorials + +- [Go.dev Tutorial: Developing a RESTful API with Go and Gin](https://go.dev/doc/tutorial/web-service-gin) + +## ⚡ Performance Benchmarks + +Gin demonstrates exceptional performance compared to other Go web frameworks. It uses a custom version of [HttpRouter](https://github.com/julienschmidt/httprouter) for maximum efficiency. [View detailed benchmarks →](/BENCHMARKS.md) + +**Gin vs. Other Go Frameworks** (GitHub API routing benchmark): | Benchmark name | (1) | (2) | (3) | (4) | -| ------------------------------ | ---------:| ---------------:| ------------:| ---------------:| +| ------------------------------ | --------: | --------------: | -----------: | --------------: | | BenchmarkGin_GithubAll | **43550** | **27364 ns/op** | **0 B/op** | **0 allocs/op** | | BenchmarkAce_GithubAll | 40543 | 29670 ns/op | 0 B/op | 0 allocs/op | | BenchmarkAero_GithubAll | 57632 | 20648 ns/op | 0 B/op | 0 allocs/op | @@ -149,23 +185,43 @@ Gin uses a custom version of [HttpRouter](https://github.com/julienschmidt/httpr - (3): Heap Memory (B/op), lower is better - (4): Average Allocations per Repetition (allocs/op), lower is better -## Middleware +## 🔌 Middleware Ecosystem -You can find many useful Gin middlewares at [gin-contrib](https://github.com/gin-contrib). +Gin has a rich ecosystem of middleware for common web development needs. Explore community-contributed middleware: -## Uses +- **[gin-contrib](https://github.com/gin-contrib)** - Official middleware collection including: + - Authentication (JWT, Basic Auth, Sessions) + - CORS, Rate limiting, Compression + - Logging, Metrics, Tracing + - Static file serving, Template engines +- **[gin-gonic/contrib](https://github.com/gin-gonic/contrib)** - Additional community middleware -Here are some awesome projects that are using the [Gin](https://github.com/gin-gonic/gin) web framework. +## 🏢 Production Usage -- [gorush](https://github.com/appleboy/gorush): A push notification server. -- [fnproject](https://github.com/fnproject/fn): A container native, cloud agnostic serverless platform. -- [photoprism](https://github.com/photoprism/photoprism): Personal photo management powered by Google TensorFlow. -- [lura](https://github.com/luraproject/lura): Ultra performant API Gateway with middleware. -- [picfit](https://github.com/thoas/picfit): An image resizing server. -- [dkron](https://github.com/distribworks/dkron): Distributed, fault tolerant job scheduling system. +Gin powers many high-traffic applications and services in production: -## Contributing +- **[gorush](https://github.com/appleboy/gorush)** - High-performance push notification server +- **[fnproject](https://github.com/fnproject/fn)** - Container-native, serverless platform +- **[photoprism](https://github.com/photoprism/photoprism)** - AI-powered personal photo management +- **[lura](https://github.com/luraproject/lura)** - Ultra-performant API Gateway framework +- **[picfit](https://github.com/thoas/picfit)** - Real-time image processing server +- **[dkron](https://github.com/distribworks/dkron)** - Distributed job scheduling system -Gin is the work of hundreds of contributors. We appreciate your help! +## 🤝 Contributing -Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on submitting patches and the contribution workflow. +Gin is the work of hundreds of contributors from around the world. We welcome and appreciate your contributions! + +### How to Contribute + +- 🐛 **Report bugs** - Help us identify and fix issues +- 💡 **Suggest features** - Share your ideas for improvements +- 📝 **Improve documentation** - Help make our docs clearer +- 🔧 **Submit code** - Fix bugs or implement new features +- 🧪 **Write tests** - Improve our test coverage + +### Getting Started with Contributing + +1. Check out our [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines +2. Join our community discussions and ask questions + +**All contributions are valued and help make Gin better for everyone!** diff --git a/auth_test.go b/auth_test.go index f7175929..9166e3b0 100644 --- a/auth_test.go +++ b/auth_test.go @@ -90,7 +90,7 @@ func TestBasicAuthSucceed(t *testing.T) { }) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/login", nil) + req, _ := http.NewRequest(http.MethodGet, "/login", nil) req.Header.Set("Authorization", authorizationHeader("admin", "password")) router.ServeHTTP(w, req) @@ -109,7 +109,7 @@ func TestBasicAuth401(t *testing.T) { }) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/login", nil) + req, _ := http.NewRequest(http.MethodGet, "/login", nil) req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password"))) router.ServeHTTP(w, req) @@ -129,7 +129,7 @@ func TestBasicAuth401WithCustomRealm(t *testing.T) { }) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/login", nil) + req, _ := http.NewRequest(http.MethodGet, "/login", nil) req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password"))) router.ServeHTTP(w, req) @@ -147,7 +147,7 @@ func TestBasicAuthForProxySucceed(t *testing.T) { }) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) + req, _ := http.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Proxy-Authorization", authorizationHeader("admin", "password")) router.ServeHTTP(w, req) @@ -166,7 +166,7 @@ func TestBasicAuthForProxy407(t *testing.T) { }) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) + req, _ := http.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password"))) router.ServeHTTP(w, req) diff --git a/benchmarks_test.go b/benchmarks_test.go index 5b7929b8..ca504ecb 100644 --- a/benchmarks_test.go +++ b/benchmarks_test.go @@ -14,21 +14,21 @@ import ( func BenchmarkOneRoute(B *testing.B) { router := New() router.GET("/ping", func(c *Context) {}) - runRequest(B, router, "GET", "/ping") + runRequest(B, router, http.MethodGet, "/ping") } func BenchmarkRecoveryMiddleware(B *testing.B) { router := New() router.Use(Recovery()) router.GET("/", func(c *Context) {}) - runRequest(B, router, "GET", "/") + runRequest(B, router, http.MethodGet, "/") } func BenchmarkLoggerMiddleware(B *testing.B) { router := New() router.Use(LoggerWithWriter(newMockWriter())) router.GET("/", func(c *Context) {}) - runRequest(B, router, "GET", "/") + runRequest(B, router, http.MethodGet, "/") } func BenchmarkManyHandlers(B *testing.B) { @@ -37,7 +37,7 @@ func BenchmarkManyHandlers(B *testing.B) { router.Use(func(c *Context) {}) router.Use(func(c *Context) {}) router.GET("/ping", func(c *Context) {}) - runRequest(B, router, "GET", "/ping") + runRequest(B, router, http.MethodGet, "/ping") } func Benchmark5Params(B *testing.B) { @@ -45,7 +45,7 @@ func Benchmark5Params(B *testing.B) { router := New() router.Use(func(c *Context) {}) router.GET("/param/:param1/:params2/:param3/:param4/:param5", func(c *Context) {}) - runRequest(B, router, "GET", "/param/path/to/parameter/john/12345") + runRequest(B, router, http.MethodGet, "/param/path/to/parameter/john/12345") } func BenchmarkOneRouteJSON(B *testing.B) { @@ -56,7 +56,7 @@ func BenchmarkOneRouteJSON(B *testing.B) { router.GET("/json", func(c *Context) { c.JSON(http.StatusOK, data) }) - runRequest(B, router, "GET", "/json") + runRequest(B, router, http.MethodGet, "/json") } func BenchmarkOneRouteHTML(B *testing.B) { @@ -68,7 +68,7 @@ func BenchmarkOneRouteHTML(B *testing.B) { router.GET("/html", func(c *Context) { c.HTML(http.StatusOK, "index", "hola") }) - runRequest(B, router, "GET", "/html") + runRequest(B, router, http.MethodGet, "/html") } func BenchmarkOneRouteSet(B *testing.B) { @@ -76,7 +76,7 @@ func BenchmarkOneRouteSet(B *testing.B) { router.GET("/ping", func(c *Context) { c.Set("key", "value") }) - runRequest(B, router, "GET", "/ping") + runRequest(B, router, http.MethodGet, "/ping") } func BenchmarkOneRouteString(B *testing.B) { @@ -84,13 +84,13 @@ func BenchmarkOneRouteString(B *testing.B) { router.GET("/text", func(c *Context) { c.String(http.StatusOK, "this is a plain text") }) - runRequest(B, router, "GET", "/text") + runRequest(B, router, http.MethodGet, "/text") } -func BenchmarkManyRoutesFist(B *testing.B) { +func BenchmarkManyRoutesFirst(B *testing.B) { router := New() router.Any("/ping", func(c *Context) {}) - runRequest(B, router, "GET", "/ping") + runRequest(B, router, http.MethodGet, "/ping") } func BenchmarkManyRoutesLast(B *testing.B) { @@ -103,7 +103,7 @@ func Benchmark404(B *testing.B) { router := New() router.Any("/something", func(c *Context) {}) router.NoRoute(func(c *Context) {}) - runRequest(B, router, "GET", "/ping") + runRequest(B, router, http.MethodGet, "/ping") } func Benchmark404Many(B *testing.B) { @@ -118,7 +118,7 @@ func Benchmark404Many(B *testing.B) { router.GET("/user/:id/:mode", func(c *Context) {}) router.NoRoute(func(c *Context) {}) - runRequest(B, router, "GET", "/viewfake") + runRequest(B, router, http.MethodGet, "/viewfake") } type mockWriter struct { diff --git a/binding/binding_msgpack_test.go b/binding/binding_msgpack_test.go index a8116391..7a5db34b 100644 --- a/binding/binding_msgpack_test.go +++ b/binding/binding_msgpack_test.go @@ -8,6 +8,7 @@ package binding import ( "bytes" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -39,20 +40,20 @@ func testMsgPackBodyBinding(t *testing.T, b Binding, name, path, badPath, body, assert.Equal(t, name, b.Name()) obj := FooStruct{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) req.Header.Add("Content-Type", MIMEMSGPACK) err := b.Bind(req, &obj) require.NoError(t, err) assert.Equal(t, "bar", obj.Foo) obj = FooStruct{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) req.Header.Add("Content-Type", MIMEMSGPACK) err = MsgPack.Bind(req, &obj) require.Error(t, err) } func TestBindingDefaultMsgPack(t *testing.T) { - assert.Equal(t, MsgPack, Default("POST", MIMEMSGPACK)) - assert.Equal(t, MsgPack, Default("PUT", MIMEMSGPACK2)) + assert.Equal(t, MsgPack, Default(http.MethodPost, MIMEMSGPACK)) + assert.Equal(t, MsgPack, Default(http.MethodPut, MIMEMSGPACK2)) } diff --git a/binding/binding_test.go b/binding/binding_test.go index 2036b59b..07619ebf 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -51,8 +51,6 @@ type FooBarFileStruct struct { type FooBarFileFailStruct struct { FooBarStruct File *multipart.FileHeader `invalid_name:"file" binding:"required"` - // for unexport test - data *multipart.FileHeader `form:"data" binding:"required"` } type FooDefaultBarStruct struct { @@ -69,15 +67,19 @@ type FooStructDisallowUnknownFields struct { } type FooBarStructForTimeType struct { - TimeFoo time.Time `form:"time_foo" time_format:"2006-01-02" time_utc:"1" time_location:"Asia/Chongqing"` - TimeBar time.Time `form:"time_bar" time_format:"2006-01-02" time_utc:"1"` - CreateTime time.Time `form:"createTime" time_format:"unixNano"` - UnixTime time.Time `form:"unixTime" time_format:"unix"` + TimeFoo time.Time `form:"time_foo" time_format:"2006-01-02" time_utc:"1" time_location:"Asia/Chongqing"` + TimeBar time.Time `form:"time_bar" time_format:"2006-01-02" time_utc:"1"` + CreateTime time.Time `form:"createTime" time_format:"unixNano"` + UnixTime time.Time `form:"unixTime" time_format:"unix"` + UnixMilliTime time.Time `form:"unixMilliTime" time_format:"unixmilli"` + UnixMicroTime time.Time `form:"unixMicroTime" time_format:"uNiXmiCrO"` } type FooStructForTimeTypeNotUnixFormat struct { - CreateTime time.Time `form:"createTime" time_format:"unixNano"` - UnixTime time.Time `form:"unixTime" time_format:"unix"` + CreateTime time.Time `form:"createTime" time_format:"unixNano"` + UnixTime time.Time `form:"unixTime" time_format:"unix"` + UnixMilliTime time.Time `form:"unixMilliTime" time_format:"unixMilli"` + UnixMicroTime time.Time `form:"unixMicroTime" time_format:"unixMicro"` } type FooStructForTimeTypeNotFormat struct { @@ -145,31 +147,31 @@ type FooStructForMapPtrType struct { } func TestBindingDefault(t *testing.T) { - assert.Equal(t, Form, Default("GET", "")) - assert.Equal(t, Form, Default("GET", MIMEJSON)) + assert.Equal(t, Form, Default(http.MethodGet, "")) + assert.Equal(t, Form, Default(http.MethodGet, MIMEJSON)) - assert.Equal(t, JSON, Default("POST", MIMEJSON)) - assert.Equal(t, JSON, Default("PUT", MIMEJSON)) + assert.Equal(t, JSON, Default(http.MethodPost, MIMEJSON)) + assert.Equal(t, JSON, Default(http.MethodPut, MIMEJSON)) - assert.Equal(t, XML, Default("POST", MIMEXML)) - assert.Equal(t, XML, Default("PUT", MIMEXML2)) + assert.Equal(t, XML, Default(http.MethodPost, MIMEXML)) + assert.Equal(t, XML, Default(http.MethodPut, MIMEXML2)) - assert.Equal(t, Form, Default("POST", MIMEPOSTForm)) - assert.Equal(t, Form, Default("PUT", MIMEPOSTForm)) + assert.Equal(t, Form, Default(http.MethodPost, MIMEPOSTForm)) + assert.Equal(t, Form, Default(http.MethodPut, MIMEPOSTForm)) - assert.Equal(t, FormMultipart, Default("POST", MIMEMultipartPOSTForm)) - assert.Equal(t, FormMultipart, Default("PUT", MIMEMultipartPOSTForm)) + assert.Equal(t, FormMultipart, Default(http.MethodPost, MIMEMultipartPOSTForm)) + assert.Equal(t, FormMultipart, Default(http.MethodPut, MIMEMultipartPOSTForm)) - assert.Equal(t, ProtoBuf, Default("POST", MIMEPROTOBUF)) - assert.Equal(t, ProtoBuf, Default("PUT", MIMEPROTOBUF)) + assert.Equal(t, ProtoBuf, Default(http.MethodPost, MIMEPROTOBUF)) + assert.Equal(t, ProtoBuf, Default(http.MethodPut, MIMEPROTOBUF)) - assert.Equal(t, YAML, Default("POST", MIMEYAML)) - assert.Equal(t, YAML, Default("PUT", MIMEYAML)) - assert.Equal(t, YAML, Default("POST", MIMEYAML2)) - assert.Equal(t, YAML, Default("PUT", MIMEYAML2)) + assert.Equal(t, YAML, Default(http.MethodPost, MIMEYAML)) + assert.Equal(t, YAML, Default(http.MethodPut, MIMEYAML)) + assert.Equal(t, YAML, Default(http.MethodPost, MIMEYAML2)) + assert.Equal(t, YAML, Default(http.MethodPut, MIMEYAML2)) - assert.Equal(t, TOML, Default("POST", MIMETOML)) - assert.Equal(t, TOML, Default("PUT", MIMETOML)) + assert.Equal(t, TOML, Default(http.MethodPost, MIMETOML)) + assert.Equal(t, TOML, Default(http.MethodPut, MIMETOML)) } func TestBindingJSONNilBody(t *testing.T) { @@ -227,137 +229,137 @@ func TestBindingJSONStringMap(t *testing.T) { } func TestBindingForm(t *testing.T) { - testFormBinding(t, "POST", + testFormBinding(t, http.MethodPost, "/", "/", "foo=bar&bar=foo", "bar2=foo") } func TestBindingForm2(t *testing.T) { - testFormBinding(t, "GET", + testFormBinding(t, http.MethodGet, "/?foo=bar&bar=foo", "/?bar2=foo", "", "") } func TestBindingFormEmbeddedStruct(t *testing.T) { - testFormBindingEmbeddedStruct(t, "POST", + testFormBindingEmbeddedStruct(t, http.MethodPost, "/", "/", "page=1&size=2&appkey=test-appkey", "bar2=foo") } func TestBindingFormEmbeddedStruct2(t *testing.T) { - testFormBindingEmbeddedStruct(t, "GET", + testFormBindingEmbeddedStruct(t, http.MethodGet, "/?page=1&size=2&appkey=test-appkey", "/?bar2=foo", "", "") } func TestBindingFormDefaultValue(t *testing.T) { - testFormBindingDefaultValue(t, "POST", + testFormBindingDefaultValue(t, http.MethodPost, "/", "/", "foo=bar", "bar2=foo") } func TestBindingFormDefaultValue2(t *testing.T) { - testFormBindingDefaultValue(t, "GET", + testFormBindingDefaultValue(t, http.MethodGet, "/?foo=bar", "/?bar2=foo", "", "") } func TestBindingFormForTime(t *testing.T) { - testFormBindingForTime(t, "POST", + testFormBindingForTime(t, http.MethodPost, "/", "/", - "time_foo=2017-11-15&time_bar=&createTime=1562400033000000123&unixTime=1562400033", "bar2=foo") - testFormBindingForTimeNotUnixFormat(t, "POST", + "time_foo=2017-11-15&time_bar=&createTime=1562400033000000123&unixTime=1562400033&unixMilliTime=1562400033001&unixMicroTime=1562400033000012", "bar2=foo") + testFormBindingForTimeNotUnixFormat(t, http.MethodPost, "/", "/", - "time_foo=2017-11-15&createTime=bad&unixTime=bad", "bar2=foo") - testFormBindingForTimeNotFormat(t, "POST", + "time_foo=2017-11-15&createTime=bad&unixTime=bad&unixMilliTime=bad&unixMicroTime=bad", "bar2=foo") + testFormBindingForTimeNotFormat(t, http.MethodPost, "/", "/", "time_foo=2017-11-15", "bar2=foo") - testFormBindingForTimeFailFormat(t, "POST", + testFormBindingForTimeFailFormat(t, http.MethodPost, "/", "/", "time_foo=2017-11-15", "bar2=foo") - testFormBindingForTimeFailLocation(t, "POST", + testFormBindingForTimeFailLocation(t, http.MethodPost, "/", "/", "time_foo=2017-11-15", "bar2=foo") } func TestBindingFormForTime2(t *testing.T) { - testFormBindingForTime(t, "GET", - "/?time_foo=2017-11-15&time_bar=&createTime=1562400033000000123&unixTime=1562400033", "/?bar2=foo", + testFormBindingForTime(t, http.MethodGet, + "/?time_foo=2017-11-15&time_bar=&createTime=1562400033000000123&unixTime=1562400033&unixMilliTime=1562400033001&unixMicroTime=1562400033000012", "/?bar2=foo", "", "") - testFormBindingForTimeNotUnixFormat(t, "POST", + testFormBindingForTimeNotUnixFormat(t, http.MethodPost, "/", "/", - "time_foo=2017-11-15&createTime=bad&unixTime=bad", "bar2=foo") - testFormBindingForTimeNotFormat(t, "GET", + "time_foo=2017-11-15&createTime=bad&unixTime=bad&unixMilliTime=bad&unixMicroTime=bad", "bar2=foo") + testFormBindingForTimeNotFormat(t, http.MethodGet, "/?time_foo=2017-11-15", "/?bar2=foo", "", "") - testFormBindingForTimeFailFormat(t, "GET", + testFormBindingForTimeFailFormat(t, http.MethodGet, "/?time_foo=2017-11-15", "/?bar2=foo", "", "") - testFormBindingForTimeFailLocation(t, "GET", + testFormBindingForTimeFailLocation(t, http.MethodGet, "/?time_foo=2017-11-15", "/?bar2=foo", "", "") } func TestFormBindingIgnoreField(t *testing.T) { - testFormBindingIgnoreField(t, "POST", + testFormBindingIgnoreField(t, http.MethodPost, "/", "/", "-=bar", "") } func TestBindingFormInvalidName(t *testing.T) { - testFormBindingInvalidName(t, "POST", + testFormBindingInvalidName(t, http.MethodPost, "/", "/", "test_name=bar", "bar2=foo") } func TestBindingFormInvalidName2(t *testing.T) { - testFormBindingInvalidName2(t, "POST", + testFormBindingInvalidName2(t, http.MethodPost, "/", "/", "map_foo=bar", "bar2=foo") } func TestBindingFormForType(t *testing.T) { - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "map_foo={\"bar\":123}", "map_foo=1", "Map") - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "slice_foo=1&slice_foo=2", "bar2=1&bar2=2", "Slice") - testFormBindingForType(t, "GET", + testFormBindingForType(t, http.MethodGet, "/?slice_foo=1&slice_foo=2", "/?bar2=1&bar2=2", "", "", "Slice") - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "slice_map_foo=1&slice_map_foo=2", "bar2=1&bar2=2", "SliceMap") - testFormBindingForType(t, "GET", + testFormBindingForType(t, http.MethodGet, "/?slice_map_foo=1&slice_map_foo=2", "/?bar2=1&bar2=2", "", "", "SliceMap") - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "ptr_bar=test", "bar2=test", "Ptr") - testFormBindingForType(t, "GET", + testFormBindingForType(t, http.MethodGet, "/?ptr_bar=test", "/?bar2=test", "", "", "Ptr") - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "idx=123", "id1=1", "Struct") - testFormBindingForType(t, "GET", + testFormBindingForType(t, http.MethodGet, "/?idx=123", "/?id1=1", "", "", "Struct") - testFormBindingForType(t, "POST", + testFormBindingForType(t, http.MethodPost, "/", "/", "name=thinkerou", "name1=ou", "StructPointer") - testFormBindingForType(t, "GET", + testFormBindingForType(t, http.MethodGet, "/?name=thinkerou", "/?name1=ou", "", "", "StructPointer") } @@ -374,7 +376,7 @@ func TestBindingFormStringMap(t *testing.T) { func TestBindingFormStringSliceMap(t *testing.T) { obj := make(map[string][]string) - req := requestWithBody("POST", "/", "foo=something&foo=bar&hello=world") + req := requestWithBody(http.MethodPost, "/", "foo=something&foo=bar&hello=world") req.Header.Add("Content-Type", MIMEPOSTForm) err := Form.Bind(req, &obj) require.NoError(t, err) @@ -387,38 +389,38 @@ func TestBindingFormStringSliceMap(t *testing.T) { assert.True(t, reflect.DeepEqual(obj, target)) objInvalid := make(map[string][]int) - req = requestWithBody("POST", "/", "foo=something&foo=bar&hello=world") + req = requestWithBody(http.MethodPost, "/", "foo=something&foo=bar&hello=world") req.Header.Add("Content-Type", MIMEPOSTForm) err = Form.Bind(req, &objInvalid) require.Error(t, err) } func TestBindingQuery(t *testing.T) { - testQueryBinding(t, "POST", + testQueryBinding(t, http.MethodPost, "/?foo=bar&bar=foo", "/", "foo=unused", "bar2=foo") } func TestBindingQuery2(t *testing.T) { - testQueryBinding(t, "GET", + testQueryBinding(t, http.MethodGet, "/?foo=bar&bar=foo", "/?bar2=foo", "foo=unused", "") } func TestBindingQueryFail(t *testing.T) { - testQueryBindingFail(t, "POST", + testQueryBindingFail(t, http.MethodPost, "/?map_foo=", "/", "map_foo=unused", "bar2=foo") } func TestBindingQueryFail2(t *testing.T) { - testQueryBindingFail(t, "GET", + testQueryBindingFail(t, http.MethodGet, "/?map_foo=", "/?bar2=foo", "map_foo=unused", "") } func TestBindingQueryBoolFail(t *testing.T) { - testQueryBindingBoolFail(t, "GET", + testQueryBindingBoolFail(t, http.MethodGet, "/?bool_foo=fasl", "/?bar2=foo", "bool_foo=unused", "") } @@ -427,7 +429,7 @@ func TestBindingQueryStringMap(t *testing.T) { b := Query obj := make(map[string]string) - req := requestWithBody("GET", "/?foo=bar&hello=world", "") + req := requestWithBody(http.MethodGet, "/?foo=bar&hello=world", "") err := b.Bind(req, &obj) require.NoError(t, err) assert.NotNil(t, obj) @@ -436,7 +438,7 @@ func TestBindingQueryStringMap(t *testing.T) { assert.Equal(t, "world", obj["hello"]) obj = make(map[string]string) - req = requestWithBody("GET", "/?foo=bar&foo=2&hello=world", "") // should pick last + req = requestWithBody(http.MethodGet, "/?foo=bar&foo=2&hello=world", "") // should pick last err = b.Bind(req, &obj) require.NoError(t, err) assert.NotNil(t, obj) @@ -495,28 +497,28 @@ func TestBindingYAMLFail(t *testing.T) { } func createFormPostRequest(t *testing.T) *http.Request { - req, err := http.NewRequest("POST", "/?foo=getfoo&bar=getbar", bytes.NewBufferString("foo=bar&bar=foo")) + req, err := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", bytes.NewBufferString("foo=bar&bar=foo")) require.NoError(t, err) req.Header.Set("Content-Type", MIMEPOSTForm) return req } func createDefaultFormPostRequest(t *testing.T) *http.Request { - req, err := http.NewRequest("POST", "/?foo=getfoo&bar=getbar", bytes.NewBufferString("foo=bar")) + req, err := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", bytes.NewBufferString("foo=bar")) require.NoError(t, err) req.Header.Set("Content-Type", MIMEPOSTForm) return req } func createFormPostRequestForMap(t *testing.T) *http.Request { - req, err := http.NewRequest("POST", "/?map_foo=getfoo", bytes.NewBufferString("map_foo={\"bar\":123}")) + req, err := http.NewRequest(http.MethodPost, "/?map_foo=getfoo", bytes.NewBufferString("map_foo={\"bar\":123}")) require.NoError(t, err) req.Header.Set("Content-Type", MIMEPOSTForm) return req } func createFormPostRequestForMapFail(t *testing.T) *http.Request { - req, err := http.NewRequest("POST", "/?map_foo=getfoo", bytes.NewBufferString("map_foo=hello")) + req, err := http.NewRequest(http.MethodPost, "/?map_foo=getfoo", bytes.NewBufferString("map_foo=hello")) require.NoError(t, err) req.Header.Set("Content-Type", MIMEPOSTForm) return req @@ -540,7 +542,7 @@ func createFormFilesMultipartRequest(t *testing.T) *http.Request { _, err = io.Copy(fw, f) require.NoError(t, err) - req, err2 := http.NewRequest("POST", "/?foo=getfoo&bar=getbar", body) + req, err2 := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", body) require.NoError(t, err2) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) @@ -565,7 +567,7 @@ func createFormFilesMultipartRequestFail(t *testing.T) *http.Request { _, err = io.Copy(fw, f) require.NoError(t, err) - req, err2 := http.NewRequest("POST", "/?foo=getfoo&bar=getbar", body) + req, err2 := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", body) require.NoError(t, err2) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) @@ -581,7 +583,7 @@ func createFormMultipartRequest(t *testing.T) *http.Request { require.NoError(t, mw.SetBoundary(boundary)) require.NoError(t, mw.WriteField("foo", "bar")) require.NoError(t, mw.WriteField("bar", "foo")) - req, err := http.NewRequest("POST", "/?foo=getfoo&bar=getbar", body) + req, err := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", body) require.NoError(t, err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) return req @@ -595,7 +597,7 @@ func createFormMultipartRequestForMap(t *testing.T) *http.Request { require.NoError(t, mw.SetBoundary(boundary)) require.NoError(t, mw.WriteField("map_foo", "{\"bar\":123, \"name\":\"thinkerou\", \"pai\": 3.14}")) - req, err := http.NewRequest("POST", "/?map_foo=getfoo", body) + req, err := http.NewRequest(http.MethodPost, "/?map_foo=getfoo", body) require.NoError(t, err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) return req @@ -609,7 +611,7 @@ func createFormMultipartRequestForMapFail(t *testing.T) *http.Request { require.NoError(t, mw.SetBoundary(boundary)) require.NoError(t, mw.WriteField("map_foo", "3.14")) - req, err := http.NewRequest("POST", "/?map_foo=getfoo", body) + req, err := http.NewRequest(http.MethodPost, "/?map_foo=getfoo", body) require.NoError(t, err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) return req @@ -731,7 +733,7 @@ func TestBindingProtoBufFail(t *testing.T) { func TestValidationFails(t *testing.T) { var obj FooStruct - req := requestWithBody("POST", "/", `{"bar": "foo"}`) + req := requestWithBody(http.MethodPost, "/", `{"bar": "foo"}`) err := JSON.Bind(req, &obj) require.Error(t, err) } @@ -742,7 +744,7 @@ func TestValidationDisabled(t *testing.T) { defer func() { Validator = backup }() var obj FooStruct - req := requestWithBody("POST", "/", `{"bar": "foo"}`) + req := requestWithBody(http.MethodPost, "/", `{"bar": "foo"}`) err := JSON.Bind(req, &obj) require.NoError(t, err) } @@ -753,7 +755,7 @@ func TestRequiredSucceeds(t *testing.T) { } var obj HogeStruct - req := requestWithBody("POST", "/", `{"hoge": 0}`) + req := requestWithBody(http.MethodPost, "/", `{"hoge": 0}`) err := JSON.Bind(req, &obj) require.NoError(t, err) } @@ -764,7 +766,7 @@ func TestRequiredFails(t *testing.T) { } var obj HogeStruct - req := requestWithBody("POST", "/", `{"boen": 0}`) + req := requestWithBody(http.MethodPost, "/", `{"boen": 0}`) err := JSON.Bind(req, &obj) require.Error(t, err) } @@ -778,12 +780,12 @@ func TestHeaderBinding(t *testing.T) { } var theader tHeader - req := requestWithBody("GET", "/", "") + req := requestWithBody(http.MethodGet, "/", "") req.Header.Add("limit", "1000") require.NoError(t, h.Bind(req, &theader)) assert.Equal(t, 1000, theader.Limit) - req = requestWithBody("GET", "/", "") + req = requestWithBody(http.MethodGet, "/", "") req.Header.Add("fail", `{fail:fail}`) type failStruct struct { @@ -843,7 +845,7 @@ func testFormBindingEmbeddedStruct(t *testing.T, method, path, badPath, body, ba obj := QueryTest{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -859,7 +861,7 @@ func testFormBinding(t *testing.T, method, path, badPath, body, badBody string) obj := FooBarStruct{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -879,7 +881,7 @@ func testFormBindingDefaultValue(t *testing.T, method, path, badPath, body, badB obj := FooDefaultBarStruct{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -898,14 +900,14 @@ func TestFormBindingFail(t *testing.T) { assert.Equal(t, "form", b.Name()) obj := FooBarStruct{} - req, _ := http.NewRequest("POST", "/", nil) + req, _ := http.NewRequest(http.MethodPost, "/", nil) err := b.Bind(req, &obj) require.Error(t, err) } func TestFormBindingMultipartFail(t *testing.T) { obj := FooBarStruct{} - req, err := http.NewRequest("POST", "/", strings.NewReader("foo=bar")) + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader("foo=bar")) require.NoError(t, err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+";boundary=testboundary") _, err = req.MultipartReader() @@ -919,7 +921,7 @@ func TestFormPostBindingFail(t *testing.T) { assert.Equal(t, "form-urlencoded", b.Name()) obj := FooBarStruct{} - req, _ := http.NewRequest("POST", "/", nil) + req, _ := http.NewRequest(http.MethodPost, "/", nil) err := b.Bind(req, &obj) require.Error(t, err) } @@ -929,7 +931,7 @@ func TestFormMultipartBindingFail(t *testing.T) { assert.Equal(t, "multipart/form-data", b.Name()) obj := FooBarStruct{} - req, _ := http.NewRequest("POST", "/", nil) + req, _ := http.NewRequest(http.MethodPost, "/", nil) err := b.Bind(req, &obj) require.Error(t, err) } @@ -940,7 +942,7 @@ func testFormBindingForTime(t *testing.T, method, path, badPath, body, badBody s obj := FooBarStructForTimeType{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -952,6 +954,8 @@ func testFormBindingForTime(t *testing.T, method, path, badPath, body, badBody s assert.Equal(t, "UTC", obj.TimeBar.Location().String()) assert.Equal(t, int64(1562400033000000123), obj.CreateTime.UnixNano()) assert.Equal(t, int64(1562400033), obj.UnixTime.Unix()) + assert.Equal(t, int64(1562400033001), obj.UnixMilliTime.UnixMilli()) + assert.Equal(t, int64(1562400033000012), obj.UnixMicroTime.UnixMicro()) obj = FooBarStructForTimeType{} req = requestWithBody(method, badPath, badBody) @@ -965,7 +969,7 @@ func testFormBindingForTimeNotUnixFormat(t *testing.T, method, path, badPath, bo obj := FooStructForTimeTypeNotUnixFormat{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -983,7 +987,7 @@ func testFormBindingForTimeNotFormat(t *testing.T, method, path, badPath, body, obj := FooStructForTimeTypeNotFormat{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1001,7 +1005,7 @@ func testFormBindingForTimeFailFormat(t *testing.T, method, path, badPath, body, obj := FooStructForTimeTypeFailFormat{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1019,7 +1023,7 @@ func testFormBindingForTimeFailLocation(t *testing.T, method, path, badPath, bod obj := FooStructForTimeTypeFailLocation{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1037,7 +1041,7 @@ func testFormBindingIgnoreField(t *testing.T, method, path, badPath, body, badBo obj := FooStructForIgnoreFormTag{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1052,12 +1056,12 @@ func testFormBindingInvalidName(t *testing.T, method, path, badPath, body, badBo obj := InvalidNameType{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) require.NoError(t, err) - assert.Equal(t, "", obj.TestName) + assert.Empty(t, obj.TestName) obj = InvalidNameType{} req = requestWithBody(method, badPath, badBody) @@ -1071,7 +1075,7 @@ func testFormBindingInvalidName2(t *testing.T, method, path, badPath, body, badB obj := InvalidNameMapType{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1088,7 +1092,7 @@ func testFormBindingForType(t *testing.T, method, path, badPath, body, badBody s assert.Equal(t, "form", b.Name()) req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } switch typ { @@ -1159,7 +1163,7 @@ func testQueryBinding(t *testing.T, method, path, badPath, body, badBody string) obj := FooBarStruct{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1174,7 +1178,7 @@ func testQueryBindingFail(t *testing.T, method, path, badPath, body, badBody str obj := FooStructForMapType{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1187,7 +1191,7 @@ func testQueryBindingBoolFail(t *testing.T, method, path, badPath, body, badBody obj := FooStructForBoolType{} req := requestWithBody(method, path, body) - if method == "POST" { + if method == http.MethodPost { req.Header.Add("Content-Type", MIMEPOSTForm) } err := b.Bind(req, &obj) @@ -1198,13 +1202,13 @@ func testBodyBinding(t *testing.T, b Binding, name, path, badPath, body, badBody assert.Equal(t, name, b.Name()) obj := FooStruct{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) err := b.Bind(req, &obj) require.NoError(t, err) assert.Equal(t, "bar", obj.Foo) obj = FooStruct{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj) require.Error(t, err) } @@ -1213,19 +1217,19 @@ func testBodyBindingSlice(t *testing.T, b Binding, name, path, badPath, body, ba assert.Equal(t, name, b.Name()) var obj1 []FooStruct - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) err := b.Bind(req, &obj1) require.NoError(t, err) var obj2 []FooStruct - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj2) require.Error(t, err) } func testBodyBindingStringMap(t *testing.T, b Binding, path, badPath, body, badBody string) { obj := make(map[string]string) - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) if b.Name() == "form" { req.Header.Add("Content-Type", MIMEPOSTForm) } @@ -1238,13 +1242,13 @@ func testBodyBindingStringMap(t *testing.T, b Binding, path, badPath, body, badB if badPath != "" && badBody != "" { obj = make(map[string]string) - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = b.Bind(req, &obj) require.Error(t, err) } objInt := make(map[string]int) - req = requestWithBody("POST", path, body) + req = requestWithBody(http.MethodPost, path, body) err = b.Bind(req, &objInt) require.Error(t, err) } @@ -1253,7 +1257,7 @@ func testBodyBindingUseNumber(t *testing.T, b Binding, name, path, badPath, body assert.Equal(t, name, b.Name()) obj := FooStructUseNumber{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) EnableDecoderUseNumber = true err := b.Bind(req, &obj) require.NoError(t, err) @@ -1263,7 +1267,7 @@ func testBodyBindingUseNumber(t *testing.T, b Binding, name, path, badPath, body assert.Equal(t, int64(123), v) obj = FooStructUseNumber{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj) require.Error(t, err) } @@ -1272,7 +1276,7 @@ func testBodyBindingUseNumber2(t *testing.T, b Binding, name, path, badPath, bod assert.Equal(t, name, b.Name()) obj := FooStructUseNumber{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) EnableDecoderUseNumber = false err := b.Bind(req, &obj) require.NoError(t, err) @@ -1281,7 +1285,7 @@ func testBodyBindingUseNumber2(t *testing.T, b Binding, name, path, badPath, bod assert.InDelta(t, float64(123), obj.Foo, 0.01) obj = FooStructUseNumber{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj) require.Error(t, err) } @@ -1293,13 +1297,13 @@ func testBodyBindingDisallowUnknownFields(t *testing.T, b Binding, path, badPath }() obj := FooStructDisallowUnknownFields{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) err := b.Bind(req, &obj) require.NoError(t, err) assert.Equal(t, "bar", obj.Foo) obj = FooStructDisallowUnknownFields{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj) require.Error(t, err) assert.Contains(t, err.Error(), "what") @@ -1309,13 +1313,13 @@ func testBodyBindingFail(t *testing.T, b Binding, name, path, badPath, body, bad assert.Equal(t, name, b.Name()) obj := FooStruct{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) err := b.Bind(req, &obj) require.Error(t, err) - assert.Equal(t, "", obj.Foo) + assert.Empty(t, obj.Foo) obj = FooStruct{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) err = JSON.Bind(req, &obj) require.Error(t, err) } @@ -1324,14 +1328,14 @@ func testProtoBodyBinding(t *testing.T, b Binding, name, path, badPath, body, ba assert.Equal(t, name, b.Name()) obj := protoexample.Test{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) req.Header.Add("Content-Type", MIMEPROTOBUF) err := b.Bind(req, &obj) require.NoError(t, err) assert.Equal(t, "yes", *obj.Label) obj = protoexample.Test{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) req.Header.Add("Content-Type", MIMEPROTOBUF) err = ProtoBuf.Bind(req, &obj) require.Error(t, err) @@ -1358,28 +1362,28 @@ func TestPlainBinding(t *testing.T) { assert.Equal(t, "plain", p.Name()) var s string - req := requestWithBody("POST", "/", "test string") + req := requestWithBody(http.MethodPost, "/", "test string") require.NoError(t, p.Bind(req, &s)) assert.Equal(t, "test string", s) var bs []byte - req = requestWithBody("POST", "/", "test []byte") + req = requestWithBody(http.MethodPost, "/", "test []byte") require.NoError(t, p.Bind(req, &bs)) assert.Equal(t, bs, []byte("test []byte")) var i int - req = requestWithBody("POST", "/", "test fail") + req = requestWithBody(http.MethodPost, "/", "test fail") require.Error(t, p.Bind(req, &i)) - req = requestWithBody("POST", "/", "") + req = requestWithBody(http.MethodPost, "/", "") req.Body = &failRead{} require.Error(t, p.Bind(req, &s)) - req = requestWithBody("POST", "/", "") + req = requestWithBody(http.MethodPost, "/", "") require.NoError(t, p.Bind(req, nil)) var ptr *string - req = requestWithBody("POST", "/", "") + req = requestWithBody(http.MethodPost, "/", "") require.NoError(t, p.Bind(req, ptr)) } @@ -1387,7 +1391,7 @@ func testProtoBodyBindingFail(t *testing.T, b Binding, name, path, badPath, body assert.Equal(t, name, b.Name()) obj := protoexample.Test{} - req := requestWithBody("POST", path, body) + req := requestWithBody(http.MethodPost, path, body) req.Body = io.NopCloser(&hook{}) req.Header.Add("Content-Type", MIMEPROTOBUF) @@ -1402,7 +1406,7 @@ func testProtoBodyBindingFail(t *testing.T, b Binding, name, path, badPath, body assert.Equal(t, "obj is not ProtoMessage", err.Error()) obj = protoexample.Test{} - req = requestWithBody("POST", badPath, badBody) + req = requestWithBody(http.MethodPost, badPath, badBody) req.Header.Add("Content-Type", MIMEPROTOBUF) err = ProtoBuf.Bind(req, &obj) require.Error(t, err) diff --git a/binding/default_validator_benchmark_test.go b/binding/default_validator_benchmark_test.go index 44547412..a7b22696 100644 --- a/binding/default_validator_benchmark_test.go +++ b/binding/default_validator_benchmark_test.go @@ -18,9 +18,8 @@ func BenchmarkSliceValidationError(b *testing.B) { } b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { if len(e.Error()) == 0 { b.Errorf("error") } diff --git a/binding/default_validator_test.go b/binding/default_validator_test.go index df7742b7..fc819eb4 100644 --- a/binding/default_validator_test.go +++ b/binding/default_validator_test.go @@ -18,14 +18,16 @@ func TestSliceValidationError(t *testing.T) { {"has nil elements", SliceValidationError{errors.New("test error"), nil}, "[0]: test error"}, {"has zero elements", SliceValidationError{}, ""}, {"has one element", SliceValidationError{errors.New("test one error")}, "[0]: test one error"}, - {"has two elements", + { + "has two elements", SliceValidationError{ errors.New("first error"), errors.New("second error"), }, "[0]: first error\n[1]: second error", }, - {"has many elements", + { + "has many elements", SliceValidationError{ errors.New("first error"), errors.New("second error"), diff --git a/binding/form.go b/binding/form.go index b17352ba..06732e97 100644 --- a/binding/form.go +++ b/binding/form.go @@ -11,9 +11,11 @@ import ( const defaultMemory = 32 << 20 -type formBinding struct{} -type formPostBinding struct{} -type formMultipartBinding struct{} +type ( + formBinding struct{} + formPostBinding struct{} + formMultipartBinding struct{} +) func (formBinding) Name() string { return "form" diff --git a/binding/form_mapping.go b/binding/form_mapping.go index 33389b28..1244b522 100644 --- a/binding/form_mapping.go +++ b/binding/form_mapping.go @@ -7,14 +7,15 @@ package binding import ( "errors" "fmt" + "maps" "mime/multipart" "reflect" "strconv" "strings" "time" + "github.com/gin-gonic/gin/codec/json" "github.com/gin-gonic/gin/internal/bytesconv" - "github.com/gin-gonic/gin/internal/json" ) var ( @@ -159,6 +160,14 @@ func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter if k, v := head(opt, "="); k == "default" { setOpt.isDefaultExists = true setOpt.defaultValue = v + + // convert semicolon-separated default values to csv-separated values for processing in setByForm + if field.Type.Kind() == reflect.Slice || field.Type.Kind() == reflect.Array { + cfTag := field.Tag.Get("collection_format") + if cfTag == "" || cfTag == "multi" || cfTag == "csv" { + setOpt.defaultValue = strings.ReplaceAll(v, ";", ",") + } + } } } @@ -167,7 +176,7 @@ func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter // BindUnmarshaler is the interface used to wrap the UnmarshalParam method. type BindUnmarshaler interface { - // UnmarshalParam decodes and assigns a value from an form or query param. + // UnmarshalParam decodes and assigns a value from a form or query param. UnmarshalParam(param string) error } @@ -182,6 +191,38 @@ func trySetCustom(val string, value reflect.Value) (isSet bool, err error) { return false, nil } +func trySplit(vs []string, field reflect.StructField) (newVs []string, err error) { + cfTag := field.Tag.Get("collection_format") + if cfTag == "" || cfTag == "multi" { + return vs, nil + } + + var sep string + switch cfTag { + case "csv": + sep = "," + case "ssv": + sep = " " + case "tsv": + sep = "\t" + case "pipes": + sep = "|" + default: + return vs, fmt.Errorf("%s is not supported in the collection_format. (csv, ssv, pipes)", cfTag) + } + + totalLength := 0 + for _, v := range vs { + totalLength += strings.Count(v, sep) + 1 + } + newVs = make([]string, 0, totalLength) + for _, v := range vs { + newVs = append(newVs, strings.Split(v, sep)...) + } + + return newVs, nil +} + func setByForm(value reflect.Value, field reflect.StructField, form map[string][]string, tagValue string, opt setOptions) (isSet bool, err error) { vs, ok := form[tagValue] if !ok && !opt.isDefaultExists { @@ -190,24 +231,50 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ switch value.Kind() { case reflect.Slice: - if !ok { + if len(vs) == 0 { + if !opt.isDefaultExists { + return false, nil + } + vs = []string{opt.defaultValue} + // pre-process the default value for multi if present + cfTag := field.Tag.Get("collection_format") + if cfTag == "" || cfTag == "multi" { + vs = strings.Split(opt.defaultValue, ",") + } } if ok, err = trySetCustom(vs[0], value); ok { return ok, err } + if vs, err = trySplit(vs, field); err != nil { + return false, err + } + return true, setSlice(vs, value, field) case reflect.Array: - if !ok { + if len(vs) == 0 { + if !opt.isDefaultExists { + return false, nil + } + vs = []string{opt.defaultValue} + // pre-process the default value for multi if present + cfTag := field.Tag.Get("collection_format") + if cfTag == "" || cfTag == "multi" { + vs = strings.Split(opt.defaultValue, ",") + } } if ok, err = trySetCustom(vs[0], value); ok { return ok, err } + if vs, err = trySplit(vs, field); err != nil { + return false, err + } + if len(vs) != value.Len() { return false, fmt.Errorf("%q is not valid value for %s", vs, value.Type().String()) } @@ -221,6 +288,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ if len(vs) > 0 { val = vs[0] + if val == "" { + val = opt.defaultValue + } } if ok, err := trySetCustom(val, value); ok { return ok, err @@ -270,9 +340,9 @@ func setWithProperType(val string, value reflect.Value, field reflect.StructFiel case multipart.FileHeader: return nil } - return json.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface()) + return json.API.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface()) case reflect.Map: - return json.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface()) + return json.API.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface()) case reflect.Ptr: if !value.Elem().IsValid() { value.Set(reflect.New(value.Type().Elem())) @@ -335,18 +405,24 @@ func setTimeField(val string, structField reflect.StructField, value reflect.Val } switch tf := strings.ToLower(timeFormat); tf { - case "unix", "unixnano": + case "unix", "unixmilli", "unixmicro", "unixnano": tv, err := strconv.ParseInt(val, 10, 64) if err != nil { return err } - d := time.Duration(1) - if tf == "unixnano" { - d = time.Second + var t time.Time + switch tf { + case "unix": + t = time.Unix(tv, 0) + case "unixmilli": + t = time.UnixMilli(tv) + case "unixmicro": + t = time.UnixMicro(tv) + default: + t = time.Unix(0, tv) } - t := time.Unix(tv/int64(d), tv%int64(d)) value.Set(reflect.ValueOf(t)) return nil } @@ -420,9 +496,7 @@ func setFormMap(ptr any, form map[string][]string) error { if !ok { return ErrConvertMapStringSlice } - for k, v := range form { - ptrMap[k] = v - } + maps.Copy(ptrMap, form) return nil } diff --git a/binding/form_mapping_benchmark_test.go b/binding/form_mapping_benchmark_test.go index 5788133f..d40699e9 100644 --- a/binding/form_mapping_benchmark_test.go +++ b/binding/form_mapping_benchmark_test.go @@ -31,7 +31,7 @@ type structFull struct { func BenchmarkMapFormFull(b *testing.B) { var s structFull - for i := 0; i < b.N; i++ { + for b.Loop() { err := mapForm(&s, form) if err != nil { b.Fatalf("Error on a form mapping") @@ -54,7 +54,7 @@ type structName struct { func BenchmarkMapFormName(b *testing.B) { var s structName - for i := 0; i < b.N; i++ { + for b.Loop() { err := mapForm(&s, form) if err != nil { b.Fatalf("Error on a form mapping") diff --git a/binding/form_mapping_test.go b/binding/form_mapping_test.go index 9ea0895c..006eddf1 100644 --- a/binding/form_mapping_test.go +++ b/binding/form_mapping_test.go @@ -6,7 +6,7 @@ package binding import ( "encoding/hex" - "fmt" + "errors" "mime/multipart" "reflect" "strconv" @@ -69,6 +69,7 @@ func TestMappingBaseTypes(t *testing.T) { func TestMappingDefault(t *testing.T) { var s struct { + Str string `form:",default=defaultVal"` Int int `form:",default=9"` Slice []int `form:",default=9"` Array [1]int `form:",default=9"` @@ -76,6 +77,7 @@ func TestMappingDefault(t *testing.T) { err := mappingByPtr(&s, formSource{}, "form") require.NoError(t, err) + assert.Equal(t, "defaultVal", s.Str) assert.Equal(t, 9, s.Int) assert.Equal(t, []int{9}, s.Slice) assert.Equal(t, [1]int{9}, s.Array) @@ -152,6 +154,24 @@ func TestMappingForm(t *testing.T) { assert.Equal(t, 6, s.F) } +func TestMappingFormFieldNotSent(t *testing.T) { + var s struct { + F string `form:"field,default=defVal"` + } + err := mapForm(&s, map[string][]string{}) + require.NoError(t, err) + assert.Equal(t, "defVal", s.F) +} + +func TestMappingFormWithEmptyToDefault(t *testing.T) { + var s struct { + F string `form:"field,default=DefVal"` + } + err := mapForm(&s, map[string][]string{"field": {""}}) + require.NoError(t, err) + assert.Equal(t, "DefVal", s.F) +} + func TestMapFormWithTag(t *testing.T) { var s struct { F int `externalTag:"field"` @@ -264,6 +284,111 @@ func TestMappingArray(t *testing.T) { require.Error(t, err) } +func TestMappingCollectionFormat(t *testing.T) { + var s struct { + SliceMulti []int `form:"slice_multi" collection_format:"multi"` + SliceCsv []int `form:"slice_csv" collection_format:"csv"` + SliceSsv []int `form:"slice_ssv" collection_format:"ssv"` + SliceTsv []int `form:"slice_tsv" collection_format:"tsv"` + SlicePipes []int `form:"slice_pipes" collection_format:"pipes"` + ArrayMulti [2]int `form:"array_multi" collection_format:"multi"` + ArrayCsv [2]int `form:"array_csv" collection_format:"csv"` + ArraySsv [2]int `form:"array_ssv" collection_format:"ssv"` + ArrayTsv [2]int `form:"array_tsv" collection_format:"tsv"` + ArrayPipes [2]int `form:"array_pipes" collection_format:"pipes"` + } + err := mappingByPtr(&s, formSource{ + "slice_multi": {"1", "2"}, + "slice_csv": {"1,2"}, + "slice_ssv": {"1 2"}, + "slice_tsv": {"1 2"}, + "slice_pipes": {"1|2"}, + "array_multi": {"1", "2"}, + "array_csv": {"1,2"}, + "array_ssv": {"1 2"}, + "array_tsv": {"1 2"}, + "array_pipes": {"1|2"}, + }, "form") + require.NoError(t, err) + + assert.Equal(t, []int{1, 2}, s.SliceMulti) + assert.Equal(t, []int{1, 2}, s.SliceCsv) + assert.Equal(t, []int{1, 2}, s.SliceSsv) + assert.Equal(t, []int{1, 2}, s.SliceTsv) + assert.Equal(t, []int{1, 2}, s.SlicePipes) + assert.Equal(t, [2]int{1, 2}, s.ArrayMulti) + assert.Equal(t, [2]int{1, 2}, s.ArrayCsv) + assert.Equal(t, [2]int{1, 2}, s.ArraySsv) + assert.Equal(t, [2]int{1, 2}, s.ArrayTsv) + assert.Equal(t, [2]int{1, 2}, s.ArrayPipes) +} + +func TestMappingCollectionFormatInvalid(t *testing.T) { + var s struct { + SliceCsv []int `form:"slice_csv" collection_format:"xxx"` + } + err := mappingByPtr(&s, formSource{ + "slice_csv": {"1,2"}, + }, "form") + require.Error(t, err) + + var s2 struct { + ArrayCsv [2]int `form:"array_csv" collection_format:"xxx"` + } + err = mappingByPtr(&s2, formSource{ + "array_csv": {"1,2"}, + }, "form") + require.Error(t, err) +} + +func TestMappingMultipleDefaultWithCollectionFormat(t *testing.T) { + var s struct { + SliceMulti []int `form:",default=1;2;3" collection_format:"multi"` + SliceCsv []int `form:",default=1;2;3" collection_format:"csv"` + SliceSsv []int `form:",default=1 2 3" collection_format:"ssv"` + SliceTsv []int `form:",default=1\t2\t3" collection_format:"tsv"` + SlicePipes []int `form:",default=1|2|3" collection_format:"pipes"` + ArrayMulti [2]int `form:",default=1;2" collection_format:"multi"` + ArrayCsv [2]int `form:",default=1;2" collection_format:"csv"` + ArraySsv [2]int `form:",default=1 2" collection_format:"ssv"` + ArrayTsv [2]int `form:",default=1\t2" collection_format:"tsv"` + ArrayPipes [2]int `form:",default=1|2" collection_format:"pipes"` + SliceStringMulti []string `form:",default=1;2;3" collection_format:"multi"` + SliceStringCsv []string `form:",default=1;2;3" collection_format:"csv"` + SliceStringSsv []string `form:",default=1 2 3" collection_format:"ssv"` + SliceStringTsv []string `form:",default=1\t2\t3" collection_format:"tsv"` + SliceStringPipes []string `form:",default=1|2|3" collection_format:"pipes"` + ArrayStringMulti [2]string `form:",default=1;2" collection_format:"multi"` + ArrayStringCsv [2]string `form:",default=1;2" collection_format:"csv"` + ArrayStringSsv [2]string `form:",default=1 2" collection_format:"ssv"` + ArrayStringTsv [2]string `form:",default=1\t2" collection_format:"tsv"` + ArrayStringPipes [2]string `form:",default=1|2" collection_format:"pipes"` + } + err := mappingByPtr(&s, formSource{}, "form") + require.NoError(t, err) + + assert.Equal(t, []int{1, 2, 3}, s.SliceMulti) + assert.Equal(t, []int{1, 2, 3}, s.SliceCsv) + assert.Equal(t, []int{1, 2, 3}, s.SliceSsv) + assert.Equal(t, []int{1, 2, 3}, s.SliceTsv) + assert.Equal(t, []int{1, 2, 3}, s.SlicePipes) + assert.Equal(t, [2]int{1, 2}, s.ArrayMulti) + assert.Equal(t, [2]int{1, 2}, s.ArrayCsv) + assert.Equal(t, [2]int{1, 2}, s.ArraySsv) + assert.Equal(t, [2]int{1, 2}, s.ArrayTsv) + assert.Equal(t, [2]int{1, 2}, s.ArrayPipes) + assert.Equal(t, []string{"1", "2", "3"}, s.SliceStringMulti) + assert.Equal(t, []string{"1", "2", "3"}, s.SliceStringCsv) + assert.Equal(t, []string{"1", "2", "3"}, s.SliceStringSsv) + assert.Equal(t, []string{"1", "2", "3"}, s.SliceStringTsv) + assert.Equal(t, []string{"1", "2", "3"}, s.SliceStringPipes) + assert.Equal(t, [2]string{"1", "2"}, s.ArrayStringMulti) + assert.Equal(t, [2]string{"1", "2"}, s.ArrayStringCsv) + assert.Equal(t, [2]string{"1", "2"}, s.ArrayStringSsv) + assert.Equal(t, [2]string{"1", "2"}, s.ArrayStringTsv) + assert.Equal(t, [2]string{"1", "2"}, s.ArrayStringPipes) +} + func TestMappingStructField(t *testing.T) { var s struct { J struct { @@ -369,7 +494,7 @@ type customUnmarshalParamType struct { func (f *customUnmarshalParamType) UnmarshalParam(param string) error { parts := strings.Split(param, ":") if len(parts) != 3 { - return fmt.Errorf("invalid format") + return errors.New("invalid format") } f.Protocol = parts[0] f.Path = parts[1] @@ -384,9 +509,9 @@ func TestMappingCustomStructTypeWithFormTag(t *testing.T) { err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") require.NoError(t, err) - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) } func TestMappingCustomStructTypeWithURITag(t *testing.T) { @@ -396,9 +521,9 @@ func TestMappingCustomStructTypeWithURITag(t *testing.T) { err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") require.NoError(t, err) - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) } func TestMappingCustomPointerStructTypeWithFormTag(t *testing.T) { @@ -408,9 +533,9 @@ func TestMappingCustomPointerStructTypeWithFormTag(t *testing.T) { err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") require.NoError(t, err) - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) } func TestMappingCustomPointerStructTypeWithURITag(t *testing.T) { @@ -420,9 +545,9 @@ func TestMappingCustomPointerStructTypeWithURITag(t *testing.T) { err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") require.NoError(t, err) - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) } type customPath []string @@ -431,7 +556,7 @@ func (p *customPath) UnmarshalParam(param string) error { elems := strings.Split(param, "/") n := len(elems) if n < 2 { - return fmt.Errorf("invalid format") + return errors.New("invalid format") } *p = elems @@ -445,8 +570,8 @@ func TestMappingCustomSliceUri(t *testing.T) { err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "uri") require.NoError(t, err) - assert.EqualValues(t, "bar", s.FileData[0]) - assert.EqualValues(t, "foo", s.FileData[1]) + assert.Equal(t, "bar", s.FileData[0]) + assert.Equal(t, "foo", s.FileData[1]) } func TestMappingCustomSliceForm(t *testing.T) { @@ -456,8 +581,8 @@ func TestMappingCustomSliceForm(t *testing.T) { err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "form") require.NoError(t, err) - assert.EqualValues(t, "bar", s.FileData[0]) - assert.EqualValues(t, "foo", s.FileData[1]) + assert.Equal(t, "bar", s.FileData[0]) + assert.Equal(t, "foo", s.FileData[1]) } type objectID [12]byte @@ -475,7 +600,7 @@ func (o *objectID) UnmarshalParam(param string) error { func convertTo(s string) (objectID, error) { var nilObjectID objectID if len(s) != 24 { - return nilObjectID, fmt.Errorf("invalid format") + return nilObjectID, errors.New("invalid format") } var oid [12]byte @@ -496,7 +621,7 @@ func TestMappingCustomArrayUri(t *testing.T) { require.NoError(t, err) expected, _ := convertTo(val) - assert.EqualValues(t, expected, s.FileData) + assert.Equal(t, expected, s.FileData) } func TestMappingCustomArrayForm(t *testing.T) { @@ -508,5 +633,85 @@ func TestMappingCustomArrayForm(t *testing.T) { require.NoError(t, err) expected, _ := convertTo(val) - assert.EqualValues(t, expected, s.FileData) + assert.Equal(t, expected, s.FileData) +} + +func TestMappingEmptyValues(t *testing.T) { + t.Run("slice with default", func(t *testing.T) { + var s struct { + Slice []int `form:"slice,default=5"` + } + + // field not present + err := mappingByPtr(&s, formSource{}, "form") + require.NoError(t, err) + assert.Equal(t, []int{5}, s.Slice) + + // field present but empty + err = mappingByPtr(&s, formSource{"slice": {}}, "form") + require.NoError(t, err) + assert.Equal(t, []int{5}, s.Slice) + + // field present with values + err = mappingByPtr(&s, formSource{"slice": {"1", "2", "3"}}, "form") + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, s.Slice) + }) + + t.Run("array with default", func(t *testing.T) { + var s struct { + Array [1]int `form:"array,default=5"` + } + + // field not present + err := mappingByPtr(&s, formSource{}, "form") + require.NoError(t, err) + assert.Equal(t, [1]int{5}, s.Array) + + // field present but empty + err = mappingByPtr(&s, formSource{"array": {}}, "form") + require.NoError(t, err) + assert.Equal(t, [1]int{5}, s.Array) + }) + + t.Run("slice without default", func(t *testing.T) { + var s struct { + Slice []int `form:"slice"` + } + + // field present but empty + err := mappingByPtr(&s, formSource{"slice": {}}, "form") + require.NoError(t, err) + assert.Equal(t, []int(nil), s.Slice) + }) + + t.Run("array without default", func(t *testing.T) { + var s struct { + Array [1]int `form:"array"` + } + + // field present but empty + err := mappingByPtr(&s, formSource{"array": {}}, "form") + require.NoError(t, err) + assert.Equal(t, [1]int{0}, s.Array) + }) + + t.Run("slice with collection format", func(t *testing.T) { + var s struct { + SliceMulti []int `form:"slice_multi,default=1;2;3" collection_format:"multi"` + SliceCsv []int `form:"slice_csv,default=1;2;3" collection_format:"csv"` + } + + // field not present + err := mappingByPtr(&s, formSource{}, "form") + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, s.SliceMulti) + assert.Equal(t, []int{1, 2, 3}, s.SliceCsv) + + // field present but empty + err = mappingByPtr(&s, formSource{"slice_multi": {}, "slice_csv": {}}, "form") + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, s.SliceMulti) + assert.Equal(t, []int{1, 2, 3}, s.SliceCsv) + }) } diff --git a/binding/header.go b/binding/header.go index 03bc78da..6ed8c0c5 100644 --- a/binding/header.go +++ b/binding/header.go @@ -17,7 +17,6 @@ func (headerBinding) Name() string { } func (headerBinding) Bind(req *http.Request, obj any) error { - if err := mapHeader(obj, req.Header); err != nil { return err } diff --git a/binding/json.go b/binding/json.go index e21c2ee3..f4ae921a 100644 --- a/binding/json.go +++ b/binding/json.go @@ -10,7 +10,7 @@ import ( "io" "net/http" - "github.com/gin-gonic/gin/internal/json" + "github.com/gin-gonic/gin/codec/json" ) // EnableDecoderUseNumber is used to call the UseNumber method on the JSON @@ -42,7 +42,7 @@ func (jsonBinding) BindBody(body []byte, obj any) error { } func decodeJSON(r io.Reader, obj any) error { - decoder := json.NewDecoder(r) + decoder := json.API.NewDecoder(r) if EnableDecoderUseNumber { decoder.UseNumber() } diff --git a/binding/json_test.go b/binding/json_test.go index fbd5c527..942ee3eb 100644 --- a/binding/json_test.go +++ b/binding/json_test.go @@ -5,8 +5,16 @@ package binding import ( + "io" + "net/http/httptest" "testing" + "time" + "unsafe" + "github.com/gin-gonic/gin/codec/json" + "github.com/gin-gonic/gin/render" + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,3 +36,181 @@ func TestJSONBindingBindBodyMap(t *testing.T) { assert.Equal(t, "FOO", s["foo"]) assert.Equal(t, "world", s["hello"]) } + +func TestCustomJsonCodec(t *testing.T) { + // Restore json encoding configuration after testing + oldMarshal := json.API + defer func() { + json.API = oldMarshal + }() + // Custom json api + json.API = customJsonApi{} + + // test decode json + obj := customReq{} + err := jsonBinding{}.BindBody([]byte(`{"time_empty":null,"time_struct": "2001-12-05 10:01:02.345","time_nil":null,"time_pointer":"2002-12-05 10:01:02.345"}`), &obj) + require.NoError(t, err) + assert.Equal(t, zeroTime, obj.TimeEmpty) + assert.Equal(t, time.Date(2001, 12, 5, 10, 1, 2, 345000000, time.Local), obj.TimeStruct) + assert.Nil(t, obj.TimeNil) + assert.Equal(t, time.Date(2002, 12, 5, 10, 1, 2, 345000000, time.Local), *obj.TimePointer) + // test encode json + w := httptest.NewRecorder() + err2 := (render.PureJSON{Data: obj}).Render(w) + require.NoError(t, err2) + assert.JSONEq(t, "{\"time_empty\":null,\"time_struct\":\"2001-12-05 10:01:02.345\",\"time_nil\":null,\"time_pointer\":\"2002-12-05 10:01:02.345\"}\n", w.Body.String()) + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) +} + +type customReq struct { + TimeEmpty time.Time `json:"time_empty"` + TimeStruct time.Time `json:"time_struct"` + TimeNil *time.Time `json:"time_nil"` + TimePointer *time.Time `json:"time_pointer"` +} + +var customConfig = jsoniter.Config{ + EscapeHTML: true, + SortMapKeys: true, + ValidateJsonRawMessage: true, +}.Froze() + +func init() { + customConfig.RegisterExtension(&TimeEx{}) + customConfig.RegisterExtension(&TimePointerEx{}) +} + +type customJsonApi struct{} + +func (j customJsonApi) Marshal(v any) ([]byte, error) { + return customConfig.Marshal(v) +} + +func (j customJsonApi) Unmarshal(data []byte, v any) error { + return customConfig.Unmarshal(data, v) +} + +func (j customJsonApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return customConfig.MarshalIndent(v, prefix, indent) +} + +func (j customJsonApi) NewEncoder(writer io.Writer) json.Encoder { + return customConfig.NewEncoder(writer) +} + +func (j customJsonApi) NewDecoder(reader io.Reader) json.Decoder { + return customConfig.NewDecoder(reader) +} + +// region Time Extension + +var ( + zeroTime = time.Time{} + timeType = reflect2.TypeOfPtr((*time.Time)(nil)).Elem() + defaultTimeCodec = &timeCodec{} +) + +type TimeEx struct { + jsoniter.DummyExtension +} + +func (te *TimeEx) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder { + if typ == timeType { + return defaultTimeCodec + } + return nil +} + +func (te *TimeEx) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder { + if typ == timeType { + return defaultTimeCodec + } + return nil +} + +type timeCodec struct{} + +func (tc timeCodec) IsEmpty(ptr unsafe.Pointer) bool { + t := *((*time.Time)(ptr)) + return t.Equal(zeroTime) +} + +func (tc timeCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + t := *((*time.Time)(ptr)) + if t.Equal(zeroTime) { + stream.WriteNil() + return + } + stream.WriteString(t.In(time.Local).Format("2006-01-02 15:04:05.000")) +} + +func (tc timeCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + ts := iter.ReadString() + if len(ts) == 0 { + *((*time.Time)(ptr)) = zeroTime + return + } + t, err := time.ParseInLocation("2006-01-02 15:04:05.000", ts, time.Local) + if err != nil { + panic(err) + } + *((*time.Time)(ptr)) = t +} + +// endregion + +// region *Time Extension + +var ( + timePointerType = reflect2.TypeOfPtr((**time.Time)(nil)).Elem() + defaultTimePointerCodec = &timePointerCodec{} +) + +type TimePointerEx struct { + jsoniter.DummyExtension +} + +func (tpe *TimePointerEx) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder { + if typ == timePointerType { + return defaultTimePointerCodec + } + return nil +} + +func (tpe *TimePointerEx) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder { + if typ == timePointerType { + return defaultTimePointerCodec + } + return nil +} + +type timePointerCodec struct{} + +func (tpc timePointerCodec) IsEmpty(ptr unsafe.Pointer) bool { + t := *((**time.Time)(ptr)) + return t == nil || (*t).Equal(zeroTime) +} + +func (tpc timePointerCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + t := *((**time.Time)(ptr)) + if t == nil || (*t).Equal(zeroTime) { + stream.WriteNil() + return + } + stream.WriteString(t.In(time.Local).Format("2006-01-02 15:04:05.000")) +} + +func (tpc timePointerCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + ts := iter.ReadString() + if len(ts) == 0 { + *((**time.Time)(ptr)) = nil + return + } + t, err := time.ParseInLocation("2006-01-02 15:04:05.000", ts, time.Local) + if err != nil { + panic(err) + } + *((**time.Time)(ptr)) = &t +} + +// endregion diff --git a/binding/multipart_form_mapping_test.go b/binding/multipart_form_mapping_test.go index 9782b81d..c93f2141 100644 --- a/binding/multipart_form_mapping_test.go +++ b/binding/multipart_form_mapping_test.go @@ -116,7 +116,7 @@ func createRequestMultipartFiles(t *testing.T, files ...testFile) *http.Request err := mw.Close() require.NoError(t, err) - req, err := http.NewRequest("POST", "/", &body) + req, err := http.NewRequest(http.MethodPost, "/", &body) require.NoError(t, err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+mw.Boundary()) diff --git a/binding/plain.go b/binding/plain.go index 3b250bb0..5d466bdd 100644 --- a/binding/plain.go +++ b/binding/plain.go @@ -15,7 +15,7 @@ func (plainBinding) Name() string { return "plain" } -func (plainBinding) Bind(req *http.Request, obj interface{}) error { +func (plainBinding) Bind(req *http.Request, obj any) error { all, err := io.ReadAll(req.Body) if err != nil { return err diff --git a/binding/protobuf.go b/binding/protobuf.go index 57721fc9..259ae8e7 100644 --- a/binding/protobuf.go +++ b/binding/protobuf.go @@ -34,7 +34,7 @@ func (protobufBinding) BindBody(body []byte, obj any) error { if err := proto.Unmarshal(body, msg); err != nil { return err } - // Here it's same to return validate(obj), but util now we can't add + // Here it's same to return validate(obj), but until now we can't add // `binding:""` to the struct which automatically generate by gen-proto return nil // return validate(obj) diff --git a/binding/toml.go b/binding/toml.go index a66b93aa..2681231d 100644 --- a/binding/toml.go +++ b/binding/toml.go @@ -31,5 +31,5 @@ func decodeToml(r io.Reader, obj any) error { if err := decoder.Decode(obj); err != nil { return err } - return decoder.Decode(obj) + return validate(obj) } diff --git a/binding/validate_test.go b/binding/validate_test.go index c9bbe601..792f64c4 100644 --- a/binding/validate_test.go +++ b/binding/validate_test.go @@ -158,16 +158,16 @@ type structNoValidationPointer struct { } func TestValidateNoValidationPointers(t *testing.T) { - //origin := createNoValidation_values() - //test := createNoValidation_values() + // origin := createNoValidation_values() + // test := createNoValidation_values() empty := structNoValidationPointer{} - //assert.Nil(t, validate(test)) - //assert.Nil(t, validate(&test)) + // assert.Nil(t, validate(test)) + // assert.Nil(t, validate(&test)) require.NoError(t, validate(empty)) require.NoError(t, validate(&empty)) - //assert.Equal(t, origin, test) + // assert.Equal(t, origin, test) } type Object map[string]any @@ -198,7 +198,7 @@ type structModifyValidation struct { } func toZero(sl validator.StructLevel) { - var s *structModifyValidation = sl.Top().Interface().(*structModifyValidation) + s := sl.Top().Interface().(*structModifyValidation) s.Integer = 0 } @@ -249,5 +249,5 @@ func TestValidatorEngine(t *testing.T) { // Check that we got back non-nil errs require.Error(t, errs) // Check that the error matches expectation - require.Error(t, errs, "", "", "notone") + require.Error(t, errs, "notone") } diff --git a/binding/xml.go b/binding/xml.go index a70f4ad3..acd6f942 100644 --- a/binding/xml.go +++ b/binding/xml.go @@ -24,6 +24,7 @@ func (xmlBinding) Bind(req *http.Request, obj any) error { func (xmlBinding) BindBody(body []byte, obj any) error { return decodeXML(bytes.NewReader(body), obj) } + func decodeXML(r io.Reader, obj any) error { decoder := xml.NewDecoder(r) if err := decoder.Decode(obj); err != nil { diff --git a/binding/yaml.go b/binding/yaml.go index 2535f8c3..6638e739 100644 --- a/binding/yaml.go +++ b/binding/yaml.go @@ -9,7 +9,7 @@ import ( "io" "net/http" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) type yamlBinding struct{} diff --git a/codec/json/api.go b/codec/json/api.go new file mode 100644 index 00000000..f2135683 --- /dev/null +++ b/codec/json/api.go @@ -0,0 +1,57 @@ +// Copyright 2025 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package json + +import "io" + +// API the json codec in use. +var API Core + +// Core the api for json codec. +type Core interface { + Marshal(v any) ([]byte, error) + Unmarshal(data []byte, v any) error + MarshalIndent(v any, prefix, indent string) ([]byte, error) + NewEncoder(writer io.Writer) Encoder + NewDecoder(reader io.Reader) Decoder +} + +// Encoder an interface writes JSON values to an output stream. +type Encoder interface { + // SetEscapeHTML specifies whether problematic HTML characters + // should be escaped inside JSON quoted strings. + // The default behavior is to escape &, <, and > to \u0026, \u003c, and \u003e + // to avoid certain safety problems that can arise when embedding JSON in HTML. + // + // In non-HTML settings where the escaping interferes with the readability + // of the output, SetEscapeHTML(false) disables this behavior. + SetEscapeHTML(on bool) + + // Encode writes the JSON encoding of v to the stream, + // followed by a newline character. + // + // See the documentation for Marshal for details about the + // conversion of Go values to JSON. + Encode(v any) error +} + +// Decoder an interface reads and decodes JSON values from an input stream. +type Decoder interface { + // UseNumber causes the Decoder to unmarshal a number into an any as a + // Number instead of as a float64. + UseNumber() + + // DisallowUnknownFields causes the Decoder to return an error when the destination + // is a struct and the input contains object keys which do not match any + // non-ignored, exported fields in the destination. + DisallowUnknownFields() + + // Decode reads the next JSON-encoded value from its + // input and stores it in the value pointed to by v. + // + // See the documentation for Unmarshal for details about + // the conversion of JSON into a Go value. + Decode(v any) error +} diff --git a/codec/json/go_json.go b/codec/json/go_json.go new file mode 100644 index 00000000..42a476ac --- /dev/null +++ b/codec/json/go_json.go @@ -0,0 +1,42 @@ +// Copyright 2025 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +//go:build go_json + +package json + +import ( + "io" + + "github.com/goccy/go-json" +) + +// Package indicates what library is being used for JSON encoding. +const Package = "github.com/goccy/go-json" + +func init() { + API = gojsonApi{} +} + +type gojsonApi struct{} + +func (j gojsonApi) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (j gojsonApi) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (j gojsonApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +func (j gojsonApi) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (j gojsonApi) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} diff --git a/codec/json/json.go b/codec/json/json.go new file mode 100644 index 00000000..2971f42f --- /dev/null +++ b/codec/json/json.go @@ -0,0 +1,41 @@ +// Copyright 2025 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +//go:build !jsoniter && !go_json && !(sonic && (linux || windows || darwin)) + +package json + +import ( + "encoding/json" + "io" +) + +// Package indicates what library is being used for JSON encoding. +const Package = "encoding/json" + +func init() { + API = jsonApi{} +} + +type jsonApi struct{} + +func (j jsonApi) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (j jsonApi) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (j jsonApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +func (j jsonApi) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (j jsonApi) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} diff --git a/codec/json/jsoniter.go b/codec/json/jsoniter.go new file mode 100644 index 00000000..ea624e77 --- /dev/null +++ b/codec/json/jsoniter.go @@ -0,0 +1,44 @@ +// Copyright 2025 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +//go:build jsoniter + +package json + +import ( + "io" + + jsoniter "github.com/json-iterator/go" +) + +// Package indicates what library is being used for JSON encoding. +const Package = "github.com/json-iterator/go" + +func init() { + API = jsoniterApi{} +} + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +type jsoniterApi struct{} + +func (j jsoniterApi) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (j jsoniterApi) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (j jsoniterApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +func (j jsoniterApi) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (j jsoniterApi) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} diff --git a/codec/json/sonic.go b/codec/json/sonic.go new file mode 100644 index 00000000..69496565 --- /dev/null +++ b/codec/json/sonic.go @@ -0,0 +1,44 @@ +// Copyright 2025 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +//go:build sonic && (linux || windows || darwin) + +package json + +import ( + "io" + + "github.com/bytedance/sonic" +) + +// Package indicates what library is being used for JSON encoding. +const Package = "github.com/bytedance/sonic" + +func init() { + API = sonicApi{} +} + +var json = sonic.ConfigStd + +type sonicApi struct{} + +func (j sonicApi) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (j sonicApi) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (j sonicApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +func (j sonicApi) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (j sonicApi) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} diff --git a/context.go b/context.go index baa4b0f9..112f0ee0 100644 --- a/context.go +++ b/context.go @@ -6,8 +6,11 @@ package gin import ( "errors" + "fmt" "io" + "io/fs" "log" + "maps" "math" "mime/multipart" "net" @@ -36,6 +39,7 @@ const ( MIMEYAML = binding.MIMEYAML MIMEYAML2 = binding.MIMEYAML2 MIMETOML = binding.MIMETOML + MIMEPROTOBUF = binding.MIMEPROTOBUF ) // BodyBytesKey indicates a default body bytes key. @@ -51,6 +55,14 @@ const ContextRequestKey ContextKeyType = 0 // abortIndex represents a typical value used in abort functions. const abortIndex int8 = math.MaxInt8 >> 1 +// safeInt8 converts int to int8 safely, capping at math.MaxInt8 +func safeInt8(n int) int8 { + if n > math.MaxInt8 { + return math.MaxInt8 + } + return int8(n) +} + // Context is the most important part of gin. It allows us to pass variables between middleware, // manage the flow, validate the JSON of a request and render a JSON response for example. type Context struct { @@ -71,7 +83,7 @@ type Context struct { mu sync.RWMutex // Keys is a key/value pair exclusively for the context of each request. - Keys map[string]any + Keys map[any]any // Errors is a list of errors attached to all the handlers/middlewares who used this context. Errors errorMsgs @@ -128,11 +140,8 @@ func (c *Context) Copy() *Context { cp.fullPath = c.fullPath cKeys := c.Keys - cp.Keys = make(map[string]any, len(cKeys)) c.mu.RLock() - for k, v := range cKeys { - cp.Keys[k] = v - } + cp.Keys = maps.Clone(cKeys) c.mu.RUnlock() cParams := c.Params @@ -185,11 +194,10 @@ func (c *Context) FullPath() string { // See example in GitHub. func (c *Context) Next() { c.index++ - for c.index < int8(len(c.handlers)) { - if c.handlers[c.index] == nil { - continue + for c.index < safeInt8(len(c.handlers)) { + if c.handlers[c.index] != nil { + c.handlers[c.index](c) } - c.handlers[c.index](c) c.index++ } } @@ -215,6 +223,14 @@ func (c *Context) AbortWithStatus(code int) { c.Abort() } +// AbortWithStatusPureJSON calls `Abort()` and then `PureJSON` internally. +// This method stops the chain, writes the status code and return a JSON body without escaping. +// It also sets the Content-Type as "application/json". +func (c *Context) AbortWithStatusPureJSON(code int, jsonObj any) { + c.Abort() + c.PureJSON(code, jsonObj) +} + // AbortWithStatusJSON calls `Abort()` and then `JSON` internally. // This method stops the chain, writes the status code and return a JSON body. // It also sets the Content-Type as "application/json". @@ -263,12 +279,12 @@ func (c *Context) Error(err error) *Error { /************************************/ // Set is used to store a new key/value pair exclusively for this context. -// It also lazy initializes c.Keys if it was not used previously. -func (c *Context) Set(key string, value any) { +// It also lazy initializes c.Keys if it was not used previously. +func (c *Context) Set(key any, value any) { c.mu.Lock() defer c.mu.Unlock() if c.Keys == nil { - c.Keys = make(map[string]any) + c.Keys = make(map[any]any) } c.Keys[key] = value @@ -276,7 +292,7 @@ func (c *Context) Set(key string, value any) { // Get returns the value for the given key, ie: (value, true). // If the value does not exist it returns (nil, false) -func (c *Context) Get(key string) (value any, exists bool) { +func (c *Context) Get(key any) (value any, exists bool) { c.mu.RLock() defer c.mu.RUnlock() value, exists = c.Keys[key] @@ -284,115 +300,188 @@ func (c *Context) Get(key string) (value any, exists bool) { } // MustGet returns the value for the given key if it exists, otherwise it panics. -func (c *Context) MustGet(key string) any { +func (c *Context) MustGet(key any) any { if value, exists := c.Get(key); exists { return value } - panic("Key \"" + key + "\" does not exist") + panic(fmt.Sprintf("key %v does not exist", key)) +} + +func getTyped[T any](c *Context, key any) (res T) { + if val, ok := c.Get(key); ok && val != nil { + res, _ = val.(T) + } + return } // GetString returns the value associated with the key as a string. -func (c *Context) GetString(key string) (s string) { - if val, ok := c.Get(key); ok && val != nil { - s, _ = val.(string) - } - return +func (c *Context) GetString(key any) string { + return getTyped[string](c, key) } // GetBool returns the value associated with the key as a boolean. -func (c *Context) GetBool(key string) (b bool) { - if val, ok := c.Get(key); ok && val != nil { - b, _ = val.(bool) - } - return +func (c *Context) GetBool(key any) bool { + return getTyped[bool](c, key) } // GetInt returns the value associated with the key as an integer. -func (c *Context) GetInt(key string) (i int) { - if val, ok := c.Get(key); ok && val != nil { - i, _ = val.(int) - } - return +func (c *Context) GetInt(key any) int { + return getTyped[int](c, key) } -// GetInt64 returns the value associated with the key as an integer. -func (c *Context) GetInt64(key string) (i64 int64) { - if val, ok := c.Get(key); ok && val != nil { - i64, _ = val.(int64) - } - return +// GetInt8 returns the value associated with the key as an integer 8. +func (c *Context) GetInt8(key any) int8 { + return getTyped[int8](c, key) +} + +// GetInt16 returns the value associated with the key as an integer 16. +func (c *Context) GetInt16(key any) int16 { + return getTyped[int16](c, key) +} + +// GetInt32 returns the value associated with the key as an integer 32. +func (c *Context) GetInt32(key any) int32 { + return getTyped[int32](c, key) +} + +// GetInt64 returns the value associated with the key as an integer 64. +func (c *Context) GetInt64(key any) int64 { + return getTyped[int64](c, key) } // GetUint returns the value associated with the key as an unsigned integer. -func (c *Context) GetUint(key string) (ui uint) { - if val, ok := c.Get(key); ok && val != nil { - ui, _ = val.(uint) - } - return +func (c *Context) GetUint(key any) uint { + return getTyped[uint](c, key) } -// GetUint64 returns the value associated with the key as an unsigned integer. -func (c *Context) GetUint64(key string) (ui64 uint64) { - if val, ok := c.Get(key); ok && val != nil { - ui64, _ = val.(uint64) - } - return +// GetUint8 returns the value associated with the key as an unsigned integer 8. +func (c *Context) GetUint8(key any) uint8 { + return getTyped[uint8](c, key) +} + +// GetUint16 returns the value associated with the key as an unsigned integer 16. +func (c *Context) GetUint16(key any) uint16 { + return getTyped[uint16](c, key) +} + +// GetUint32 returns the value associated with the key as an unsigned integer 32. +func (c *Context) GetUint32(key any) uint32 { + return getTyped[uint32](c, key) +} + +// GetUint64 returns the value associated with the key as an unsigned integer 64. +func (c *Context) GetUint64(key any) uint64 { + return getTyped[uint64](c, key) +} + +// GetFloat32 returns the value associated with the key as a float32. +func (c *Context) GetFloat32(key any) float32 { + return getTyped[float32](c, key) } // GetFloat64 returns the value associated with the key as a float64. -func (c *Context) GetFloat64(key string) (f64 float64) { - if val, ok := c.Get(key); ok && val != nil { - f64, _ = val.(float64) - } - return +func (c *Context) GetFloat64(key any) float64 { + return getTyped[float64](c, key) } // GetTime returns the value associated with the key as time. -func (c *Context) GetTime(key string) (t time.Time) { - if val, ok := c.Get(key); ok && val != nil { - t, _ = val.(time.Time) - } - return +func (c *Context) GetTime(key any) time.Time { + return getTyped[time.Time](c, key) } // GetDuration returns the value associated with the key as a duration. -func (c *Context) GetDuration(key string) (d time.Duration) { - if val, ok := c.Get(key); ok && val != nil { - d, _ = val.(time.Duration) - } - return +func (c *Context) GetDuration(key any) time.Duration { + return getTyped[time.Duration](c, key) +} + +// GetIntSlice returns the value associated with the key as a slice of integers. +func (c *Context) GetIntSlice(key any) []int { + return getTyped[[]int](c, key) +} + +// GetInt8Slice returns the value associated with the key as a slice of int8 integers. +func (c *Context) GetInt8Slice(key any) []int8 { + return getTyped[[]int8](c, key) +} + +// GetInt16Slice returns the value associated with the key as a slice of int16 integers. +func (c *Context) GetInt16Slice(key any) []int16 { + return getTyped[[]int16](c, key) +} + +// GetInt32Slice returns the value associated with the key as a slice of int32 integers. +func (c *Context) GetInt32Slice(key any) []int32 { + return getTyped[[]int32](c, key) +} + +// GetInt64Slice returns the value associated with the key as a slice of int64 integers. +func (c *Context) GetInt64Slice(key any) []int64 { + return getTyped[[]int64](c, key) +} + +// GetUintSlice returns the value associated with the key as a slice of unsigned integers. +func (c *Context) GetUintSlice(key any) []uint { + return getTyped[[]uint](c, key) +} + +// GetUint8Slice returns the value associated with the key as a slice of uint8 integers. +func (c *Context) GetUint8Slice(key any) []uint8 { + return getTyped[[]uint8](c, key) +} + +// GetUint16Slice returns the value associated with the key as a slice of uint16 integers. +func (c *Context) GetUint16Slice(key any) []uint16 { + return getTyped[[]uint16](c, key) +} + +// GetUint32Slice returns the value associated with the key as a slice of uint32 integers. +func (c *Context) GetUint32Slice(key any) []uint32 { + return getTyped[[]uint32](c, key) +} + +// GetUint64Slice returns the value associated with the key as a slice of uint64 integers. +func (c *Context) GetUint64Slice(key any) []uint64 { + return getTyped[[]uint64](c, key) +} + +// GetFloat32Slice returns the value associated with the key as a slice of float32 numbers. +func (c *Context) GetFloat32Slice(key any) []float32 { + return getTyped[[]float32](c, key) +} + +// GetFloat64Slice returns the value associated with the key as a slice of float64 numbers. +func (c *Context) GetFloat64Slice(key any) []float64 { + return getTyped[[]float64](c, key) } // GetStringSlice returns the value associated with the key as a slice of strings. -func (c *Context) GetStringSlice(key string) (ss []string) { - if val, ok := c.Get(key); ok && val != nil { - ss, _ = val.([]string) - } - return +func (c *Context) GetStringSlice(key any) []string { + return getTyped[[]string](c, key) } // GetStringMap returns the value associated with the key as a map of interfaces. -func (c *Context) GetStringMap(key string) (sm map[string]any) { - if val, ok := c.Get(key); ok && val != nil { - sm, _ = val.(map[string]any) - } - return +func (c *Context) GetStringMap(key any) map[string]any { + return getTyped[map[string]any](c, key) } // GetStringMapString returns the value associated with the key as a map of strings. -func (c *Context) GetStringMapString(key string) (sms map[string]string) { - if val, ok := c.Get(key); ok && val != nil { - sms, _ = val.(map[string]string) - } - return +func (c *Context) GetStringMapString(key any) map[string]string { + return getTyped[map[string]string](c, key) } // GetStringMapStringSlice returns the value associated with the key as a map to a slice of strings. -func (c *Context) GetStringMapStringSlice(key string) (smss map[string][]string) { - if val, ok := c.Get(key); ok && val != nil { - smss, _ = val.(map[string][]string) +func (c *Context) GetStringMapStringSlice(key any) map[string][]string { + return getTyped[map[string][]string](c, key) +} + +// Delete deletes the key from the Context's Key map, if it exists. +// This operation is safe to be used by concurrent go-routines +func (c *Context) Delete(key any) { + c.mu.Lock() + defer c.mu.Unlock() + if c.Keys != nil { + delete(c.Keys, key) } - return } /************************************/ @@ -501,7 +590,7 @@ func (c *Context) QueryMap(key string) (dicts map[string]string) { // whether at least one value exists for the given key. func (c *Context) GetQueryMap(key string) (map[string]string, bool) { c.initQueryCache() - return c.get(c.queryCache, key) + return getMapFromFormData(c.queryCache, key) } // PostForm returns the specified key from a POST urlencoded form or multipart form @@ -574,22 +663,32 @@ func (c *Context) PostFormMap(key string) (dicts map[string]string) { // whether at least one value exists for the given key. func (c *Context) GetPostFormMap(key string) (map[string]string, bool) { c.initFormCache() - return c.get(c.formCache, key) + return getMapFromFormData(c.formCache, key) } -// get is an internal method and returns a map which satisfies conditions. -func (c *Context) get(m map[string][]string, key string) (map[string]string, bool) { - dicts := make(map[string]string) - exist := false +// getMapFromFormData return a map which satisfies conditions. +// It parses from data with bracket notation like "key[subkey]=value" into a map. +func getMapFromFormData(m map[string][]string, key string) (map[string]string, bool) { + d := make(map[string]string) + found := false + keyLen := len(key) + for k, v := range m { - if i := strings.IndexByte(k, '['); i >= 1 && k[0:i] == key { - if j := strings.IndexByte(k[i+1:], ']'); j >= 1 { - exist = true - dicts[k[i+1:][:j]] = v[0] - } + if len(k) < keyLen+3 { // key + "[" + at least one char + "]" + continue + } + + if k[:keyLen] != key || k[keyLen] != '[' { + continue + } + + if j := strings.IndexByte(k[keyLen+1:], ']'); j > 0 { + found = true + d[k[keyLen+1:keyLen+1+j]] = v[0] } } - return dicts, exist + + return d, found } // FormFile returns the first file for the provided form key. @@ -614,14 +713,22 @@ func (c *Context) MultipartForm() (*multipart.Form, error) { } // SaveUploadedFile uploads the form file to specific dst. -func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error { +func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, perm ...fs.FileMode) error { src, err := file.Open() if err != nil { return err } defer src.Close() - if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + var mode os.FileMode = 0o750 + if len(perm) > 0 { + mode = perm[0] + } + dir := filepath.Dir(dst) + if err = os.MkdirAll(dir, mode); err != nil { + return err + } + if err = os.Chmod(dir, mode); err != nil { return err } @@ -698,8 +805,19 @@ func (c *Context) BindUri(obj any) error { // It will abort the request with HTTP 400 if any error occurs. // See the binding package. func (c *Context) MustBindWith(obj any, b binding.Binding) error { - if err := c.ShouldBindWith(obj, b); err != nil { - c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) //nolint: errcheck + err := c.ShouldBindWith(obj, b) + if err != nil { + var maxBytesErr *http.MaxBytesError + + // Note: When using sonic or go-json as JSON encoder, they do not propagate the http.MaxBytesError error + // https://github.com/goccy/go-json/issues/485 + // https://github.com/bytedance/sonic/issues/800 + switch { + case errors.As(err, &maxBytesErr): + c.AbortWithError(http.StatusRequestEntityTooLarge, err).SetType(ErrorTypeBind) //nolint: errcheck + default: + c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) //nolint: errcheck + } return err } return nil @@ -720,41 +838,71 @@ func (c *Context) ShouldBind(obj any) error { } // ShouldBindJSON is a shortcut for c.ShouldBindWith(obj, binding.JSON). +// +// Example: +// +// POST /user +// Content-Type: application/json +// +// Request Body: +// { +// "name": "Manu", +// "age": 20 +// } +// +// type User struct { +// Name string `json:"name"` +// Age int `json:"age"` +// } +// +// var user User +// if err := c.ShouldBindJSON(&user); err != nil { +// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +// return +// } +// c.JSON(http.StatusOK, user) func (c *Context) ShouldBindJSON(obj any) error { return c.ShouldBindWith(obj, binding.JSON) } // ShouldBindXML is a shortcut for c.ShouldBindWith(obj, binding.XML). +// It works like ShouldBindJSON but binds the request body as XML data. func (c *Context) ShouldBindXML(obj any) error { return c.ShouldBindWith(obj, binding.XML) } // ShouldBindQuery is a shortcut for c.ShouldBindWith(obj, binding.Query). +// It works like ShouldBindJSON but binds query parameters from the URL. func (c *Context) ShouldBindQuery(obj any) error { return c.ShouldBindWith(obj, binding.Query) } // ShouldBindYAML is a shortcut for c.ShouldBindWith(obj, binding.YAML). +// It works like ShouldBindJSON but binds the request body as YAML data. func (c *Context) ShouldBindYAML(obj any) error { return c.ShouldBindWith(obj, binding.YAML) } // ShouldBindTOML is a shortcut for c.ShouldBindWith(obj, binding.TOML). +// It works like ShouldBindJSON but binds the request body as TOML data. func (c *Context) ShouldBindTOML(obj any) error { return c.ShouldBindWith(obj, binding.TOML) } // ShouldBindPlain is a shortcut for c.ShouldBindWith(obj, binding.Plain). +// It works like ShouldBindJSON but binds plain text data from the request body. func (c *Context) ShouldBindPlain(obj any) error { return c.ShouldBindWith(obj, binding.Plain) } // ShouldBindHeader is a shortcut for c.ShouldBindWith(obj, binding.Header). +// It works like ShouldBindJSON but binds values from HTTP headers. func (c *Context) ShouldBindHeader(obj any) error { return c.ShouldBindWith(obj, binding.Header) } // ShouldBindUri binds the passed struct pointer using the specified binding engine. +// It works like ShouldBindJSON but binds parameters from the URI. func (c *Context) ShouldBindUri(obj any) error { m := make(map[string][]string, len(c.Params)) for _, v := range c.Params { @@ -811,14 +959,14 @@ func (c *Context) ShouldBindBodyWithTOML(obj any) error { return c.ShouldBindBodyWith(obj, binding.TOML) } -// ShouldBindBodyWithJSON is a shortcut for c.ShouldBindBodyWith(obj, binding.JSON). +// ShouldBindBodyWithPlain is a shortcut for c.ShouldBindBodyWith(obj, binding.Plain). func (c *Context) ShouldBindBodyWithPlain(obj any) error { return c.ShouldBindBodyWith(obj, binding.Plain) } // ClientIP implements one best effort algorithm to return the real client IP. // It calls c.RemoteIP() under the hood, to check if the remote IP is a trusted proxy or not. -// If it is it will then try to parse the headers defined in Engine.RemoteIPHeaders (defaulting to [X-Forwarded-For, X-Real-Ip]). +// If it is it will then try to parse the headers defined in Engine.RemoteIPHeaders (defaulting to [X-Forwarded-For, X-Real-IP]). // If the headers are not syntactically valid OR the remote IP does not correspond to a trusted proxy, // the remote IP (coming from Request.RemoteAddr) is returned. func (c *Context) ClientIP() string { @@ -956,6 +1104,19 @@ func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, }) } +// SetCookieData adds a Set-Cookie header to the ResponseWriter's headers. +// It accepts a pointer to http.Cookie structure for more flexibility in setting cookie attributes. +// The provided cookie must have a valid Name. Invalid cookies may be silently dropped. +func (c *Context) SetCookieData(cookie *http.Cookie) { + if cookie.Path == "" { + cookie.Path = "/" + } + if cookie.SameSite == http.SameSiteDefaultMode { + cookie.SameSite = c.sameSite + } + http.SetCookie(c.Writer, cookie) +} + // Cookie returns the named cookie provided in the request or // ErrNoCookie if not found. And return the named cookie is unescaped. // If multiple cookies match the given name, only one cookie will @@ -1158,14 +1319,15 @@ func (c *Context) Stream(step func(w io.Writer) bool) bool { // Negotiate contains all negotiations data. type Negotiate struct { - Offered []string - HTMLName string - HTMLData any - JSONData any - XMLData any - YAMLData any - Data any - TOMLData any + Offered []string + HTMLName string + HTMLData any + JSONData any + XMLData any + YAMLData any + Data any + TOMLData any + PROTOBUFData any } // Negotiate calls different Render according to acceptable Accept format. @@ -1191,6 +1353,10 @@ func (c *Context) Negotiate(code int, config Negotiate) { data := chooseData(config.TOMLData, config.Data) c.TOML(code, data) + case binding.MIMEPROTOBUF: + data := chooseData(config.PROTOBUFData, config.Data) + c.ProtoBuf(code, data) + default: c.AbortWithError(http.StatusNotAcceptable, errors.New("the accepted formats are not offered by the server")) //nolint: errcheck } diff --git a/context_file_test.go b/context_file_test.go new file mode 100644 index 00000000..50cc3c8e --- /dev/null +++ b/context_file_test.go @@ -0,0 +1,35 @@ +package gin + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestContextFileSimple tests the Context.File() method with a simple case +func TestContextFileSimple(t *testing.T) { + // Test serving an existing file + testFile := "testdata/test_file.txt" + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + + c.File(testFile) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "This is a test file") + assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) +} + +// TestContextFileNotFound tests serving a non-existent file +func TestContextFileNotFound(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + + c.File("non_existent_file.txt") + + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/context_test.go b/context_test.go index 66190b30..126646fc 100644 --- a/context_test.go +++ b/context_test.go @@ -11,13 +11,16 @@ import ( "fmt" "html/template" "io" + "io/fs" "mime/multipart" "net" "net/http" "net/http/httptest" "net/url" "os" + "path/filepath" "reflect" + "strconv" "strings" "sync" "testing" @@ -25,6 +28,7 @@ import ( "github.com/gin-contrib/sse" "github.com/gin-gonic/gin/binding" + "github.com/gin-gonic/gin/codec/json" testdata "github.com/gin-gonic/gin/testdata/protoexample" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,7 +63,7 @@ func createMultipartRequest() *http.Request { must(mw.WriteField("time_location", "31/12/2016 14:55")) must(mw.WriteField("names[a]", "thinkerou")) must(mw.WriteField("names[b]", "tianou")) - req, err := http.NewRequest("POST", "/", body) + req, err := http.NewRequest(http.MethodPost, "/", body) must(err) req.Header.Set("Content-Type", MIMEMultipartPOSTForm+"; boundary="+boundary) return req @@ -71,6 +75,79 @@ func must(err error) { } } +// TestContextFile tests the Context.File() method +func TestContextFile(t *testing.T) { + // Test serving an existing file + t.Run("serve existing file", func(t *testing.T) { + // Create a temporary test file + testFile := "testdata/test_file.txt" + + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + + c.File(testFile) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "This is a test file") + assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) + }) + + // Test serving a non-existent file + t.Run("serve non-existent file", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + + c.File("non_existent_file.txt") + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + // Test serving a directory (should return 200 with directory listing or 403 Forbidden) + t.Run("serve directory", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + + c.File(".") + + // Directory serving can return either 200 (with listing) or 403 (forbidden) + assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusForbidden) + }) + + // Test with HEAD request + t.Run("HEAD request", func(t *testing.T) { + testFile := "testdata/test_file.txt" + + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodHead, "/test", nil) + + c.File(testFile) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, w.Body.String()) // HEAD request should not return body + assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) + }) + + // Test with Range request + t.Run("Range request", func(t *testing.T) { + testFile := "testdata/test_file.txt" + + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) + c.Request.Header.Set("Range", "bytes=0-10") + + c.File(testFile) + + assert.Equal(t, http.StatusPartialContent, w.Code) + assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) + assert.Contains(t, w.Header().Get("Content-Range"), "bytes 0-10") + }) +} + func TestContextFormFile(t *testing.T) { buf := new(bytes.Buffer) mw := multipart.NewWriter(buf) @@ -80,7 +157,7 @@ func TestContextFormFile(t *testing.T) { require.NoError(t, err) mw.Close() c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", buf) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) c.Request.Header.Set("Content-Type", mw.FormDataContentType()) f, err := c.FormFile("file") require.NoError(t, err) @@ -94,7 +171,7 @@ func TestContextFormFileFailed(t *testing.T) { mw := multipart.NewWriter(buf) mw.Close() c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Set("Content-Type", mw.FormDataContentType()) c.engine.MaxMultipartMemory = 8 << 20 f, err := c.FormFile("file") @@ -112,7 +189,7 @@ func TestContextMultipartForm(t *testing.T) { require.NoError(t, err) mw.Close() c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", buf) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) c.Request.Header.Set("Content-Type", mw.FormDataContentType()) f, err := c.MultipartForm() require.NoError(t, err) @@ -127,7 +204,7 @@ func TestSaveUploadedOpenFailed(t *testing.T) { mw.Close() c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", buf) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) c.Request.Header.Set("Content-Type", mw.FormDataContentType()) f := &multipart.FileHeader{ @@ -145,7 +222,7 @@ func TestSaveUploadedCreateFailed(t *testing.T) { require.NoError(t, err) mw.Close() c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", buf) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) c.Request.Header.Set("Content-Type", mw.FormDataContentType()) f, err := c.FormFile("file") require.NoError(t, err) @@ -154,6 +231,48 @@ func TestSaveUploadedCreateFailed(t *testing.T) { require.Error(t, c.SaveUploadedFile(f, "/")) } +func TestSaveUploadedFileWithPermission(t *testing.T) { + buf := new(bytes.Buffer) + mw := multipart.NewWriter(buf) + w, err := mw.CreateFormFile("file", "permission_test") + require.NoError(t, err) + _, err = w.Write([]byte("permission_test")) + require.NoError(t, err) + mw.Close() + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) + c.Request.Header.Set("Content-Type", mw.FormDataContentType()) + f, err := c.FormFile("file") + require.NoError(t, err) + assert.Equal(t, "permission_test", f.Filename) + var mode fs.FileMode = 0o755 + require.NoError(t, c.SaveUploadedFile(f, "permission_test", mode)) + t.Cleanup(func() { + assert.NoError(t, os.Remove("permission_test")) + }) + info, err := os.Stat(filepath.Dir("permission_test")) + require.NoError(t, err) + assert.Equal(t, info.Mode().Perm(), mode) +} + +func TestSaveUploadedFileWithPermissionFailed(t *testing.T) { + buf := new(bytes.Buffer) + mw := multipart.NewWriter(buf) + w, err := mw.CreateFormFile("file", "permission_test") + require.NoError(t, err) + _, err = w.Write([]byte("permission_test")) + require.NoError(t, err) + mw.Close() + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) + c.Request.Header.Set("Content-Type", mw.FormDataContentType()) + f, err := c.FormFile("file") + require.NoError(t, err) + assert.Equal(t, "permission_test", f.Filename) + var mode fs.FileMode = 0o644 + require.Error(t, c.SaveUploadedFile(f, "test/permission_test", mode)) +} + func TestContextReset(t *testing.T) { router := New() c := router.allocateContext(0) @@ -173,7 +292,7 @@ func TestContextReset(t *testing.T) { assert.Empty(t, c.Errors.Errors()) assert.Empty(t, c.Errors.ByType(ErrorTypeAny)) assert.Empty(t, c.Params) - assert.EqualValues(t, c.index, -1) + assert.EqualValues(t, -1, c.index) assert.Equal(t, c.Writer.(*responseWriter), &c.writermem) } @@ -211,7 +330,46 @@ func TestContextSetGet(t *testing.T) { assert.False(t, err) assert.Equal(t, "bar", c.MustGet("foo")) - assert.Panics(t, func() { c.MustGet("no_exist") }) + assert.Panicsf(t, func() { + c.MustGet("no_exist") + }, "key no_exist does not exist") +} + +func TestContextSetGetAnyKey(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + type key struct{} + + tests := []struct { + key any + }{ + {1}, + {int32(1)}, + {int64(1)}, + {uint(1)}, + {float32(1)}, + {key{}}, + {&key{}}, + } + + for _, tt := range tests { + t.Run(reflect.TypeOf(tt.key).String(), func(t *testing.T) { + c.Set(tt.key, 1) + value, ok := c.Get(tt.key) + assert.True(t, ok) + assert.Equal(t, 1, value) + }) + } +} + +func TestContextSetGetPanicsWhenKeyNotComparable(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + assert.Panics(t, func() { + c.Set([]int{1}, 1) + c.Set(func() {}, 1) + c.Set(make(chan int), 1) + }) } func TestContextSetGetValues(t *testing.T) { @@ -226,7 +384,7 @@ func TestContextSetGetValues(t *testing.T) { c.Set("intInterface", a) assert.Exactly(t, "this is a string", c.MustGet("string").(string)) - assert.Exactly(t, c.MustGet("int32").(int32), int32(-42)) + assert.Exactly(t, int32(-42), c.MustGet("int32").(int32)) assert.Exactly(t, int64(42424242424242), c.MustGet("int64").(int64)) assert.Exactly(t, uint64(42), c.MustGet("uint64").(uint64)) assert.InDelta(t, float32(4.2), c.MustGet("float32").(float32), 0.01) @@ -246,12 +404,49 @@ func TestContextSetGetBool(t *testing.T) { assert.True(t, c.GetBool("bool")) } +func TestSetGetDelete(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "example-key" + value := "example-value" + c.Set(key, value) + val, exists := c.Get(key) + assert.True(t, exists) + assert.Equal(t, val, value) + c.Delete(key) + _, exists = c.Get(key) + assert.False(t, exists) +} + func TestContextGetInt(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Set("int", 1) assert.Equal(t, 1, c.GetInt("int")) } +func TestContextGetInt8(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int8" + value := int8(0x7F) + c.Set(key, value) + assert.Equal(t, value, c.GetInt8(key)) +} + +func TestContextGetInt16(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int16" + value := int16(0x7FFF) + c.Set(key, value) + assert.Equal(t, value, c.GetInt16(key)) +} + +func TestContextGetInt32(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int32" + value := int32(0x7FFFFFFF) + c.Set(key, value) + assert.Equal(t, value, c.GetInt32(key)) +} + func TestContextGetInt64(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Set("int64", int64(42424242424242)) @@ -264,12 +459,44 @@ func TestContextGetUint(t *testing.T) { assert.Equal(t, uint(1), c.GetUint("uint")) } +func TestContextGetUint8(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint8" + value := uint8(0xFF) + c.Set(key, value) + assert.Equal(t, value, c.GetUint8(key)) +} + +func TestContextGetUint16(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint16" + value := uint16(0xFFFF) + c.Set(key, value) + assert.Equal(t, value, c.GetUint16(key)) +} + +func TestContextGetUint32(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint32" + value := uint32(0xFFFFFFFF) + c.Set(key, value) + assert.Equal(t, value, c.GetUint32(key)) +} + func TestContextGetUint64(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Set("uint64", uint64(18446744073709551615)) assert.Equal(t, uint64(18446744073709551615), c.GetUint64("uint64")) } +func TestContextGetFloat32(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "float32" + value := float32(3.14) + c.Set(key, value) + assert.InDelta(t, value, c.GetFloat32(key), 0.01) +} + func TestContextGetFloat64(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Set("float64", 4.2) @@ -289,6 +516,102 @@ func TestContextGetDuration(t *testing.T) { assert.Equal(t, time.Second, c.GetDuration("duration")) } +func TestContextGetIntSlice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int-slice" + value := []int{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetIntSlice(key)) +} + +func TestContextGetInt8Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int8-slice" + value := []int8{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetInt8Slice(key)) +} + +func TestContextGetInt16Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int16-slice" + value := []int16{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetInt16Slice(key)) +} + +func TestContextGetInt32Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int32-slice" + value := []int32{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetInt32Slice(key)) +} + +func TestContextGetInt64Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "int64-slice" + value := []int64{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetInt64Slice(key)) +} + +func TestContextGetUintSlice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint-slice" + value := []uint{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetUintSlice(key)) +} + +func TestContextGetUint8Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint8-slice" + value := []uint8{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetUint8Slice(key)) +} + +func TestContextGetUint16Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint16-slice" + value := []uint16{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetUint16Slice(key)) +} + +func TestContextGetUint32Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint32-slice" + value := []uint32{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetUint32Slice(key)) +} + +func TestContextGetUint64Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "uint64-slice" + value := []uint64{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetUint64Slice(key)) +} + +func TestContextGetFloat32Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "float32-slice" + value := []float32{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetFloat32Slice(key)) +} + +func TestContextGetFloat64Slice(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + key := "float64-slice" + value := []float64{1, 2} + c.Set(key, value) + assert.Equal(t, value, c.GetFloat64Slice(key)) +} + func TestContextGetStringSlice(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Set("slice", []string{"foo"}) @@ -328,7 +651,7 @@ func TestContextGetStringMapStringSlice(t *testing.T) { func TestContextCopy(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.index = 2 - c.Request, _ = http.NewRequest("POST", "/hola", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/hola", nil) c.handlers = HandlersChain{func(c *Context) {}} c.Params = Params{Param{Key: "foo", Value: "bar"}} c.Set("foo", "bar") @@ -385,7 +708,7 @@ func TestContextHandler(t *testing.T) { func TestContextQuery(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("GET", "http://example.com/?foo=bar&page=10&id=", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/?foo=bar&page=10&id=", nil) value, ok := c.GetQuery("foo") assert.True(t, ok) @@ -458,7 +781,6 @@ func TestContextInitQueryCache(t *testing.T) { assert.Equal(t, test.expectedQueryCache, test.testContext.queryCache) }) } - } func TestContextDefaultQueryOnEmptyRequest(t *testing.T) { @@ -478,8 +800,8 @@ func TestContextDefaultQueryOnEmptyRequest(t *testing.T) { func TestContextQueryAndPostForm(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - body := bytes.NewBufferString("foo=bar&page=11&both=&foo=second") - c.Request, _ = http.NewRequest("POST", + body := strings.NewReader("foo=bar&page=11&both=&foo=second") + c.Request, _ = http.NewRequest(http.MethodPost, "/?both=GET&id=main&id=omit&array[]=first&array[]=second&ids[a]=hi&ids[b]=3.14", body) c.Request.Header.Add("Content-Type", MIMEPOSTForm) @@ -499,7 +821,7 @@ func TestContextQueryAndPostForm(t *testing.T) { assert.Empty(t, value) assert.Empty(t, c.PostForm("both")) assert.Empty(t, c.DefaultPostForm("both", "nothing")) - assert.Equal(t, "GET", c.Query("both"), "GET") + assert.Equal(t, http.MethodGet, c.Query("both"), http.MethodGet) value, ok = c.GetQuery("id") assert.True(t, ok) @@ -547,7 +869,7 @@ func TestContextQueryAndPostForm(t *testing.T) { values = c.QueryArray("both") assert.Len(t, values, 1) - assert.Equal(t, "GET", values[0]) + assert.Equal(t, http.MethodGet, values[0]) dicts, ok := c.GetQueryMap("ids") assert.True(t, ok) @@ -682,7 +1004,7 @@ func TestContextSetCookiePathEmpty(t *testing.T) { func TestContextGetCookie(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("GET", "/get", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/get", nil) c.Request.Header.Set("Cookie", "user=gin") cookie, _ := c.Cookie("user") assert.Equal(t, "gin", cookie) @@ -692,10 +1014,10 @@ func TestContextGetCookie(t *testing.T) { } func TestContextBodyAllowedForStatus(t *testing.T) { - assert.False(t, false, bodyAllowedForStatus(http.StatusProcessing)) - assert.False(t, false, bodyAllowedForStatus(http.StatusNoContent)) - assert.False(t, false, bodyAllowedForStatus(http.StatusNotModified)) - assert.True(t, true, bodyAllowedForStatus(http.StatusInternalServerError)) + assert.False(t, bodyAllowedForStatus(http.StatusProcessing)) + assert.False(t, bodyAllowedForStatus(http.StatusNoContent)) + assert.False(t, bodyAllowedForStatus(http.StatusNotModified)) + assert.True(t, bodyAllowedForStatus(http.StatusInternalServerError)) } type TestRender struct{} @@ -725,7 +1047,7 @@ func TestContextRenderJSON(t *testing.T) { c.JSON(http.StatusCreated, H{"foo": "bar", "html": ""}) assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, "{\"foo\":\"bar\",\"html\":\"\\u003cb\\u003e\"}", w.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\",\"html\":\"\\u003cb\\u003e\"}", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -734,7 +1056,7 @@ func TestContextRenderJSON(t *testing.T) { func TestContextRenderJSONP(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "http://example.com/?callback=x", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/?callback=x", nil) c.JSONP(http.StatusCreated, H{"foo": "bar"}) @@ -748,12 +1070,12 @@ func TestContextRenderJSONP(t *testing.T) { func TestContextRenderJSONPWithoutCallback(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "http://example.com", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com", nil) c.JSONP(http.StatusCreated, H{"foo": "bar"}) assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, "{\"foo\":\"bar\"}", w.Body.String()) + assert.JSONEq(t, `{"foo":"bar"}`, w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -779,7 +1101,7 @@ func TestContextRenderAPIJSON(t *testing.T) { c.JSON(http.StatusCreated, H{"foo": "bar"}) assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, "{\"foo\":\"bar\"}", w.Body.String()) + assert.JSONEq(t, `{"foo":"bar"}`, w.Body.String()) assert.Equal(t, "application/vnd.api+json", w.Header().Get("Content-Type")) } @@ -805,7 +1127,7 @@ func TestContextRenderIndentedJSON(t *testing.T) { c.IndentedJSON(http.StatusCreated, H{"foo": "bar", "bar": "foo", "nested": H{"foo": "bar"}}) assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, "{\n \"bar\": \"foo\",\n \"foo\": \"bar\",\n \"nested\": {\n \"foo\": \"bar\"\n }\n}", w.Body.String()) + assert.JSONEq(t, "{\n \"bar\": \"foo\",\n \"foo\": \"bar\",\n \"nested\": {\n \"foo\": \"bar\"\n }\n}", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -866,7 +1188,7 @@ func TestContextRenderPureJSON(t *testing.T) { c, _ := CreateTestContext(w) c.PureJSON(http.StatusCreated, H{"foo": "bar", "html": ""}) assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, "{\"foo\":\"bar\",\"html\":\"\"}\n", w.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\",\"html\":\"\"}\n", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -891,7 +1213,7 @@ func TestContextRenderHTML2(t *testing.T) { c, router := CreateTestContext(w) // print debug warning log when Engine.trees > 0 - router.addRoute("GET", "/", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodGet, "/", HandlersChain{func(_ *Context) {}}) assert.Len(t, router.trees, 1) templ := template.Must(template.New("t").Parse(`Hello {{.name}}`)) @@ -924,7 +1246,7 @@ func TestContextRenderNoContentHTML(t *testing.T) { assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) } -// TestContextXML tests that the response is serialized as XML +// TestContextRenderXML tests that the response is serialized as XML // and Content-Type is set to application/xml func TestContextRenderXML(t *testing.T) { w := httptest.NewRecorder() @@ -949,7 +1271,7 @@ func TestContextRenderNoContentXML(t *testing.T) { assert.Equal(t, "application/xml; charset=utf-8", w.Header().Get("Content-Type")) } -// TestContextString tests that the response is returned +// TestContextRenderString tests that the response is returned // with Content-Type set to text/plain func TestContextRenderString(t *testing.T) { w := httptest.NewRecorder() @@ -974,7 +1296,7 @@ func TestContextRenderNoContentString(t *testing.T) { assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) } -// TestContextString tests that the response is returned +// TestContextRenderHTMLString tests that the response is returned // with Content-Type set to text/html func TestContextRenderHTMLString(t *testing.T) { w := httptest.NewRecorder() @@ -1001,7 +1323,7 @@ func TestContextRenderNoContentHTMLString(t *testing.T) { assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) } -// TestContextData tests that the response can be written from `bytestring` +// TestContextRenderData tests that the response can be written from `bytestring` // with specified MIME type func TestContextRenderData(t *testing.T) { w := httptest.NewRecorder() @@ -1040,35 +1362,35 @@ func TestContextRenderSSE(t *testing.T) { "bar": "foo", }) - assert.Equal(t, strings.Replace(w.Body.String(), " ", "", -1), strings.Replace("event:float\ndata:1.5\n\nid:123\ndata:text\n\nevent:chat\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n", " ", "", -1)) + assert.Equal(t, strings.ReplaceAll(w.Body.String(), " ", ""), strings.ReplaceAll("event:float\ndata:1.5\n\nid:123\ndata:text\n\nevent:chat\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n", " ", "")) } func TestContextRenderFile(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) c.File("./gin.go") assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "func New(opts ...OptionFunc) *Engine {") // Content-Type='text/plain; charset=utf-8' when go version <= 1.16, // else, Content-Type='text/x-go; charset=utf-8' - assert.NotEqual(t, "", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Header().Get("Content-Type")) } func TestContextRenderFileFromFS(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/some/path", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/some/path", nil) c.FileFromFS("./gin.go", Dir(".", false)) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "func New(opts ...OptionFunc) *Engine {") // Content-Type='text/plain; charset=utf-8' when go version <= 1.16, // else, Content-Type='text/x-go; charset=utf-8' - assert.NotEqual(t, "", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Header().Get("Content-Type")) assert.Equal(t, "/some/path", c.Request.URL.Path) } @@ -1077,7 +1399,7 @@ func TestContextRenderAttachment(t *testing.T) { c, _ := CreateTestContext(w) newFilename := "new_filename.go" - c.Request, _ = http.NewRequest("GET", "/", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) c.FileAttachment("./gin.go", newFilename) assert.Equal(t, 200, w.Code) @@ -1091,7 +1413,7 @@ func TestContextRenderAndEscapeAttachment(t *testing.T) { maliciousFilename := "tampering_field.sh\"; \\\"; dummy=.go" actualEscapedResponseFilename := "tampering_field.sh\\\"; \\\\\\\"; dummy=.go" - c.Request, _ = http.NewRequest("GET", "/", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) c.FileAttachment("./gin.go", maliciousFilename) assert.Equal(t, 200, w.Code) @@ -1104,7 +1426,7 @@ func TestContextRenderUTF8Attachment(t *testing.T) { c, _ := CreateTestContext(w) newFilename := "new🧡_filename.go" - c.Request, _ = http.NewRequest("GET", "/", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) c.FileAttachment("./gin.go", newFilename) assert.Equal(t, 200, w.Code) @@ -1183,7 +1505,7 @@ func TestContextRenderRedirectWithRelativePath(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "http://example.com", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) assert.Panics(t, func() { c.Redirect(299, "/new_path") }) assert.Panics(t, func() { c.Redirect(309, "/new_path") }) @@ -1197,7 +1519,7 @@ func TestContextRenderRedirectWithAbsolutePath(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "http://example.com", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) c.Redirect(http.StatusFound, "http://google.com") c.Writer.WriteHeaderNow() @@ -1209,7 +1531,7 @@ func TestContextRenderRedirectWith201(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "http://example.com", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) c.Redirect(http.StatusCreated, "/resource") c.Writer.WriteHeaderNow() @@ -1219,7 +1541,7 @@ func TestContextRenderRedirectWith201(t *testing.T) { func TestContextRenderRedirectAll(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "http://example.com", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) assert.Panics(t, func() { c.Redirect(http.StatusOK, "/resource") }) assert.Panics(t, func() { c.Redirect(http.StatusAccepted, "/resource") }) assert.Panics(t, func() { c.Redirect(299, "/resource") }) @@ -1231,7 +1553,7 @@ func TestContextRenderRedirectAll(t *testing.T) { func TestContextNegotiationWithJSON(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) c.Negotiate(http.StatusOK, Negotiate{ Offered: []string{MIMEJSON, MIMEXML, MIMEYAML, MIMEYAML2}, @@ -1239,14 +1561,14 @@ func TestContextNegotiationWithJSON(t *testing.T) { }) assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "{\"foo\":\"bar\"}", w.Body.String()) + assert.JSONEq(t, `{"foo":"bar"}`, w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } func TestContextNegotiationWithXML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) c.Negotiate(http.StatusOK, Negotiate{ Offered: []string{MIMEXML, MIMEJSON, MIMEYAML, MIMEYAML2}, @@ -1261,7 +1583,7 @@ func TestContextNegotiationWithXML(t *testing.T) { func TestContextNegotiationWithYAML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) c.Negotiate(http.StatusOK, Negotiate{ Offered: []string{MIMEYAML, MIMEXML, MIMEJSON, MIMETOML, MIMEYAML2}, @@ -1276,7 +1598,7 @@ func TestContextNegotiationWithYAML(t *testing.T) { func TestContextNegotiationWithTOML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) c.Negotiate(http.StatusOK, Negotiate{ Offered: []string{MIMETOML, MIMEXML, MIMEJSON, MIMEYAML, MIMEYAML2}, @@ -1291,7 +1613,7 @@ func TestContextNegotiationWithTOML(t *testing.T) { func TestContextNegotiationWithHTML(t *testing.T) { w := httptest.NewRecorder() c, router := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) templ := template.Must(template.New("t").Parse(`Hello {{.name}}`)) router.SetHTMLTemplate(templ) @@ -1306,10 +1628,36 @@ func TestContextNegotiationWithHTML(t *testing.T) { assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) } +func TestContextNegotiationWithPROTOBUF(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + reps := []int64{int64(1), int64(2)} + label := "test" + data := &testdata.Test{ + Label: &label, + Reps: reps, + } + + c.Negotiate(http.StatusCreated, Negotiate{ + Offered: []string{MIMEPROTOBUF, MIMEJSON, MIMEXML}, + Data: data, + }) + + // Marshal original data for comparison + protoData, err := proto.Marshal(data) + require.NoError(t, err) + + assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, string(protoData), w.Body.String()) + assert.Equal(t, "application/x-protobuf", w.Header().Get("Content-Type")) +} + func TestContextNegotiationNotSupport(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) c.Negotiate(http.StatusOK, Negotiate{ Offered: []string{MIMEPOSTForm}, @@ -1322,16 +1670,16 @@ func TestContextNegotiationNotSupport(t *testing.T) { func TestContextNegotiationFormat(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "", nil) assert.Panics(t, func() { c.NegotiateFormat() }) - assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON, MIMEXML)) + assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON, MIMEXML)) //nolint:testifylint assert.Equal(t, MIMEHTML, c.NegotiateFormat(MIMEHTML, MIMEJSON)) } func TestContextNegotiationFormatWithAccept(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9;q=0.8") assert.Equal(t, MIMEXML, c.NegotiateFormat(MIMEJSON, MIMEXML)) @@ -1341,47 +1689,47 @@ func TestContextNegotiationFormatWithAccept(t *testing.T) { func TestContextNegotiationFormatWithWildcardAccept(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("Accept", "*/*") assert.Equal(t, "*/*", c.NegotiateFormat("*/*")) assert.Equal(t, "text/*", c.NegotiateFormat("text/*")) assert.Equal(t, "application/*", c.NegotiateFormat("application/*")) - assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON)) + assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON)) //nolint:testifylint assert.Equal(t, MIMEXML, c.NegotiateFormat(MIMEXML)) assert.Equal(t, MIMEHTML, c.NegotiateFormat(MIMEHTML)) c, _ = CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("Accept", "text/*") assert.Equal(t, "*/*", c.NegotiateFormat("*/*")) assert.Equal(t, "text/*", c.NegotiateFormat("text/*")) - assert.Equal(t, "", c.NegotiateFormat("application/*")) - assert.Equal(t, "", c.NegotiateFormat(MIMEJSON)) - assert.Equal(t, "", c.NegotiateFormat(MIMEXML)) + assert.Empty(t, c.NegotiateFormat("application/*")) + assert.Empty(t, c.NegotiateFormat(MIMEJSON)) + assert.Empty(t, c.NegotiateFormat(MIMEXML)) assert.Equal(t, MIMEHTML, c.NegotiateFormat(MIMEHTML)) } func TestContextNegotiationFormatCustom(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9;q=0.8") c.Accepted = nil c.SetAccepted(MIMEJSON, MIMEXML) - assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON, MIMEXML)) + assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON, MIMEXML)) //nolint:testifylint assert.Equal(t, MIMEXML, c.NegotiateFormat(MIMEXML, MIMEHTML)) - assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON)) + assert.Equal(t, MIMEJSON, c.NegotiateFormat(MIMEJSON)) //nolint:testifylint } func TestContextNegotiationFormat2(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("Accept", "image/tiff-fx") - assert.Equal(t, "", c.NegotiateFormat("image/tiff")) + assert.Empty(t, c.NegotiateFormat("image/tiff")) } func TestContextIsAborted(t *testing.T) { @@ -1398,7 +1746,7 @@ func TestContextIsAborted(t *testing.T) { assert.True(t, c.IsAborted()) } -// TestContextData tests that the response can be written from `bytestring` +// TestContextAbortWithStatus tests that the response can be written from `bytestring` // with specified MIME type func TestContextAbortWithStatus(t *testing.T) { w := httptest.NewRecorder() @@ -1441,7 +1789,33 @@ func TestContextAbortWithStatusJSON(t *testing.T) { _, err := buf.ReadFrom(w.Body) require.NoError(t, err) jsonStringBody := buf.String() - assert.Equal(t, "{\"foo\":\"fooValue\",\"bar\":\"barValue\"}", jsonStringBody) + assert.JSONEq(t, "{\"foo\":\"fooValue\",\"bar\":\"barValue\"}", jsonStringBody) +} + +func TestContextAbortWithStatusPureJSON(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.index = 4 + + in := new(testJSONAbortMsg) + in.Bar = "barValue" + in.Foo = "fooValue" + + c.AbortWithStatusPureJSON(http.StatusUnsupportedMediaType, in) + + assert.Equal(t, abortIndex, c.index) + assert.Equal(t, http.StatusUnsupportedMediaType, c.Writer.Status()) + assert.Equal(t, http.StatusUnsupportedMediaType, w.Code) + assert.True(t, c.IsAborted()) + + contentType := w.Header().Get("Content-Type") + assert.Equal(t, "application/json; charset=utf-8", contentType) + + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(w.Body) + require.NoError(t, err) + jsonStringBody := buf.String() + assert.JSONEq(t, "{\"foo\":\"fooValue\",\"bar\":\"barValue\"}", jsonStringBody) } func TestContextError(t *testing.T) { @@ -1506,7 +1880,7 @@ func TestContextAbortWithError(t *testing.T) { func TestContextClientIP(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.engine.trustedCIDRs, _ = c.engine.prepareTrustedCIDRs() resetContextForClientIPTests(c) @@ -1649,15 +2023,46 @@ func resetContextForClientIPTests(c *Context) { func TestContextContentType(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Set("Content-Type", "application/json; charset=utf-8") assert.Equal(t, "application/json", c.ContentType()) } +func TestContextBindRequestTooLarge(t *testing.T) { + // When using go-json as JSON encoder, they do not propagate the http.MaxBytesError error + // The response will fail with a generic 400 instead of 413 + // https://github.com/goccy/go-json/issues/485 + var expectedCode int + switch json.Package { + case "github.com/goccy/go-json": + expectedCode = http.StatusBadRequest + default: + expectedCode = http.StatusRequestEntityTooLarge + } + + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10) + + var obj struct { + Foo string `json:"foo"` + Bar string `json:"bar"` + } + require.Error(t, c.BindJSON(&obj)) + c.Writer.WriteHeaderNow() + + assert.Empty(t, obj.Bar) + assert.Empty(t, obj.Foo) + assert.Equal(t, expectedCode, w.Code) + assert.True(t, c.IsAborted()) +} + func TestContextAutoBindJSON(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) c.Request.Header.Add("Content-Type", MIMEJSON) var obj struct { @@ -1674,7 +2079,7 @@ func TestContextBindWithJSON(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) c.Request.Header.Add("Content-Type", MIMEXML) // set fake content-type var obj struct { @@ -1691,7 +2096,7 @@ func TestContextBindWithXML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(` + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(` FOO BAR @@ -1712,7 +2117,7 @@ func TestContextBindPlain(t *testing.T) { // string w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(`test string`)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`test string`)) c.Request.Header.Add("Content-Type", MIMEPlain) var s string @@ -1722,7 +2127,7 @@ func TestContextBindPlain(t *testing.T) { assert.Equal(t, 0, w.Body.Len()) // []byte - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(`test []byte`)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`test []byte`)) c.Request.Header.Add("Content-Type", MIMEPlain) var bs []byte @@ -1736,7 +2141,7 @@ func TestContextBindHeader(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("rate", "8000") c.Request.Header.Add("domain", "music") c.Request.Header.Add("limit", "1000") @@ -1758,7 +2163,7 @@ func TestContextBindWithQuery(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/?foo=bar&bar=foo", bytes.NewBufferString("foo=unused")) + c.Request, _ = http.NewRequest(http.MethodPost, "/?foo=bar&bar=foo", strings.NewReader("foo=unused")) var obj struct { Foo string `form:"foo"` @@ -1774,7 +2179,7 @@ func TestContextBindWithYAML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("foo: bar\nbar: foo")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("foo: bar\nbar: foo")) c.Request.Header.Add("Content-Type", MIMEXML) // set fake content-type var obj struct { @@ -1791,7 +2196,7 @@ func TestContextBindWithTOML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("foo = 'bar'\nbar = 'foo'")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("foo = 'bar'\nbar = 'foo'")) c.Request.Header.Add("Content-Type", MIMEXML) // set fake content-type var obj struct { @@ -1808,7 +2213,7 @@ func TestContextBadAutoBind(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "http://example.com", bytes.NewBufferString("\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", strings.NewReader("\"foo\":\"bar\", \"bar\":\"foo\"}")) c.Request.Header.Add("Content-Type", MIMEJSON) var obj struct { Foo string `json:"foo"` @@ -1827,7 +2232,7 @@ func TestContextBadAutoBind(t *testing.T) { func TestContextAutoShouldBindJSON(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) c.Request.Header.Add("Content-Type", MIMEJSON) var obj struct { @@ -1844,7 +2249,7 @@ func TestContextShouldBindWithJSON(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) c.Request.Header.Add("Content-Type", MIMEXML) // set fake content-type var obj struct { @@ -1861,7 +2266,7 @@ func TestContextShouldBindWithXML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(` + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(` FOO BAR @@ -1882,7 +2287,7 @@ func TestContextShouldBindPlain(t *testing.T) { // string w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(`test string`)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`test string`)) c.Request.Header.Add("Content-Type", MIMEPlain) var s string @@ -1892,7 +2297,7 @@ func TestContextShouldBindPlain(t *testing.T) { assert.Equal(t, 0, w.Body.Len()) // []byte - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(`test []byte`)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`test []byte`)) c.Request.Header.Add("Content-Type", MIMEPlain) var bs []byte @@ -1906,7 +2311,7 @@ func TestContextShouldBindHeader(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.Header.Add("rate", "8000") c.Request.Header.Add("domain", "music") c.Request.Header.Add("limit", "1000") @@ -1928,7 +2333,7 @@ func TestContextShouldBindWithQuery(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/?foo=bar&bar=foo&Foo=bar1&Bar=foo1", bytes.NewBufferString("foo=unused")) + c.Request, _ = http.NewRequest(http.MethodPost, "/?foo=bar&bar=foo&Foo=bar1&Bar=foo1", strings.NewReader("foo=unused")) var obj struct { Foo string `form:"foo"` @@ -1948,7 +2353,7 @@ func TestContextShouldBindWithYAML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("foo: bar\nbar: foo")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("foo: bar\nbar: foo")) c.Request.Header.Add("Content-Type", MIMEXML) // set fake content-type var obj struct { @@ -1965,7 +2370,7 @@ func TestContextShouldBindWithTOML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("foo='bar'\nbar= 'foo'")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("foo='bar'\nbar= 'foo'")) c.Request.Header.Add("Content-Type", MIMETOML) // set fake content-type var obj struct { @@ -1982,7 +2387,7 @@ func TestContextBadAutoShouldBind(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "http://example.com", bytes.NewBufferString("\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", strings.NewReader(`"foo":"bar", "bar":"foo"}`)) c.Request.Header.Add("Content-Type", MIMEJSON) var obj struct { Foo string `json:"foo"` @@ -2046,7 +2451,7 @@ func TestContextShouldBindBodyWith(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) c.Request, _ = http.NewRequest( - "POST", "http://example.com", bytes.NewBufferString(tt.bodyA), + http.MethodPost, "http://example.com", strings.NewReader(tt.bodyA), ) // When it binds to typeA and typeB, it finds the body is // not typeB but typeA. @@ -2064,7 +2469,7 @@ func TestContextShouldBindBodyWith(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) c.Request, _ = http.NewRequest( - "POST", "http://example.com", bytes.NewBufferString(tt.bodyB), + http.MethodPost, "http://example.com", strings.NewReader(tt.bodyB), ) objA := typeA{} require.Error(t, c.ShouldBindBodyWith(&objA, tt.bindingA)) @@ -2111,7 +2516,7 @@ func TestContextShouldBindBodyWithJSON(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tt.body)) type typeJSON struct { Foo string `json:"foo" binding:"required"` @@ -2175,7 +2580,7 @@ func TestContextShouldBindBodyWithXML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tt.body)) type typeXML struct { Foo string `xml:"foo" binding:"required"` @@ -2239,7 +2644,7 @@ func TestContextShouldBindBodyWithYAML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tt.body)) type typeYAML struct { Foo string `yaml:"foo" binding:"required"` @@ -2304,7 +2709,7 @@ func TestContextShouldBindBodyWithTOML(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tt.body)) type typeTOML struct { Foo string `toml:"foo" binding:"required"` @@ -2373,7 +2778,7 @@ func TestContextShouldBindBodyWithPlain(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tt.body)) type typeJSON struct { Foo string `json:"foo" binding:"required"` @@ -2410,7 +2815,7 @@ func TestContextShouldBindBodyWithPlain(t *testing.T) { func TestContextGolangContext(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) require.NoError(t, c.Err()) assert.Nil(t, c.Done()) ti, ok := c.Deadline() @@ -2428,7 +2833,7 @@ func TestContextGolangContext(t *testing.T) { func TestWebsocketsRequired(t *testing.T) { // Example request from spec: https://tools.ietf.org/html/rfc6455#section-1.2 c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("GET", "/chat", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/chat", nil) c.Request.Header.Set("Host", "server.example.com") c.Request.Header.Set("Upgrade", "websocket") c.Request.Header.Set("Connection", "Upgrade") @@ -2441,7 +2846,7 @@ func TestWebsocketsRequired(t *testing.T) { // Normal request, no websocket required. c, _ = CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("GET", "/chat", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/chat", nil) c.Request.Header.Set("Host", "server.example.com") assert.False(t, c.IsWebsocket()) @@ -2449,7 +2854,7 @@ func TestWebsocketsRequired(t *testing.T) { func TestGetRequestHeaderValue(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("GET", "/chat", nil) + c.Request, _ = http.NewRequest(http.MethodGet, "/chat", nil) c.Request.Header.Set("Gin-Version", "1.0.0") assert.Equal(t, "1.0.0", c.GetHeader("Gin-Version")) @@ -2458,8 +2863,8 @@ func TestGetRequestHeaderValue(t *testing.T) { func TestContextGetRawData(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - body := bytes.NewBufferString("Fetch binary post data") - c.Request, _ = http.NewRequest("POST", "/", body) + body := strings.NewReader("Fetch binary post data") + c.Request, _ = http.NewRequest(http.MethodPost, "/", body) c.Request.Header.Add("Content-Type", MIMEPOSTForm) data, err := c.GetRawData() @@ -2482,7 +2887,7 @@ func TestContextRenderDataFromReader(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, body, w.Body.String()) assert.Equal(t, contentType, w.Header().Get("Content-Type")) - assert.Equal(t, fmt.Sprintf("%d", contentLength), w.Header().Get("Content-Length")) + assert.Equal(t, strconv.FormatInt(contentLength, 10), w.Header().Get("Content-Length")) assert.Equal(t, extraHeaders["Content-Disposition"], w.Header().Get("Content-Disposition")) } @@ -2500,7 +2905,7 @@ func TestContextRenderDataFromReaderNoHeaders(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, body, w.Body.String()) assert.Equal(t, contentType, w.Header().Get("Content-Type")) - assert.Equal(t, fmt.Sprintf("%d", contentLength), w.Header().Get("Content-Length")) + assert.Equal(t, strconv.FormatInt(contentLength, 10), w.Header().Get("Content-Length")) } type TestResponseRecorder struct { @@ -2588,8 +2993,8 @@ func TestRaceParamsContextCopy(t *testing.T) { }(c.Copy(), c.Param("name")) }) } - PerformRequest(router, "GET", "/name1/api") - PerformRequest(router, "GET", "/name2/api") + PerformRequest(router, http.MethodGet, "/name1/api") + PerformRequest(router, http.MethodGet, "/name2/api") wg.Wait() } @@ -2608,7 +3013,7 @@ func TestContextWithKeysMutex(t *testing.T) { func TestRemoteIPFail(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request.RemoteAddr = "[:::]:80" ip := net.ParseIP(c.RemoteIP()) trust := c.engine.isTrustedProxy(ip) @@ -2705,11 +3110,12 @@ func TestContextWithFallbackValueFromRequestContext(t *testing.T) { { name: "c with struct context key", getContextAndKey: func() (*Context, any) { - var key struct{} + type KeyStruct struct{} // https://staticcheck.dev/docs/checks/#SA1029 + var key KeyStruct c, _ := CreateTestContext(httptest.NewRecorder()) // enable ContextWithFallback feature flag c.engine.ContextWithFallback = true - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request = c.Request.WithContext(context.WithValue(context.TODO(), key, "value")) return c, key }, @@ -2721,7 +3127,7 @@ func TestContextWithFallbackValueFromRequestContext(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) // enable ContextWithFallback feature flag c.engine.ContextWithFallback = true - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) c.Request = c.Request.WithContext(context.WithValue(context.TODO(), contextKey("key"), "value")) return c, contextKey("key") }, @@ -2744,7 +3150,7 @@ func TestContextWithFallbackValueFromRequestContext(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) // enable ContextWithFallback feature flag c.engine.ContextWithFallback = true - c.Request, _ = http.NewRequest("POST", "/", nil) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) return c, "key" }, value: nil, @@ -2876,12 +3282,398 @@ func TestInterceptedHeader(t *testing.T) { c.Header("X-Test-2", "present") c.String(http.StatusOK, "hello world") }) - c.Request = httptest.NewRequest("GET", "/", nil) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) r.HandleContext(c) // Result() has headers frozen when WriteHeaderNow() has been called // Compared to this time, this is when the response headers will be flushed // As response is flushed on c.String, the Header cannot be set by the first // middleware. Assert this - assert.Equal(t, "", w.Result().Header.Get("X-Test")) + assert.Empty(t, w.Result().Header.Get("X-Test")) assert.Equal(t, "present", w.Result().Header.Get("X-Test-2")) } + +func TestContextNext(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + // Test with no handlers + c.Next() + assert.Equal(t, int8(0), c.index) + + // Test with one handler + c.index = -1 + c.handlers = HandlersChain{func(c *Context) { + c.Set("key", "value") + }} + c.Next() + assert.Equal(t, int8(1), c.index) + value, exists := c.Get("key") + assert.True(t, exists) + assert.Equal(t, "value", value) + + // Test with multiple handlers + c.handlers = HandlersChain{ + func(c *Context) { + c.Set("key1", "value1") + c.Next() + c.Set("key2", "value2") + }, + nil, + func(c *Context) { + c.Set("key3", "value3") + }, + } + c.index = -1 + c.Next() + assert.Equal(t, int8(4), c.index) + value, exists = c.Get("key1") + assert.True(t, exists) + assert.Equal(t, "value1", value) + value, exists = c.Get("key2") + assert.True(t, exists) + assert.Equal(t, "value2", value) + value, exists = c.Get("key3") + assert.True(t, exists) + assert.Equal(t, "value3", value) +} + +func TestContextSetCookieData(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.SetSameSite(http.SameSiteLaxMode) + var setCookie string + + // Basic cookie settings + cookie := &http.Cookie{ + Name: "user", + Value: "gin", + MaxAge: 1, + Path: "/", + Domain: "localhost", + Secure: true, + HttpOnly: true, + } + c.SetCookieData(cookie) + setCookie = c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "user=gin") + assert.Contains(t, setCookie, "Path=/") + assert.Contains(t, setCookie, "Domain=localhost") + assert.Contains(t, setCookie, "Max-Age=1") + assert.Contains(t, setCookie, "HttpOnly") + assert.Contains(t, setCookie, "Secure") + // SameSite=Lax might be omitted in Go 1.24+ as it's the default + // assert.Contains(t, setCookie, "SameSite=Lax") + + // Test that when Path is empty, "/" is automatically set + cookie = &http.Cookie{ + Name: "user", + Value: "gin", + MaxAge: 1, + Path: "", + Domain: "localhost", + Secure: true, + HttpOnly: true, + } + c.SetCookieData(cookie) + setCookie = c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "user=gin") + assert.Contains(t, setCookie, "Path=/") + assert.Contains(t, setCookie, "Domain=localhost") + assert.Contains(t, setCookie, "Max-Age=1") + assert.Contains(t, setCookie, "HttpOnly") + assert.Contains(t, setCookie, "Secure") + // SameSite=Lax might be omitted in Go 1.24+ as it's the default + // assert.Contains(t, setCookie, "SameSite=Lax") + + // Test additional cookie attributes (Expires) + expireTime := time.Now().Add(24 * time.Hour) + cookie = &http.Cookie{ + Name: "user", + Value: "gin", + Path: "/", + Domain: "localhost", + Expires: expireTime, + Secure: true, + HttpOnly: true, + } + c.SetCookieData(cookie) + + // Since the Expires value varies by time, partially verify with Contains + setCookie = c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "user=gin") + assert.Contains(t, setCookie, "Path=/") + assert.Contains(t, setCookie, "Domain=localhost") + assert.Contains(t, setCookie, "HttpOnly") + assert.Contains(t, setCookie, "Secure") + // SameSite=Lax might be omitted in Go 1.24+ as it's the default + // assert.Contains(t, setCookie, "SameSite=Lax") + + // Test for Partitioned attribute (Go 1.18+) + cookie = &http.Cookie{ + Name: "user", + Value: "gin", + Path: "/", + Domain: "localhost", + Secure: true, + HttpOnly: true, + Partitioned: true, + } + c.SetCookieData(cookie) + setCookie = c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "user=gin") + assert.Contains(t, setCookie, "Path=/") + assert.Contains(t, setCookie, "Domain=localhost") + assert.Contains(t, setCookie, "HttpOnly") + assert.Contains(t, setCookie, "Secure") + // SameSite=Lax might be omitted in Go 1.24+ as it's the default + // assert.Contains(t, setCookie, "SameSite=Lax") + // Not testing for Partitioned attribute as it may not be supported in all Go versions + + // Test that SameSiteStrictMode is explicitly included in the header + t.Run("SameSite=Strict is included", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + cookie := &http.Cookie{ + Name: "user", + Value: "gin", + Path: "/", + Domain: "localhost", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } + c.SetCookieData(cookie) + setCookie := c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "SameSite=Strict") + }) + + // Test that SameSiteNoneMode is explicitly included in the header + t.Run("SameSite=None is included", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + cookie := &http.Cookie{ + Name: "user", + Value: "gin", + Path: "/", + Domain: "localhost", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + } + c.SetCookieData(cookie) + setCookie := c.Writer.Header().Get("Set-Cookie") + assert.Contains(t, setCookie, "SameSite=None") + }) +} + +func TestGetMapFromFormData(t *testing.T) { + testCases := []struct { + name string + data map[string][]string + key string + expected map[string]string + found bool + }{ + { + name: "Basic bracket notation", + data: map[string][]string{ + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + }, + key: "ids", + expected: map[string]string{ + "a": "hi", + "b": "3.14", + }, + found: true, + }, + { + name: "Mixed data with bracket notation", + data: map[string][]string{ + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + "names[a]": {"mike"}, + "names[b]": {"maria"}, + "other[key]": {"value"}, + "simple": {"data"}, + }, + key: "ids", + expected: map[string]string{ + "a": "hi", + "b": "3.14", + }, + found: true, + }, + { + name: "Names key", + data: map[string][]string{ + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + "names[a]": {"mike"}, + "names[b]": {"maria"}, + "other[key]": {"value"}, + }, + key: "names", + expected: map[string]string{ + "a": "mike", + "b": "maria", + }, + found: true, + }, + { + name: "Key not found", + data: map[string][]string{ + "ids[a]": {"hi"}, + "names[b]": {"maria"}, + }, + key: "notfound", + expected: map[string]string{}, + found: false, + }, + { + name: "Empty data", + data: map[string][]string{}, + key: "ids", + expected: map[string]string{}, + found: false, + }, + { + name: "Malformed bracket notation", + data: map[string][]string{ + "ids[a": {"hi"}, // Missing closing bracket + "ids]b": {"3.14"}, // Missing opening bracket + "idsab": {"value"}, // No brackets + }, + key: "ids", + expected: map[string]string{}, + found: false, + }, + { + name: "Nested bracket notation", + data: map[string][]string{ + "ids[a][b]": {"nested"}, + "ids[c]": {"simple"}, + }, + key: "ids", + expected: map[string]string{ + "a": "nested", + "c": "simple", + }, + found: true, + }, + { + name: "Simple key without brackets", + data: map[string][]string{ + "simple": {"data"}, + "ids[a]": {"hi"}, + }, + key: "simple", + expected: map[string]string{}, + found: false, + }, + { + name: "Mixed simple and bracket keys", + data: map[string][]string{ + "simple": {"data"}, + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + "other": {"value"}, + }, + key: "ids", + expected: map[string]string{ + "a": "hi", + "b": "3.14", + }, + found: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, found := getMapFromFormData(tc.data, tc.key) + assert.Equal(t, tc.expected, result, "result mismatch") + assert.Equal(t, tc.found, found, "found mismatch") + }) + } +} + +func BenchmarkGetMapFromFormData(b *testing.B) { + // Test case 1: Small dataset with bracket notation + smallData := map[string][]string{ + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + "names[a]": {"mike"}, + "names[b]": {"maria"}, + } + + // Test case 2: Medium dataset with mixed data + mediumData := map[string][]string{ + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + "ids[c]": {"test"}, + "ids[d]": {"value"}, + "names[a]": {"mike"}, + "names[b]": {"maria"}, + "names[c]": {"john"}, + "names[d]": {"jane"}, + "other[key1]": {"value1"}, + "other[key2]": {"value2"}, + "simple": {"data"}, + "another": {"info"}, + } + + // Test case 3: Large dataset with many bracket keys + largeData := make(map[string][]string) + for i := 0; i < 100; i++ { + key := fmt.Sprintf("ids[%d]", i) + largeData[key] = []string{fmt.Sprintf("value%d", i)} + } + for i := 0; i < 50; i++ { + key := fmt.Sprintf("names[%d]", i) + largeData[key] = []string{fmt.Sprintf("name%d", i)} + } + for i := 0; i < 25; i++ { + key := fmt.Sprintf("other[key%d]", i) + largeData[key] = []string{fmt.Sprintf("other%d", i)} + } + + // Test case 4: Dataset with many non-matching keys (worst case) + worstCaseData := make(map[string][]string) + for i := 0; i < 100; i++ { + key := fmt.Sprintf("nonmatching%d", i) + worstCaseData[key] = []string{fmt.Sprintf("value%d", i)} + } + worstCaseData["ids[a]"] = []string{"hi"} + worstCaseData["ids[b]"] = []string{"3.14"} + + // Test case 5: Dataset with short keys (best case for early exit) + shortKeysData := map[string][]string{ + "a": {"value1"}, + "b": {"value2"}, + "ids[a]": {"hi"}, + "ids[b]": {"3.14"}, + } + + benchmarks := []struct { + name string + data map[string][]string + key string + }{ + {"Small_Bracket", smallData, "ids"}, + {"Small_Names", smallData, "names"}, + {"Medium_Bracket", mediumData, "ids"}, + {"Medium_Names", mediumData, "names"}, + {"Medium_Other", mediumData, "other"}, + {"Large_Bracket", largeData, "ids"}, + {"Large_Names", largeData, "names"}, + {"Large_Other", largeData, "other"}, + {"WorstCase_Bracket", worstCaseData, "ids"}, + {"ShortKeys_Bracket", shortKeysData, "ids"}, + {"Empty_Key", smallData, "notfound"}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = getMapFromFormData(bm.data, bm.key) + } + }) + } +} diff --git a/debug.go b/debug.go index 62085c5d..f22dfd87 100644 --- a/debug.go +++ b/debug.go @@ -13,7 +13,7 @@ import ( "sync/atomic" ) -const ginSupportMinGoVer = 21 +const ginSupportMinGoVer = 23 // IsDebugging returns true if the framework is running in debug mode. // Use SetMode(gin.ReleaseMode) to disable debug mode. @@ -25,7 +25,7 @@ func IsDebugging() bool { var DebugPrintRouteFunc func(httpMethod, absolutePath, handlerName string, nuHandlers int) // DebugPrintFunc indicates debug log output format. -var DebugPrintFunc func(format string, values ...interface{}) +var DebugPrintFunc func(format string, values ...any) func debugPrintRoute(httpMethod, absolutePath string, handlers HandlersChain) { if IsDebugging() { @@ -78,7 +78,7 @@ func getMinVer(v string) (uint64, error) { func debugPrintWARNINGDefault() { if v, e := getMinVer(runtime.Version()); e == nil && v < ginSupportMinGoVer { - debugPrint(`[WARNING] Now Gin requires Go 1.21+. + debugPrint(`[WARNING] Now Gin requires Go 1.24+. `) } diff --git a/debug_test.go b/debug_test.go index edf4bb12..e9d8fe01 100644 --- a/debug_test.go +++ b/debug_test.go @@ -10,6 +10,7 @@ import ( "html/template" "io" "log" + "net/http" "os" "runtime" "strings" @@ -60,7 +61,7 @@ func TestDebugPrintError(t *testing.T) { func TestDebugPrintRoutes(t *testing.T) { re := captureOutput(t, func() { SetMode(DebugMode) - debugPrintRoute("GET", "/path/to/route/:param", HandlersChain{func(c *Context) {}, handlerNameTest}) + debugPrintRoute(http.MethodGet, "/path/to/route/:param", HandlersChain{func(c *Context) {}, handlerNameTest}) SetMode(TestMode) }) assert.Regexp(t, `^\[GIN-debug\] GET /path/to/route/:param --> (.*/vendor/)?github.com/gin-gonic/gin.handlerNameTest \(2 handlers\)\n$`, re) @@ -72,7 +73,7 @@ func TestDebugPrintRouteFunc(t *testing.T) { } re := captureOutput(t, func() { SetMode(DebugMode) - debugPrintRoute("GET", "/path/to/route/:param1/:param2", HandlersChain{func(c *Context) {}, handlerNameTest}) + debugPrintRoute(http.MethodGet, "/path/to/route/:param1/:param2", HandlersChain{func(c *Context) {}, handlerNameTest}) SetMode(TestMode) }) assert.Regexp(t, `^\[GIN-debug\] GET /path/to/route/:param1/:param2 --> (.*/vendor/)?github.com/gin-gonic/gin.handlerNameTest \(2 handlers\)\n$`, re) @@ -105,7 +106,7 @@ func TestDebugPrintWARNINGDefault(t *testing.T) { }) m, e := getMinVer(runtime.Version()) if e == nil && m < ginSupportMinGoVer { - assert.Equal(t, "[GIN-debug] [WARNING] Now Gin requires Go 1.21+.\n\n[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.\n\n", re) + assert.Equal(t, "[GIN-debug] [WARNING] Now Gin requires Go 1.24+.\n\n[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.\n\n", re) } else { assert.Equal(t, "[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.\n\n", re) } diff --git a/deprecated_test.go b/deprecated_test.go index 0240b2ec..6c8f2a7f 100644 --- a/deprecated_test.go +++ b/deprecated_test.go @@ -18,7 +18,7 @@ func TestBindWith(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/?foo=bar&bar=foo", bytes.NewBufferString("foo=unused")) + c.Request, _ = http.NewRequest(http.MethodPost, "/?foo=bar&bar=foo", bytes.NewBufferString("foo=unused")) var obj struct { Foo string `form:"foo"` diff --git a/doc.go b/doc.go index 1bd03864..9442aa70 100644 --- a/doc.go +++ b/doc.go @@ -2,5 +2,21 @@ Package gin implements a HTTP web framework called gin. See https://gin-gonic.com/ for more information about gin. + +Example: + + package main + + import "github.com/gin-gonic/gin" + + func main() { + r := gin.Default() + r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{ + "message": "pong", + }) + }) + r.Run() // listen and serve on 0.0.0.0:8080 + } */ package gin // import "github.com/gin-gonic/gin" diff --git a/docs/doc.md b/docs/doc.md index 51366409..0dd86684 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -26,6 +26,8 @@ - [Custom Validators](#custom-validators) - [Only Bind Query String](#only-bind-query-string) - [Bind Query String or Post Data](#bind-query-string-or-post-data) + - [Bind default value if none provided](#bind-default-value-if-none-provided) + - [Collection format for arrays](#collection-format-for-arrays) - [Bind Uri](#bind-uri) - [Bind custom unmarshaler](#bind-custom-unmarshaler) - [Bind Header](#bind-header) @@ -61,6 +63,7 @@ - [http2 server push](#http2-server-push) - [Define format for the log of routes](#define-format-for-the-log-of-routes) - [Set and get a cookie](#set-and-get-a-cookie) + - [Custom json codec at runtime](#custom-json-codec-at-runtime) - [Don't trust all proxies](#dont-trust-all-proxies) - [Testing](#testing) @@ -68,7 +71,7 @@ ### Build with json replacement -Gin uses `encoding/json` as default json package but you can change it by build from other tags. +Gin uses `encoding/json` as the default JSON package but you can change it by building from other tags. [jsoniter](https://github.com/json-iterator/go) @@ -82,10 +85,10 @@ go build -tags=jsoniter . go build -tags=go_json . ``` -[sonic](https://github.com/bytedance/sonic) (you have to ensure that your cpu support avx instruction.) +[sonic](https://github.com/bytedance/sonic) ```sh -$ go build -tags="sonic avx" . +$ go build -tags=sonic . ``` ### Build without `MsgPack` rendering feature @@ -118,7 +121,7 @@ func main() { router.HEAD("/someHead", head) router.OPTIONS("/someOptions", options) - // By default it serves on :8080 unless a + // By default, it serves on :8080 unless a // PORT environment variable was defined. router.Run() // router.Run(":3000") for a hard coded port @@ -170,7 +173,7 @@ func main() { router := gin.Default() // Query string parameters are parsed using the existing underlying request object. - // The request responds to an url matching: /welcome?firstname=Jane&lastname=Doe + // The request responds to a URL matching: /welcome?firstname=Jane&lastname=Doe router.GET("/welcome", func(c *gin.Context) { firstname := c.DefaultQuery("firstname", "Guest") lastname := c.Query("lastname") // shortcut for c.Request.URL.Query().Get("lastname") @@ -298,7 +301,7 @@ curl -X POST http://localhost:8080/upload \ #### Multiple files -See the detail [example code](https://github.com/gin-gonic/examples/tree/master/upload-file/multiple). +See the detailed [example code](https://github.com/gin-gonic/examples/tree/master/upload-file/multiple). ```go func main() { @@ -338,16 +341,16 @@ func main() { router := gin.Default() // Simple group: v1 - v1 := router.Group("/v1") { + v1 := router.Group("/v1") v1.POST("/login", loginEndpoint) v1.POST("/submit", submitEndpoint) v1.POST("/read", readEndpoint) } // Simple group: v2 - v2 := router.Group("/v2") { + v2 := router.Group("/v2") v2.POST("/login", loginEndpoint) v2.POST("/submit", submitEndpoint) v2.POST("/read", readEndpoint) @@ -514,19 +517,19 @@ Sample Output ```go func main() { router := gin.New() - + // skip logging for desired paths by setting SkipPaths in LoggerConfig loggerConfig := gin.LoggerConfig{SkipPaths: []string{"/metrics"}} - + // skip logging based on your logic by setting Skip func in LoggerConfig loggerConfig.Skip = func(c *gin.Context) bool { // as an example skip non server side errors return c.Writer.Status() < http.StatusInternalServerError } - + router.Use(gin.LoggerWithConfig(loggerConfig)) router.Use(gin.Recovery()) - + // skipped router.GET("/metrics", func(c *gin.Context) { c.Status(http.StatusNotImplemented) @@ -541,7 +544,7 @@ func main() { router.GET("/data", func(c *gin.Context) { c.Status(http.StatusNotImplemented) }) - + router.Run(":8080") } @@ -613,7 +616,7 @@ You can also specify that specific fields are required. If a field is decorated ```go // Binding from JSON type Login struct { - User string `form:"user" json:"user" xml:"user" binding:"required"` + User string `form:"user" json:"user" xml:"user" binding:"required"` Password string `form:"password" json:"password" xml:"password" binding:"required"` } @@ -702,7 +705,7 @@ $ curl -v -X POST \ {"error":"Key: 'Login.Password' Error:Field validation for 'Password' failed on the 'required' tag"} ``` -Skip validate: when running the above example using the above the `curl` command, it returns error. Because the example use `binding:"required"` for `Password`. If use `binding:"-"` for `Password`, then it will not return error when running the above example again. +Skip-validation: Running the example above using the `curl` command returns an error. This is because the example uses `binding:"required"` for `Password`. If instead, you use `binding:"-"` for `Password`, then it will not return an error when you run the example again. ### Custom Validators @@ -830,6 +833,8 @@ type Person struct { Birthday time.Time `form:"birthday" time_format:"2006-01-02" time_utc:"1"` CreateTime time.Time `form:"createTime" time_format:"unixNano"` UnixTime time.Time `form:"unixTime" time_format:"unix"` + UnixMilliTime time.Time `form:"unixMilliTime" time_format:"unixmilli"` + UnixMicroTime time.Time `form:"unixMicroTime" time_format:"uNiXmIcRo"` // case does not matter for "unix*" time formats } func main() { @@ -849,6 +854,8 @@ func startPage(c *gin.Context) { log.Println(person.Birthday) log.Println(person.CreateTime) log.Println(person.UnixTime) + log.Println(person.UnixMilliTime) + log.Println(person.UnixMicroTime) } c.String(http.StatusOK, "Success") @@ -858,7 +865,107 @@ func startPage(c *gin.Context) { Test it with: ```sh -curl -X GET "localhost:8085/testing?name=appleboy&address=xyz&birthday=1992-03-15&createTime=1562400033000000123&unixTime=1562400033" +curl -X GET "localhost:8085/testing?name=appleboy&address=xyz&birthday=1992-03-15&createTime=1562400033000000123&unixTime=1562400033&unixMilliTime=1562400033001&unixMicroTime=1562400033000012" +``` + + +### Bind default value if none provided + +If the server should bind a default value to a field when the client does not provide one, specify the default value using the `default` key within the `form` tag: + +```go +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Person struct { + Name string `form:"name,default=William"` + Age int `form:"age,default=10"` + Friends []string `form:"friends,default=Will;Bill"` + Addresses [2]string `form:"addresses,default=foo bar" collection_format:"ssv"` + LapTimes []int `form:"lap_times,default=1;2;3" collection_format:"csv"` +} + +func main() { + g := gin.Default() + g.POST("/person", func(c *gin.Context) { + var req Person + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, err) + return + } + c.JSON(http.StatusOK, req) + }) + _ = g.Run("localhost:8080") +} +``` + +``` +curl -X POST http://localhost:8080/person +{"Name":"William","Age":10,"Friends":["Will","Bill"],"Colors":["red","blue"],"LapTimes":[1,2,3]} +``` + +NOTE: For default [collection values](#collection-format-for-arrays), the following rules apply: +- Since commas are used to delimit tag options, they are not supported within a default value and will result in undefined behavior +- For the collection formats "multi" and "csv", a semicolon should be used in place of a comma to delimited default values +- Since semicolons are used to delimit default values for "multi" and "csv", they are not supported within a default value for "multi" and "csv" + + +#### Collection format for arrays + +| Format | Description | Example | +| --------------- | --------------------------------------------------------- | ----------------------- | +| multi (default) | Multiple parameter instances rather than multiple values. | key=foo&key=bar&key=baz | +| csv | Comma-separated values. | foo,bar,baz | +| ssv | Space-separated values. | foo bar baz | +| tsv | Tab-separated values. | "foo\tbar\tbaz" | +| pipes | Pipe-separated values. | foo\|bar\|baz | + +```go +package main + +import ( + "log" + "time" + "github.com/gin-gonic/gin" +) + +type Person struct { + Name string `form:"name"` + Addresses []string `form:"addresses" collection_format:"csv"` + Birthday time.Time `form:"birthday" time_format:"2006-01-02" time_utc:"1"` + CreateTime time.Time `form:"createTime" time_format:"unixNano"` + UnixTime time.Time `form:"unixTime" time_format:"unix"` +} + +func main() { + route := gin.Default() + route.GET("/testing", startPage) + route.Run(":8085") +} +func startPage(c *gin.Context) { + var person Person + // If `GET`, only `Form` binding engine (`query`) used. + // If `POST`, first checks the `content-type` for `JSON` or `XML`, then uses `Form` (`form-data`). + // See more at https://github.com/gin-gonic/gin/blob/master/binding/binding.go#L48 + if c.ShouldBind(&person) == nil { + log.Println(person.Name) + log.Println(person.Addresses) + log.Println(person.Birthday) + log.Println(person.CreateTime) + log.Println(person.UnixTime) + } + c.String(200, "Success") +} +``` + +Test it with: +```sh +$ curl -X GET "localhost:8085/testing?name=appleboy&addresses=foo,bar&birthday=1992-03-15&createTime=1562400033000000123&unixTime=1562400033" ``` ### Bind Uri @@ -1081,7 +1188,7 @@ func main() { }) r.GET("/moreJSON", func(c *gin.Context) { - // You also can use a struct + // You can also use a struct var msg struct { Name string `json:"user"` Message string @@ -1150,7 +1257,7 @@ func main() { #### JSONP -Using JSONP to request data from a server in a different domain. Add callback to response body if the query parameter callback exists. +Using JSONP to request data from a server in a different domain. Add callback to response body if the query parameter callback exists. ```go func main() { @@ -1199,7 +1306,7 @@ func main() { #### PureJSON -Normally, JSON replaces special HTML characters with their unicode entities, e.g. `<` becomes `\u003c`. If you want to encode such characters literally, you can use PureJSON instead. +Normally, JSON replaces special HTML characters with their unicode entities, e.g. `<` becomes `\u003c`. If you want to encode such characters literally, you can use PureJSON instead. This feature is unavailable in Go 1.6 and lower. ```go @@ -1234,7 +1341,7 @@ func main() { router.StaticFS("/more_static", http.Dir("my_file_system")) router.StaticFile("/favicon.ico", "./resources/favicon.ico") router.StaticFileFS("/more_favicon.ico", "more_favicon.ico", http.Dir("my_file_system")) - + // Listen and serve on 0.0.0.0:8080 router.Run(":8080") } @@ -1287,13 +1394,19 @@ func main() { ### HTML rendering -Using LoadHTMLGlob() or LoadHTMLFiles() +Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS() ```go +//go:embed templates/* +var templates embed.FS + func main() { router := gin.Default() router.LoadHTMLGlob("templates/*") //router.LoadHTMLFiles("templates/template1.html", "templates/template2.html") + //router.LoadHTMLFS(http.Dir("templates"), "template1.html", "template2.html") + //or + //router.LoadHTMLFS(http.FS(templates), "templates/template1.html", "templates/template2.html") router.GET("/index", func(c *gin.Context) { c.HTML(http.StatusOK, "index.tmpl", gin.H{ "title": "Main website", @@ -1384,7 +1497,7 @@ You may use custom delims #### Custom Template Funcs -See the detail [example code](https://github.com/gin-gonic/examples/tree/master/template). +See the detailed [example code](https://github.com/gin-gonic/examples/tree/master/template). main.go @@ -1436,7 +1549,7 @@ Date: 2017/07/01 ### Multitemplate -Gin allow by default use only one html.Template. Check [a multitemplate render](https://github.com/gin-contrib/multitemplate) for using features like go 1.6 `block template`. +Gin allows only one html.Template by default. Check [a multitemplate render](https://github.com/gin-contrib/multitemplate) for using features like go 1.6 `block template`. ### Redirects @@ -1985,7 +2098,7 @@ type formB struct { func SomeHandler(c *gin.Context) { objA := formA{} objB := formB{} - // This c.ShouldBind consumes c.Request.Body and it cannot be reused. + // Calling c.ShouldBind consumes c.Request.Body and it cannot be reused. if errA := c.ShouldBind(&objA); errA == nil { c.String(http.StatusOK, `the body should be formA`) // Always an error is occurred by this because c.Request.Body is EOF now. @@ -2192,12 +2305,64 @@ func main() { router := gin.Default() router.GET("/cookie", func(c *gin.Context) { + cookie, err := c.Cookie("gin_cookie") + if err != nil { + cookie = "NotSet" + // Using http.Cookie struct for more control + c.SetCookieData(&http.Cookie{ + Name: "gin_cookie", + Value: "test", + Path: "/", + Domain: "localhost", + MaxAge: 3600, + Secure: false, + HttpOnly: true, + // Additional fields available in http.Cookie + Expires: time.Now().Add(24 * time.Hour), + // Partitioned: true, // Available in newer Go versions + }) + } + + fmt.Printf("Cookie value: %s \n", cookie) + }) + + router.Run() +} +``` + +You can also use the `SetCookieData` method, which accepts a `*http.Cookie` directly for more flexibility: + +```go +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + + router.GET("/cookie", func(c *gin.Context) { cookie, err := c.Cookie("gin_cookie") if err != nil { cookie = "NotSet" - c.SetCookie("gin_cookie", "test", 3600, "/", "localhost", false, true) + // Using http.Cookie struct for more control + c.SetCookieData(&http.Cookie{ + Name: "gin_cookie", + Value: "test", + Path: "/", + Domain: "localhost", + MaxAge: 3600, + Secure: false, + HttpOnly: true, + // Additional fields available in http.Cookie + Expires: time.Now().Add(24 * time.Hour), + // Partitioned: true, // Available in newer Go versions + }) } fmt.Printf("Cookie value: %s \n", cookie) @@ -2207,6 +2372,65 @@ func main() { } ``` +### Custom json codec at runtime + +Gin support custom json serialization and deserialization logic without using compile tags. + +1. Define a custom struct implements the `json.Core` interface. + +2. Before your engine starts, assign values to `json.API` using the custom struct. + +```go +package main + +import ( + "io" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/codec/json" + jsoniter "github.com/json-iterator/go" +) + +var customConfig = jsoniter.Config{ + EscapeHTML: true, + SortMapKeys: true, + ValidateJsonRawMessage: true, +}.Froze() + +// implement api.JsonApi +type customJsonApi struct { +} + +func (j customJsonApi) Marshal(v any) ([]byte, error) { + return customConfig.Marshal(v) +} + +func (j customJsonApi) Unmarshal(data []byte, v any) error { + return customConfig.Unmarshal(data, v) +} + +func (j customJsonApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return customConfig.MarshalIndent(v, prefix, indent) +} + +func (j customJsonApi) NewEncoder(writer io.Writer) json.Encoder { + return customConfig.NewEncoder(writer) +} + +func (j customJsonApi) NewDecoder(reader io.Reader) json.Decoder { + return customConfig.NewDecoder(reader) +} + +func main() { + //Replace the default json api + json.API = customJsonApi{} + + //Start your gin engine + router := gin.Default() + router.Run(":8080") +} +``` + ## Don't trust all proxies Gin lets you specify which headers to hold the real client IP (if any), @@ -2218,7 +2442,7 @@ or network CIDRs from where clients which their request headers related to clien IP can be trusted. They can be IPv4 addresses, IPv4 CIDRs, IPv6 addresses or IPv6 CIDRs. -**Attention:** Gin trust all proxies by default if you don't specify a trusted +**Attention:** Gin trusts all proxies by default if you don't specify a trusted proxy using the function above, **this is NOT safe**. At the same time, if you don't use any proxy, you can disable this feature by using `Engine.SetTrustedProxies(nil)`, then `Context.ClientIP()` will return the remote address directly to avoid some @@ -2247,7 +2471,7 @@ func main() { ``` **Notice:** If you are using a CDN service, you can set the `Engine.TrustedPlatform` -to skip TrustedProxies check, it has a higher priority than TrustedProxies. +to skip TrustedProxies check, it has a higher priority than TrustedProxies. Look at the example below: ```go diff --git a/errors.go b/errors.go index 06b53c28..829e9d2c 100644 --- a/errors.go +++ b/errors.go @@ -9,7 +9,7 @@ import ( "reflect" "strings" - "github.com/gin-gonic/gin/internal/json" + "github.com/gin-gonic/gin/codec/json" ) // ErrorType is an unsigned 64-bit error code as defined in the gin spec. @@ -77,7 +77,7 @@ func (msg *Error) JSON() any { // MarshalJSON implements the json.Marshaller interface. func (msg *Error) MarshalJSON() ([]byte, error) { - return json.Marshal(msg.JSON()) + return json.API.Marshal(msg.JSON()) } // Error implements the error interface. @@ -91,7 +91,7 @@ func (msg *Error) IsType(flags ErrorType) bool { } // Unwrap returns the wrapped error, to allow interoperability with errors.Is(), errors.As() and errors.Unwrap() -func (msg *Error) Unwrap() error { +func (msg Error) Unwrap() error { return msg.Err } @@ -157,7 +157,7 @@ func (a errorMsgs) JSON() any { // MarshalJSON implements the json.Marshaller interface. func (a errorMsgs) MarshalJSON() ([]byte, error) { - return json.Marshal(a.JSON()) + return json.API.Marshal(a.JSON()) } func (a errorMsgs) String() string { diff --git a/errors_test.go b/errors_test.go index 72a36992..6d8df278 100644 --- a/errors_test.go +++ b/errors_test.go @@ -9,7 +9,7 @@ import ( "fmt" "testing" - "github.com/gin-gonic/gin/internal/json" + "github.com/gin-gonic/gin/codec/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -33,8 +33,8 @@ func TestError(t *testing.T) { "meta": "some data", }, err.JSON()) - jsonBytes, _ := json.Marshal(err) - assert.Equal(t, "{\"error\":\"test error\",\"meta\":\"some data\"}", string(jsonBytes)) + jsonBytes, _ := json.API.Marshal(err) + assert.JSONEq(t, "{\"error\":\"test error\",\"meta\":\"some data\"}", string(jsonBytes)) err.SetMeta(H{ //nolint: errcheck "status": "200", @@ -92,14 +92,14 @@ Error #03: third H{"error": "second", "meta": "some data"}, H{"error": "third", "status": "400"}, }, errs.JSON()) - jsonBytes, _ := json.Marshal(errs) - assert.Equal(t, "[{\"error\":\"first\"},{\"error\":\"second\",\"meta\":\"some data\"},{\"error\":\"third\",\"status\":\"400\"}]", string(jsonBytes)) + jsonBytes, _ := json.API.Marshal(errs) + assert.JSONEq(t, "[{\"error\":\"first\"},{\"error\":\"second\",\"meta\":\"some data\"},{\"error\":\"third\",\"status\":\"400\"}]", string(jsonBytes)) errs = errorMsgs{ {Err: errors.New("first"), Type: ErrorTypePrivate}, } assert.Equal(t, H{"error": "first"}, errs.JSON()) - jsonBytes, _ = json.Marshal(errs) - assert.Equal(t, "{\"error\":\"first\"}", string(jsonBytes)) + jsonBytes, _ = json.API.Marshal(errs) + assert.JSONEq(t, "{\"error\":\"first\"}", string(jsonBytes)) errs = errorMsgs{} assert.Nil(t, errs.Last()) @@ -126,4 +126,15 @@ func TestErrorUnwrap(t *testing.T) { require.ErrorIs(t, err, innerErr) var testErr TestErr require.ErrorAs(t, err, &testErr) + + // Test non-pointer usage of gin.Error + errNonPointer := Error{ + Err: innerErr, + Type: ErrorTypeAny, + } + wrappedErr := fmt.Errorf("wrapped: %w", errNonPointer) + // Check that 'errors.Is()' and 'errors.As()' behave as expected for non-pointer usage + require.ErrorIs(t, wrappedErr, innerErr) + var testErrNonPointer TestErr + require.ErrorAs(t, wrappedErr, &testErrNonPointer) } diff --git a/fs.go b/fs.go index 51c3db86..ab18adb3 100644 --- a/fs.go +++ b/fs.go @@ -17,7 +17,6 @@ type OnlyFilesFS struct { // Open passes `Open` to the upstream implementation without `Readdir` functionality. func (o OnlyFilesFS) Open(name string) (http.File, error) { f, err := o.FileSystem.Open(name) - if err != nil { return nil, err } diff --git a/gin.go b/gin.go index c0b8bf34..88742956 100644 --- a/gin.go +++ b/gin.go @@ -16,17 +16,19 @@ import ( "sync" "github.com/gin-gonic/gin/internal/bytesconv" + filesystem "github.com/gin-gonic/gin/internal/fs" "github.com/gin-gonic/gin/render" - "github.com/quic-go/quic-go/http3" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" ) -const defaultMultipartMemory = 32 << 20 // 32 MB -const escapedColon = "\\:" -const colon = ":" -const backslash = "\\" +const ( + defaultMultipartMemory = 32 << 20 // 32 MB + escapedColon = "\\:" + colon = ":" + backslash = "\\" +) var ( default404Body = []byte("404 page not found") @@ -46,8 +48,10 @@ var defaultTrustedCIDRs = []*net.IPNet{ }, } -var regSafePrefix = regexp.MustCompile("[^a-zA-Z0-9/-]+") -var regRemoveRepeatedChar = regexp.MustCompile("/{2,}") +var ( + regSafePrefix = regexp.MustCompile("[^a-zA-Z0-9/-]+") + regRemoveRepeatedChar = regexp.MustCompile("/{2,}") +) // HandlerFunc defines the handler used by gin middleware as return value. type HandlerFunc func(*Context) @@ -94,6 +98,10 @@ const ( type Engine struct { RouterGroup + // routeTreesUpdated ensures that the initialization or update of the route trees + // (used for routing HTTP requests) happens only once, even if called multiple times concurrently. + routeTreesUpdated sync.Once + // RedirectTrailingSlash enables automatic redirection if the current route can't be matched but a // handler for the path with (without) the trailing slash exists. // For example if /foo/ is requested but a route only exists for /foo, the @@ -215,7 +223,7 @@ func New(opts ...OptionFunc) *Engine { trustedProxies: []string{"0.0.0.0/0", "::/0"}, trustedCIDRs: defaultTrustedCIDRs, } - engine.RouterGroup.engine = engine + engine.engine = engine engine.pool.New = func() any { return engine.allocateContext(engine.maxParams) } @@ -285,6 +293,19 @@ func (engine *Engine) LoadHTMLFiles(files ...string) { engine.SetHTMLTemplate(templ) } +// LoadHTMLFS loads an http.FileSystem and a slice of patterns +// and associates the result with HTML renderer. +func (engine *Engine) LoadHTMLFS(fs http.FileSystem, patterns ...string) { + if IsDebugging() { + engine.HTMLRender = render.HTMLDebug{FileSystem: fs, Patterns: patterns, FuncMap: engine.FuncMap, Delims: engine.delims} + return + } + + templ := template.Must(template.New("").Delims(engine.delims.Left, engine.delims.Right).Funcs(engine.FuncMap).ParseFS( + filesystem.FileSystem{FileSystem: fs}, patterns...)) + engine.SetHTMLTemplate(templ) +} + // SetHTMLTemplate associate a template with HTML renderer. func (engine *Engine) SetHTMLTemplate(templ *template.Template) { if len(engine.trees) > 0 { @@ -321,7 +342,7 @@ func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes { return engine } -// With returns a Engine with the configuration set in the OptionFunc. +// With returns an Engine with the configuration set in the OptionFunc. func (engine *Engine) With(opts ...OptionFunc) *Engine { for _, opt := range opts { opt(engine) @@ -363,7 +384,7 @@ func (engine *Engine) addRoute(method, path string, handlers HandlersChain) { } // Routes returns a slice of registered routes, including some useful information, such as: -// the http method, path and the handler name. +// the http method, path, and the handler name. func (engine *Engine) Routes() (routes RoutesInfo) { for _, tree := range engine.trees { routes = iterate("", tree.method, routes, tree.root) @@ -524,7 +545,11 @@ func (engine *Engine) Run(addr ...string) (err error) { engine.updateRouteTrees() address := resolveAddress(addr) debugPrint("Listening and serving HTTP on %s\n", address) - err = http.ListenAndServe(address, engine.Handler()) + server := &http.Server{ // #nosec G112 + Addr: address, + Handler: engine.Handler(), + } + err = server.ListenAndServe() return } @@ -540,7 +565,11 @@ func (engine *Engine) RunTLS(addr, certFile, keyFile string) (err error) { "Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.") } - err = http.ListenAndServeTLS(addr, certFile, keyFile, engine.Handler()) + server := &http.Server{ // #nosec G112 + Addr: addr, + Handler: engine.Handler(), + } + err = server.ListenAndServeTLS(certFile, keyFile) return } @@ -563,7 +592,10 @@ func (engine *Engine) RunUnix(file string) (err error) { defer listener.Close() defer os.Remove(file) - err = http.Serve(listener, engine.Handler()) + server := &http.Server{ // #nosec G112 + Handler: engine.Handler(), + } + err = server.Serve(listener) return } @@ -580,6 +612,7 @@ func (engine *Engine) RunFd(fd int) (err error) { } f := os.NewFile(uintptr(fd), fmt.Sprintf("fd@%d", fd)) + defer f.Close() listener, err := net.FileListener(f) if err != nil { return @@ -598,7 +631,7 @@ func (engine *Engine) RunQUIC(addr, certFile, keyFile string) (err error) { if engine.isUnsafeTrustedProxies() { debugPrint("[WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.\n" + - "Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.") + "Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.") } err = http3.ListenAndServeQUIC(addr, certFile, keyFile, engine.Handler()) @@ -616,12 +649,19 @@ func (engine *Engine) RunListener(listener net.Listener) (err error) { "Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.") } - err = http.Serve(listener, engine.Handler()) + server := &http.Server{ // #nosec G112 + Handler: engine.Handler(), + } + err = server.Serve(listener) return } // ServeHTTP conforms to the http.Handler interface. func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { + engine.routeTreesUpdated.Do(func() { + engine.updateRouteTrees() + }) + c := engine.pool.Get().(*Context) c.writermem.reset(w) c.Request = req @@ -637,10 +677,12 @@ func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Disclaimer: You can loop yourself to deal with this, use wisely. func (engine *Engine) HandleContext(c *Context) { oldIndexValue := c.index + oldHandlers := c.handlers c.reset() engine.handleHTTPRequest(c) c.index = oldIndexValue + c.handlers = oldHandlers } func (engine *Engine) handleHTTPRequest(c *Context) { diff --git a/ginS/gins.go b/ginS/gins.go index ea38c613..7918ce3a 100644 --- a/ginS/gins.go +++ b/ginS/gins.go @@ -12,15 +12,9 @@ import ( "github.com/gin-gonic/gin" ) -var once sync.Once -var internalEngine *gin.Engine - -func engine() *gin.Engine { - once.Do(func() { - internalEngine = gin.Default() - }) - return internalEngine -} +var engine = sync.OnceValue(func() *gin.Engine { + return gin.Default() +}) // LoadHTMLGlob is a wrapper for Engine.LoadHTMLGlob. func LoadHTMLGlob(pattern string) { @@ -32,6 +26,11 @@ func LoadHTMLFiles(files ...string) { engine().LoadHTMLFiles(files...) } +// LoadHTMLFS is a wrapper for Engine.LoadHTMLFS. +func LoadHTMLFS(fs http.FileSystem, patterns ...string) { + engine().LoadHTMLFS(fs, patterns...) +} + // SetHTMLTemplate is a wrapper for Engine.SetHTMLTemplate. func SetHTMLTemplate(templ *template.Template) { engine().SetHTMLTemplate(templ) @@ -154,7 +153,7 @@ func RunUnix(file string) (err error) { // RunFd attaches the router to a http.Server and starts listening and serving HTTP requests // through the specified file descriptor. -// Note: the method will block the calling goroutine indefinitely unless on error happens. +// Note: the method will block the calling goroutine indefinitely unless an error happens. func RunFd(fd int) (err error) { return engine().RunFd(fd) } diff --git a/gin_integration_test.go b/gin_integration_test.go index 3082bc2c..e040993a 100644 --- a/gin_integration_test.go +++ b/gin_integration_test.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" "testing" "time" @@ -28,7 +29,6 @@ import ( // params[1]=response status (custom compare status) default:"200 OK" // params[2]=response body (custom compare content) default:"it worked" func testRequest(t *testing.T, params ...string) { - if len(params) == 0 { t.Fatal("url cannot be empty") } @@ -47,12 +47,12 @@ func testRequest(t *testing.T, params ...string) { body, ioerr := io.ReadAll(resp.Body) require.NoError(t, ioerr) - var responseStatus = "200 OK" + responseStatus := "200 OK" if len(params) > 1 && params[1] != "" { responseStatus = params[1] } - var responseBody = "it worked" + responseBody := "it worked" if len(params) > 2 && params[2] != "" { responseBody = params[2] } @@ -170,7 +170,7 @@ func TestRunTLS(t *testing.T) { } func TestPusher(t *testing.T) { - var html = template.Must(template.New("https").Parse(` + html := template.Must(template.New("https").Parse(` Https Test @@ -262,10 +262,11 @@ func TestUnixSocket(t *testing.T) { fmt.Fprint(c, "GET /example HTTP/1.0\r\n\r\n") scanner := bufio.NewScanner(c) - var response string + var responseBuilder strings.Builder for scanner.Scan() { - response += scanner.Text() + responseBuilder.WriteString(scanner.Text()) } + response := responseBuilder.String() assert.Contains(t, response, "HTTP/1.0 200", "should get a 200") assert.Contains(t, response, "it worked", "resp body should match") } @@ -323,10 +324,11 @@ func TestFileDescriptor(t *testing.T) { fmt.Fprintf(c, "GET /example HTTP/1.0\r\n\r\n") scanner := bufio.NewScanner(c) - var response string + var responseBuilder strings.Builder for scanner.Scan() { - response += scanner.Text() + responseBuilder.WriteString(scanner.Text()) } + response := responseBuilder.String() assert.Contains(t, response, "HTTP/1.0 200", "should get a 200") assert.Contains(t, response, "it worked", "resp body should match") } @@ -355,10 +357,11 @@ func TestListener(t *testing.T) { fmt.Fprintf(c, "GET /example HTTP/1.0\r\n\r\n") scanner := bufio.NewScanner(c) - var response string + var responseBuilder strings.Builder for scanner.Scan() { - response += scanner.Text() + responseBuilder.WriteString(scanner.Text()) } + response := responseBuilder.String() assert.Contains(t, response, "HTTP/1.0 200", "should get a 200") assert.Contains(t, response, "it worked", "resp body should match") } diff --git a/gin_test.go b/gin_test.go index 719f63e4..cee1f3cc 100644 --- a/gin_test.go +++ b/gin_test.go @@ -46,7 +46,7 @@ func setupHTMLFiles(t *testing.T, mode string, tls bool, loadMethod func(*Engine }) router.GET("/raw", func(c *Context) { c.HTML(http.StatusOK, "raw.tmpl", map[string]any{ - "now": time.Date(2017, 07, 01, 0, 0, 0, 0, time.UTC), + "now": time.Date(2017, 07, 01, 0, 0, 0, 0, time.UTC), //nolint:gofumpt }) }) }) @@ -73,7 +73,7 @@ func TestLoadHTMLGlobDebugMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -131,7 +131,7 @@ func TestLoadHTMLGlobTestMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -151,7 +151,7 @@ func TestLoadHTMLGlobReleaseMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -178,7 +178,7 @@ func TestLoadHTMLGlobUsingTLS(t *testing.T) { }, } client := &http.Client{Transport: tr} - res, err := client.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := client.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -198,7 +198,7 @@ func TestLoadHTMLGlobFromFuncMap(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/raw", ts.URL)) + res, err := http.Get(ts.URL + "/raw") if err != nil { t.Error(err) } @@ -229,7 +229,7 @@ func TestLoadHTMLFilesTestMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -249,7 +249,7 @@ func TestLoadHTMLFilesDebugMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -269,7 +269,7 @@ func TestLoadHTMLFilesReleaseMode(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := http.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -296,7 +296,7 @@ func TestLoadHTMLFilesUsingTLS(t *testing.T) { }, } client := &http.Client{Transport: tr} - res, err := client.Get(fmt.Sprintf("%s/test", ts.URL)) + res, err := client.Get(ts.URL + "/test") if err != nil { t.Error(err) } @@ -316,7 +316,116 @@ func TestLoadHTMLFilesFuncMap(t *testing.T) { ) defer ts.Close() - res, err := http.Get(fmt.Sprintf("%s/raw", ts.URL)) + res, err := http.Get(ts.URL + "/raw") + if err != nil { + t.Error(err) + } + + resp, _ := io.ReadAll(res.Body) + assert.Equal(t, "Date: 2017/07/01", string(resp)) +} + +var tmplFS = http.Dir("testdata/template") + +func TestLoadHTMLFSTestMode(t *testing.T) { + ts := setupHTMLFiles( + t, + TestMode, + false, + func(router *Engine) { + router.LoadHTMLFS(tmplFS, "hello.tmpl", "raw.tmpl") + }, + ) + defer ts.Close() + + res, err := http.Get(ts.URL + "/test") + if err != nil { + t.Error(err) + } + + resp, _ := io.ReadAll(res.Body) + assert.Equal(t, "

Hello world

", string(resp)) +} + +func TestLoadHTMLFSDebugMode(t *testing.T) { + ts := setupHTMLFiles( + t, + DebugMode, + false, + func(router *Engine) { + router.LoadHTMLFS(tmplFS, "hello.tmpl", "raw.tmpl") + }, + ) + defer ts.Close() + + res, err := http.Get(ts.URL + "/test") + if err != nil { + t.Error(err) + } + + resp, _ := io.ReadAll(res.Body) + assert.Equal(t, "

Hello world

", string(resp)) +} + +func TestLoadHTMLFSReleaseMode(t *testing.T) { + ts := setupHTMLFiles( + t, + ReleaseMode, + false, + func(router *Engine) { + router.LoadHTMLFS(tmplFS, "hello.tmpl", "raw.tmpl") + }, + ) + defer ts.Close() + + res, err := http.Get(ts.URL + "/test") + if err != nil { + t.Error(err) + } + + resp, _ := io.ReadAll(res.Body) + assert.Equal(t, "

Hello world

", string(resp)) +} + +func TestLoadHTMLFSUsingTLS(t *testing.T) { + ts := setupHTMLFiles( + t, + TestMode, + true, + func(router *Engine) { + router.LoadHTMLFS(tmplFS, "hello.tmpl", "raw.tmpl") + }, + ) + defer ts.Close() + + // Use InsecureSkipVerify for avoiding `x509: certificate signed by unknown authority` error + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + client := &http.Client{Transport: tr} + res, err := client.Get(ts.URL + "/test") + if err != nil { + t.Error(err) + } + + resp, _ := io.ReadAll(res.Body) + assert.Equal(t, "

Hello world

", string(resp)) +} + +func TestLoadHTMLFSFuncMap(t *testing.T) { + ts := setupHTMLFiles( + t, + TestMode, + false, + func(router *Engine) { + router.LoadHTMLFS(tmplFS, "hello.tmpl", "raw.tmpl") + }, + ) + defer ts.Close() + + res, err := http.Get(ts.URL + "/raw") if err != nil { t.Error(err) } @@ -327,31 +436,31 @@ func TestLoadHTMLFilesFuncMap(t *testing.T) { func TestAddRoute(t *testing.T) { router := New() - router.addRoute("GET", "/", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodGet, "/", HandlersChain{func(_ *Context) {}}) assert.Len(t, router.trees, 1) - assert.NotNil(t, router.trees.get("GET")) - assert.Nil(t, router.trees.get("POST")) + assert.NotNil(t, router.trees.get(http.MethodGet)) + assert.Nil(t, router.trees.get(http.MethodPost)) - router.addRoute("POST", "/", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodPost, "/", HandlersChain{func(_ *Context) {}}) assert.Len(t, router.trees, 2) - assert.NotNil(t, router.trees.get("GET")) - assert.NotNil(t, router.trees.get("POST")) + assert.NotNil(t, router.trees.get(http.MethodGet)) + assert.NotNil(t, router.trees.get(http.MethodPost)) - router.addRoute("POST", "/post", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodPost, "/post", HandlersChain{func(_ *Context) {}}) assert.Len(t, router.trees, 2) } func TestAddRouteFails(t *testing.T) { router := New() assert.Panics(t, func() { router.addRoute("", "/", HandlersChain{func(_ *Context) {}}) }) - assert.Panics(t, func() { router.addRoute("GET", "a", HandlersChain{func(_ *Context) {}}) }) - assert.Panics(t, func() { router.addRoute("GET", "/", HandlersChain{}) }) + assert.Panics(t, func() { router.addRoute(http.MethodGet, "a", HandlersChain{func(_ *Context) {}}) }) + assert.Panics(t, func() { router.addRoute(http.MethodGet, "/", HandlersChain{}) }) - router.addRoute("POST", "/post", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodPost, "/post", HandlersChain{func(_ *Context) {}}) assert.Panics(t, func() { - router.addRoute("POST", "/post", HandlersChain{func(_ *Context) {}}) + router.addRoute(http.MethodPost, "/post", HandlersChain{func(_ *Context) {}}) }) } @@ -493,27 +602,27 @@ func TestListOfRoutes(t *testing.T) { assert.Len(t, list, 7) assertRoutePresent(t, list, RouteInfo{ - Method: "GET", + Method: http.MethodGet, Path: "/favicon.ico", Handler: "^(.*/vendor/)?github.com/gin-gonic/gin.handlerTest1$", }) assertRoutePresent(t, list, RouteInfo{ - Method: "GET", + Method: http.MethodGet, Path: "/", Handler: "^(.*/vendor/)?github.com/gin-gonic/gin.handlerTest1$", }) assertRoutePresent(t, list, RouteInfo{ - Method: "GET", + Method: http.MethodGet, Path: "/users/", Handler: "^(.*/vendor/)?github.com/gin-gonic/gin.handlerTest2$", }) assertRoutePresent(t, list, RouteInfo{ - Method: "GET", + Method: http.MethodGet, Path: "/users/:id", Handler: "^(.*/vendor/)?github.com/gin-gonic/gin.handlerTest1$", }) assertRoutePresent(t, list, RouteInfo{ - Method: "POST", + Method: http.MethodPost, Path: "/users/:id", Handler: "^(.*/vendor/)?github.com/gin-gonic/gin.handlerTest2$", }) @@ -531,7 +640,7 @@ func TestEngineHandleContext(t *testing.T) { } assert.NotPanics(t, func() { - w := PerformRequest(r, "GET", "/") + w := PerformRequest(r, http.MethodGet, "/") assert.Equal(t, 301, w.Code) }) } @@ -564,7 +673,7 @@ func TestEngineHandleContextManyReEntries(t *testing.T) { }) assert.NotPanics(t, func() { - w := PerformRequest(r, "GET", "/"+strconv.Itoa(expectValue-1)) // include 0 value + w := PerformRequest(r, http.MethodGet, "/"+strconv.Itoa(expectValue-1)) // include 0 value assert.Equal(t, 200, w.Code) assert.Equal(t, expectValue, w.Body.Len()) }) @@ -573,6 +682,44 @@ func TestEngineHandleContextManyReEntries(t *testing.T) { assert.Equal(t, int64(expectValue), middlewareCounter) } +func TestEngineHandleContextPreventsMiddlewareReEntry(t *testing.T) { + // given + var handlerCounterV1, handlerCounterV2, middlewareCounterV1 int64 + + r := New() + v1 := r.Group("/v1") + { + v1.Use(func(c *Context) { + atomic.AddInt64(&middlewareCounterV1, 1) + }) + v1.GET("/test", func(c *Context) { + atomic.AddInt64(&handlerCounterV1, 1) + c.Status(http.StatusOK) + }) + } + + v2 := r.Group("/v2") + { + v2.GET("/test", func(c *Context) { + c.Request.URL.Path = "/v1/test" + r.HandleContext(c) + }, func(c *Context) { + atomic.AddInt64(&handlerCounterV2, 1) + }) + } + + // when + responseV1 := PerformRequest(r, "GET", "/v1/test") + responseV2 := PerformRequest(r, "GET", "/v2/test") + + // then + assert.Equal(t, 200, responseV1.Code) + assert.Equal(t, 200, responseV2.Code) + assert.Equal(t, int64(2), handlerCounterV1) + assert.Equal(t, int64(2), middlewareCounterV1) + assert.Equal(t, int64(1), handlerCounterV2) +} + func TestPrepareTrustedCIRDsWith(t *testing.T) { r := New() @@ -700,7 +847,7 @@ func handlerTest1(c *Context) {} func handlerTest2(c *Context) {} func TestNewOptionFunc(t *testing.T) { - var fc = func(e *Engine) { + fc := func(e *Engine) { e.GET("/test1", handlerTest1) e.GET("/test2", handlerTest2) @@ -712,8 +859,8 @@ func TestNewOptionFunc(t *testing.T) { r := New(fc) routes := r.Routes() - assertRoutePresent(t, routes, RouteInfo{Path: "/test1", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest1"}) - assertRoutePresent(t, routes, RouteInfo{Path: "/test2", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest2"}) + assertRoutePresent(t, routes, RouteInfo{Path: "/test1", Method: http.MethodGet, Handler: "github.com/gin-gonic/gin.handlerTest1"}) + assertRoutePresent(t, routes, RouteInfo{Path: "/test2", Method: http.MethodGet, Handler: "github.com/gin-gonic/gin.handlerTest2"}) } func TestWithOptionFunc(t *testing.T) { @@ -729,14 +876,14 @@ func TestWithOptionFunc(t *testing.T) { }) routes := r.Routes() - assertRoutePresent(t, routes, RouteInfo{Path: "/test1", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest1"}) - assertRoutePresent(t, routes, RouteInfo{Path: "/test2", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest2"}) + assertRoutePresent(t, routes, RouteInfo{Path: "/test1", Method: http.MethodGet, Handler: "github.com/gin-gonic/gin.handlerTest1"}) + assertRoutePresent(t, routes, RouteInfo{Path: "/test2", Method: http.MethodGet, Handler: "github.com/gin-gonic/gin.handlerTest2"}) } type Birthday string func (b *Birthday) UnmarshalParam(param string) error { - *b = Birthday(strings.Replace(param, "-", "/", -1)) + *b = Birthday(strings.ReplaceAll(param, "-", "/")) return nil } @@ -749,7 +896,7 @@ func TestCustomUnmarshalStruct(t *testing.T) { _ = ctx.BindQuery(&request) ctx.JSON(200, request.Birthday) }) - req := httptest.NewRequest("GET", "/test?birthday=2000-01-01", nil) + req := httptest.NewRequest(http.MethodGet, "/test?birthday=2000-01-01", nil) w := httptest.NewRecorder() route.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) @@ -761,8 +908,107 @@ func TestMethodNotAllowedNoRoute(t *testing.T) { g := New() g.HandleMethodNotAllowed = true - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) resp := httptest.NewRecorder() assert.NotPanics(t, func() { g.ServeHTTP(resp, req) }) assert.Equal(t, http.StatusNotFound, resp.Code) } + +// Test the fix for https://github.com/gin-gonic/gin/pull/4415 +func TestLiteralColonWithRun(t *testing.T) { + SetMode(TestMode) + router := New() + + router.GET(`/test\:action`, func(c *Context) { + c.JSON(http.StatusOK, H{"path": "literal_colon"}) + }) + + router.updateRouteTrees() + + w := httptest.NewRecorder() + + req, _ := http.NewRequest(http.MethodGet, "/test:action", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "literal_colon") +} + +func TestLiteralColonWithDirectServeHTTP(t *testing.T) { + SetMode(TestMode) + router := New() + + router.GET(`/test\:action`, func(c *Context) { + c.JSON(http.StatusOK, H{"path": "literal_colon"}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test:action", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "literal_colon") +} + +func TestLiteralColonWithHandler(t *testing.T) { + SetMode(TestMode) + router := New() + + router.GET(`/test\:action`, func(c *Context) { + c.JSON(http.StatusOK, H{"path": "literal_colon"}) + }) + + handler := router.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test:action", nil) + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "literal_colon") +} + +func TestLiteralColonWithHTTPServer(t *testing.T) { + SetMode(TestMode) + router := New() + + router.GET(`/test\:action`, func(c *Context) { + c.JSON(http.StatusOK, H{"path": "literal_colon"}) + }) + + router.GET("/test/:param", func(c *Context) { + c.JSON(http.StatusOK, H{"param": c.Param("param")}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test:action", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "literal_colon") + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/test/foo", nil) + router.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusOK, w2.Code) + assert.Contains(t, w2.Body.String(), "foo") +} + +// Test that updateRouteTrees is called only once +func TestUpdateRouteTreesCalledOnce(t *testing.T) { + SetMode(TestMode) + router := New() + + router.GET(`/test\:action`, func(c *Context) { + c.String(http.StatusOK, "ok") + }) + + for range 5 { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test:action", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + } +} diff --git a/githubapi_test.go b/githubapi_test.go index 6d348787..20d4aeaf 100644 --- a/githubapi_test.go +++ b/githubapi_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "testing" @@ -297,8 +298,8 @@ func TestShouldBindUri(t *testing.T) { router.Handle(http.MethodGet, "/rest/:name/:id", func(c *Context) { var person Person require.NoError(t, c.ShouldBindUri(&person)) - assert.NotEqual(t, "", person.Name) - assert.NotEqual(t, "", person.ID) + assert.NotEmpty(t, person.Name) + assert.NotEmpty(t, person.ID) c.String(http.StatusOK, "ShouldBindUri test OK") }) @@ -319,8 +320,8 @@ func TestBindUri(t *testing.T) { router.Handle(http.MethodGet, "/rest/:name/:id", func(c *Context) { var person Person require.NoError(t, c.BindUri(&person)) - assert.NotEqual(t, "", person.Name) - assert.NotEqual(t, "", person.ID) + assert.NotEmpty(t, person.Name) + assert.NotEmpty(t, person.ID) c.String(http.StatusOK, "BindUri test OK") }) @@ -411,7 +412,7 @@ func exampleFromPath(path string) (string, Params) { } if start >= 0 { if c == '/' { - value := fmt.Sprint(rand.Intn(100000)) + value := strconv.Itoa(rand.Intn(100000)) params = append(params, Param{ Key: path[start:i], Value: value, @@ -425,7 +426,7 @@ func exampleFromPath(path string) (string, Params) { } } if start >= 0 { - value := fmt.Sprint(rand.Intn(100000)) + value := strconv.Itoa(rand.Intn(100000)) params = append(params, Param{ Key: path[start:], Value: value, diff --git a/go.mod b/go.mod index 035c2dea..628ab4c5 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,42 @@ module github.com/gin-gonic/gin -go 1.21.0 +go 1.24.0 require ( - github.com/bytedance/sonic v1.11.6 - github.com/gin-contrib/sse v0.1.0 - github.com/go-playground/validator/v10 v10.20.0 + github.com/bytedance/sonic v1.14.2 + github.com/gin-contrib/sse v1.1.0 + github.com/go-playground/validator/v10 v10.28.0 github.com/goccy/go-json v0.10.2 + github.com/goccy/go-yaml v1.18.0 github.com/json-iterator/go v1.1.12 github.com/mattn/go-isatty v0.0.20 - github.com/pelletier/go-toml/v2 v2.2.2 - github.com/quic-go/quic-go v0.43.1 - github.com/stretchr/testify v1.9.0 - github.com/ugorji/go/codec v1.2.12 - golang.org/x/net v0.27.0 - google.golang.org/protobuf v1.34.1 - gopkg.in/yaml.v3 v3.0.1 + github.com/modern-go/reflect2 v1.0.2 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/quic-go/quic-go v0.56.0 + github.com/stretchr/testify v1.11.1 + github.com/ugorji/go/codec v1.3.1 + golang.org/x/net v0.47.0 + google.golang.org/protobuf v1.36.10 ) require ( - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect - github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - go.uber.org/mock v0.4.0 // indirect - golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 55a21627..90d5e526 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,38 @@ -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -56,69 +45,51 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= -github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= -github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= -github.com/quic-go/quic-go v0.43.1 h1:fLiMNfQVe9q2JvSsiXo4fXOEguXHGGl9+6gLp4RPeZQ= -github.com/quic-go/quic-go v0.43.1/go.mod h1:132kz4kL3F9vxhW3CtQJLDVwcFe5wdWeJXXijhsO57M= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= +github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= -golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/bytesconv/bytesconv_test.go b/internal/bytesconv/bytesconv_test.go index eeaad5ee..4972ae70 100644 --- a/internal/bytesconv/bytesconv_test.go +++ b/internal/bytesconv/bytesconv_test.go @@ -6,14 +6,17 @@ package bytesconv import ( "bytes" + cRand "crypto/rand" "math/rand" "strings" "testing" "time" ) -var testString = "Albert Einstein: Logic will get you from A to B. Imagination will take you everywhere." -var testBytes = []byte(testString) +var ( + testString = "Albert Einstein: Logic will get you from A to B. Imagination will take you everywhere." + testBytes = []byte(testString) +) func rawBytesToStr(b []byte) string { return string(b) @@ -28,7 +31,10 @@ func rawStrToBytes(s string) []byte { func TestBytesToString(t *testing.T) { data := make([]byte, 1024) for i := 0; i < 100; i++ { - rand.Read(data) + _, err := cRand.Read(data) + if err != nil { + t.Fatal(err) + } if rawBytesToStr(data) != BytesToString(data) { t.Fatal("don't match") } @@ -42,7 +48,7 @@ const ( letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits ) -var src = rand.NewSource(time.Now().UnixNano()) +var src = rand.New(rand.NewSource(time.Now().UnixNano())) func RandStringBytesMaskImprSrcSB(n int) string { sb := strings.Builder{} @@ -75,25 +81,25 @@ func TestStringToBytes(t *testing.T) { // go test -v -run=none -bench=^BenchmarkBytesConv -benchmem=true func BenchmarkBytesConvBytesToStrRaw(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { rawBytesToStr(testBytes) } } func BenchmarkBytesConvBytesToStr(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { BytesToString(testBytes) } } func BenchmarkBytesConvStrToBytesRaw(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { rawStrToBytes(testString) } } func BenchmarkBytesConvStrToBytes(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { StringToBytes(testString) } } diff --git a/internal/fs/fs.go b/internal/fs/fs.go new file mode 100644 index 00000000..c2530383 --- /dev/null +++ b/internal/fs/fs.go @@ -0,0 +1,21 @@ +package fs + +import ( + "io/fs" + "net/http" +) + +// FileSystem implements an [fs.FS]. +type FileSystem struct { + http.FileSystem +} + +// Open passes `Open` to the upstream implementation and return an [fs.File]. +func (o FileSystem) Open(name string) (fs.File, error) { + f, err := o.FileSystem.Open(name) + if err != nil { + return nil, err + } + + return fs.File(f), nil +} diff --git a/internal/fs/fs_test.go b/internal/fs/fs_test.go new file mode 100644 index 00000000..f937cf7f --- /dev/null +++ b/internal/fs/fs_test.go @@ -0,0 +1,49 @@ +package fs + +import ( + "errors" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockFileSystem struct { + open func(name string) (http.File, error) +} + +func (m *mockFileSystem) Open(name string) (http.File, error) { + return m.open(name) +} + +func TestFileSystem_Open(t *testing.T) { + var testFile *os.File + mockFS := &mockFileSystem{ + open: func(name string) (http.File, error) { + return testFile, nil + }, + } + fs := &FileSystem{mockFS} + + file, err := fs.Open("foo") + + require.NoError(t, err) + assert.Equal(t, testFile, file) +} + +func TestFileSystem_Open_err(t *testing.T) { + testError := errors.New("mock") + mockFS := &mockFileSystem{ + open: func(_ string) (http.File, error) { + return nil, testError + }, + } + fs := &FileSystem{mockFS} + + file, err := fs.Open("foo") + + require.ErrorIs(t, err, testError) + assert.Nil(t, file) +} diff --git a/internal/json/go_json.go b/internal/json/go_json.go deleted file mode 100644 index 47c35598..00000000 --- a/internal/json/go_json.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2017 Bo-Yi Wu. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -//go:build go_json - -package json - -import json "github.com/goccy/go-json" - -var ( - // Marshal is exported by gin/json package. - Marshal = json.Marshal - // Unmarshal is exported by gin/json package. - Unmarshal = json.Unmarshal - // MarshalIndent is exported by gin/json package. - MarshalIndent = json.MarshalIndent - // NewDecoder is exported by gin/json package. - NewDecoder = json.NewDecoder - // NewEncoder is exported by gin/json package. - NewEncoder = json.NewEncoder -) diff --git a/internal/json/json.go b/internal/json/json.go deleted file mode 100644 index c7ee83eb..00000000 --- a/internal/json/json.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2017 Bo-Yi Wu. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -//go:build !jsoniter && !go_json && !(sonic && avx && (linux || windows || darwin) && amd64) - -package json - -import "encoding/json" - -var ( - // Marshal is exported by gin/json package. - Marshal = json.Marshal - // Unmarshal is exported by gin/json package. - Unmarshal = json.Unmarshal - // MarshalIndent is exported by gin/json package. - MarshalIndent = json.MarshalIndent - // NewDecoder is exported by gin/json package. - NewDecoder = json.NewDecoder - // NewEncoder is exported by gin/json package. - NewEncoder = json.NewEncoder -) diff --git a/internal/json/jsoniter.go b/internal/json/jsoniter.go deleted file mode 100644 index 45ed16ba..00000000 --- a/internal/json/jsoniter.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2017 Bo-Yi Wu. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -//go:build jsoniter - -package json - -import jsoniter "github.com/json-iterator/go" - -var ( - json = jsoniter.ConfigCompatibleWithStandardLibrary - // Marshal is exported by gin/json package. - Marshal = json.Marshal - // Unmarshal is exported by gin/json package. - Unmarshal = json.Unmarshal - // MarshalIndent is exported by gin/json package. - MarshalIndent = json.MarshalIndent - // NewDecoder is exported by gin/json package. - NewDecoder = json.NewDecoder - // NewEncoder is exported by gin/json package. - NewEncoder = json.NewEncoder -) diff --git a/internal/json/sonic.go b/internal/json/sonic.go deleted file mode 100644 index 529e16d0..00000000 --- a/internal/json/sonic.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2022 Gin Core Team. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -//go:build sonic && avx && (linux || windows || darwin) && amd64 - -package json - -import "github.com/bytedance/sonic" - -var ( - json = sonic.ConfigStd - // Marshal is exported by gin/json package. - Marshal = json.Marshal - // Unmarshal is exported by gin/json package. - Unmarshal = json.Unmarshal - // MarshalIndent is exported by gin/json package. - MarshalIndent = json.MarshalIndent - // NewDecoder is exported by gin/json package. - NewDecoder = json.NewDecoder - // NewEncoder is exported by gin/json package. - NewEncoder = json.NewEncoder -) diff --git a/logger.go b/logger.go index 741cdf00..3d87208e 100644 --- a/logger.go +++ b/logger.go @@ -44,7 +44,7 @@ type LoggerConfig struct { // Optional. Default value is gin.DefaultWriter. Output io.Writer - // SkipPaths is an url path array which logs are not written. + // SkipPaths is a URL path array which logs are not written. // Optional. SkipPaths []string @@ -82,7 +82,7 @@ type LogFormatterParams struct { // BodySize is the size of the Response Body BodySize int // Keys are the keys set on the request's context. - Keys map[string]any + Keys map[any]any } // StatusCodeColor is the ANSI color for appropriately logging http status code to a terminal. @@ -103,6 +103,27 @@ func (p *LogFormatterParams) StatusCodeColor() string { } } +// LatencyColor is the ANSI color for latency +func (p *LogFormatterParams) LatencyColor() string { + latency := p.Latency + switch { + case latency < time.Millisecond*100: + return white + case latency < time.Millisecond*200: + return green + case latency < time.Millisecond*300: + return cyan + case latency < time.Millisecond*500: + return blue + case latency < time.Second: + return yellow + case latency < time.Second*2: + return magenta + default: + return red + } +} + // MethodColor is the ANSI color for appropriately logging http method to a terminal. func (p *LogFormatterParams) MethodColor() string { method := p.Method @@ -139,20 +160,27 @@ func (p *LogFormatterParams) IsOutputColor() bool { // defaultLogFormatter is the default log format function Logger middleware uses. var defaultLogFormatter = func(param LogFormatterParams) string { - var statusColor, methodColor, resetColor string + var statusColor, methodColor, resetColor, latencyColor string if param.IsOutputColor() { statusColor = param.StatusCodeColor() methodColor = param.MethodColor() resetColor = param.ResetColor() + latencyColor = param.LatencyColor() } - if param.Latency > time.Minute { - param.Latency = param.Latency.Truncate(time.Second) + switch { + case param.Latency > time.Minute: + param.Latency = param.Latency.Truncate(time.Second * 10) + case param.Latency > time.Second: + param.Latency = param.Latency.Truncate(time.Millisecond * 10) + case param.Latency > time.Millisecond: + param.Latency = param.Latency.Truncate(time.Microsecond * 10) } - return fmt.Sprintf("[GIN] %v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", + + return fmt.Sprintf("[GIN] %v |%s %3d %s|%s %8v %s| %15s |%s %-7s %s %#v\n%s", param.TimeStamp.Format("2006/01/02 - 15:04:05"), statusColor, param.StatusCode, resetColor, - param.Latency, + latencyColor, param.Latency, resetColor, param.ClientIP, methodColor, param.Method, resetColor, param.Path, diff --git a/logger_test.go b/logger_test.go index b05df740..53d0df95 100644 --- a/logger_test.go +++ b/logger_test.go @@ -31,31 +31,31 @@ func TestLogger(t *testing.T) { router.HEAD("/example", func(c *Context) {}) router.OPTIONS("/example", func(c *Context) {}) - PerformRequest(router, "GET", "/example?a=100") + PerformRequest(router, http.MethodGet, "/example?a=100") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/example") assert.Contains(t, buffer.String(), "a=100") // I wrote these first (extending the above) but then realized they are more // like integration tests because they test the whole logging process rather - // than individual functions. Im not sure where these should go. + // than individual functions. I'm not sure where these should go. buffer.Reset() - PerformRequest(router, "POST", "/example") + PerformRequest(router, http.MethodPost, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "POST") + assert.Contains(t, buffer.String(), http.MethodPost) assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "PUT", "/example") + PerformRequest(router, http.MethodPut, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "PUT") + assert.Contains(t, buffer.String(), http.MethodPut) assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "DELETE", "/example") + PerformRequest(router, http.MethodDelete, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "DELETE") + assert.Contains(t, buffer.String(), http.MethodDelete) assert.Contains(t, buffer.String(), "/example") buffer.Reset() @@ -77,9 +77,9 @@ func TestLogger(t *testing.T) { assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "GET", "/notfound") + PerformRequest(router, http.MethodGet, "/notfound") assert.Contains(t, buffer.String(), "404") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/notfound") } @@ -95,31 +95,31 @@ func TestLoggerWithConfig(t *testing.T) { router.HEAD("/example", func(c *Context) {}) router.OPTIONS("/example", func(c *Context) {}) - PerformRequest(router, "GET", "/example?a=100") + PerformRequest(router, http.MethodGet, "/example?a=100") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/example") assert.Contains(t, buffer.String(), "a=100") // I wrote these first (extending the above) but then realized they are more // like integration tests because they test the whole logging process rather - // than individual functions. Im not sure where these should go. + // than individual functions. I'm not sure where these should go. buffer.Reset() - PerformRequest(router, "POST", "/example") + PerformRequest(router, http.MethodPost, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "POST") + assert.Contains(t, buffer.String(), http.MethodPost) assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "PUT", "/example") + PerformRequest(router, http.MethodPut, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "PUT") + assert.Contains(t, buffer.String(), http.MethodPut) assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "DELETE", "/example") + PerformRequest(router, http.MethodDelete, "/example") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "DELETE") + assert.Contains(t, buffer.String(), http.MethodDelete) assert.Contains(t, buffer.String(), "/example") buffer.Reset() @@ -141,9 +141,9 @@ func TestLoggerWithConfig(t *testing.T) { assert.Contains(t, buffer.String(), "/example") buffer.Reset() - PerformRequest(router, "GET", "/notfound") + PerformRequest(router, http.MethodGet, "/notfound") assert.Contains(t, buffer.String(), "404") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/notfound") } @@ -169,19 +169,19 @@ func TestLoggerWithFormatter(t *testing.T) { ) })) router.GET("/example", func(c *Context) {}) - PerformRequest(router, "GET", "/example?a=100") + PerformRequest(router, http.MethodGet, "/example?a=100") // output test assert.Contains(t, buffer.String(), "[FORMATTER TEST]") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/example") assert.Contains(t, buffer.String(), "a=100") } func TestLoggerWithConfigFormatting(t *testing.T) { var gotParam LogFormatterParams - var gotKeys map[string]any + var gotKeys map[any]any buffer := new(strings.Builder) router := New() @@ -210,12 +210,12 @@ func TestLoggerWithConfigFormatting(t *testing.T) { gotKeys = c.Keys time.Sleep(time.Millisecond) }) - PerformRequest(router, "GET", "/example?a=100") + PerformRequest(router, http.MethodGet, "/example?a=100") // output test assert.Contains(t, buffer.String(), "[FORMATTER TEST]") assert.Contains(t, buffer.String(), "200") - assert.Contains(t, buffer.String(), "GET") + assert.Contains(t, buffer.String(), http.MethodGet) assert.Contains(t, buffer.String(), "/example") assert.Contains(t, buffer.String(), "a=100") @@ -225,7 +225,7 @@ func TestLoggerWithConfigFormatting(t *testing.T) { assert.Equal(t, 200, gotParam.StatusCode) assert.NotEmpty(t, gotParam.Latency) assert.Equal(t, "20.20.20.20", gotParam.ClientIP) - assert.Equal(t, "GET", gotParam.Method) + assert.Equal(t, http.MethodGet, gotParam.Method) assert.Equal(t, "/example?a=100", gotParam.Path) assert.Empty(t, gotParam.ErrorMessage) assert.Equal(t, gotKeys, gotParam.Keys) @@ -239,7 +239,7 @@ func TestDefaultLogFormatter(t *testing.T) { StatusCode: 200, Latency: time.Second * 5, ClientIP: "20.20.20.20", - Method: "GET", + Method: http.MethodGet, Path: "/", ErrorMessage: "", isTerm: false, @@ -250,7 +250,7 @@ func TestDefaultLogFormatter(t *testing.T) { StatusCode: 200, Latency: time.Second * 5, ClientIP: "20.20.20.20", - Method: "GET", + Method: http.MethodGet, Path: "/", ErrorMessage: "", isTerm: true, @@ -260,7 +260,7 @@ func TestDefaultLogFormatter(t *testing.T) { StatusCode: 200, Latency: time.Millisecond * 9876543210, ClientIP: "20.20.20.20", - Method: "GET", + Method: http.MethodGet, Path: "/", ErrorMessage: "", isTerm: true, @@ -271,17 +271,17 @@ func TestDefaultLogFormatter(t *testing.T) { StatusCode: 200, Latency: time.Millisecond * 9876543210, ClientIP: "20.20.20.20", - Method: "GET", + Method: http.MethodGet, Path: "/", ErrorMessage: "", isTerm: false, } - assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 | 200 | 5s | 20.20.20.20 | GET \"/\"\n", defaultLogFormatter(termFalseParam)) - assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 | 200 | 2743h29m3s | 20.20.20.20 | GET \"/\"\n", defaultLogFormatter(termFalseLongDurationParam)) + assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 | 200 | 5s | 20.20.20.20 | GET \"/\"\n", defaultLogFormatter(termFalseParam)) + assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 | 200 | 2743h29m0s | 20.20.20.20 | GET \"/\"\n", defaultLogFormatter(termFalseLongDurationParam)) - assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 |\x1b[97;42m 200 \x1b[0m| 5s | 20.20.20.20 |\x1b[97;44m GET \x1b[0m \"/\"\n", defaultLogFormatter(termTrueParam)) - assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 |\x1b[97;42m 200 \x1b[0m| 2743h29m3s | 20.20.20.20 |\x1b[97;44m GET \x1b[0m \"/\"\n", defaultLogFormatter(termTrueLongDurationParam)) + assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 |\x1b[97;42m 200 \x1b[0m|\x1b[97;41m 5s \x1b[0m| 20.20.20.20 |\x1b[97;44m GET \x1b[0m \"/\"\n", defaultLogFormatter(termTrueParam)) + assert.Equal(t, "[GIN] 2018/12/07 - 09:11:42 |\x1b[97;42m 200 \x1b[0m|\x1b[97;41m 2743h29m0s \x1b[0m| 20.20.20.20 |\x1b[97;44m GET \x1b[0m \"/\"\n", defaultLogFormatter(termTrueLongDurationParam)) } func TestColorForMethod(t *testing.T) { @@ -292,10 +292,10 @@ func TestColorForMethod(t *testing.T) { return p.MethodColor() } - assert.Equal(t, blue, colorForMethod("GET"), "get should be blue") - assert.Equal(t, cyan, colorForMethod("POST"), "post should be cyan") - assert.Equal(t, yellow, colorForMethod("PUT"), "put should be yellow") - assert.Equal(t, red, colorForMethod("DELETE"), "delete should be red") + assert.Equal(t, blue, colorForMethod(http.MethodGet), "get should be blue") + assert.Equal(t, cyan, colorForMethod(http.MethodPost), "post should be cyan") + assert.Equal(t, yellow, colorForMethod(http.MethodPut), "put should be yellow") + assert.Equal(t, red, colorForMethod(http.MethodDelete), "delete should be red") assert.Equal(t, green, colorForMethod("PATCH"), "patch should be green") assert.Equal(t, magenta, colorForMethod("HEAD"), "head should be magenta") assert.Equal(t, white, colorForMethod("OPTIONS"), "options should be white") @@ -317,6 +317,23 @@ func TestColorForStatus(t *testing.T) { assert.Equal(t, red, colorForStatus(2), "other things should be red") } +func TestColorForLatency(t *testing.T) { + colorForLantency := func(latency time.Duration) string { + p := LogFormatterParams{ + Latency: latency, + } + return p.LatencyColor() + } + + assert.Equal(t, white, colorForLantency(time.Duration(0)), "0 should be white") + assert.Equal(t, white, colorForLantency(time.Millisecond*20), "20ms should be white") + assert.Equal(t, green, colorForLantency(time.Millisecond*150), "150ms should be green") + assert.Equal(t, cyan, colorForLantency(time.Millisecond*250), "250ms should be cyan") + assert.Equal(t, yellow, colorForLantency(time.Millisecond*600), "600ms should be yellow") + assert.Equal(t, magenta, colorForLantency(time.Millisecond*1500), "1.5s should be magenta") + assert.Equal(t, red, colorForLantency(time.Second*3), "other things should be red") +} + func TestResetColor(t *testing.T) { p := LogFormatterParams{} assert.Equal(t, string([]byte{27, 91, 48, 109}), p.ResetColor()) @@ -369,15 +386,15 @@ func TestErrorLogger(t *testing.T) { c.String(http.StatusInternalServerError, "hola!") }) - w := PerformRequest(router, "GET", "/error") + w := PerformRequest(router, http.MethodGet, "/error") assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "{\"error\":\"this is an error\"}", w.Body.String()) + assert.JSONEq(t, "{\"error\":\"this is an error\"}", w.Body.String()) - w = PerformRequest(router, "GET", "/abort") + w = PerformRequest(router, http.MethodGet, "/abort") assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Equal(t, "{\"error\":\"no authorized\"}", w.Body.String()) + assert.JSONEq(t, "{\"error\":\"no authorized\"}", w.Body.String()) - w = PerformRequest(router, "GET", "/print") + w = PerformRequest(router, http.MethodGet, "/print") assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, "hola!{\"error\":\"this is an error\"}", w.Body.String()) } @@ -389,11 +406,11 @@ func TestLoggerWithWriterSkippingPaths(t *testing.T) { router.GET("/logged", func(c *Context) {}) router.GET("/skipped", func(c *Context) {}) - PerformRequest(router, "GET", "/logged") + PerformRequest(router, http.MethodGet, "/logged") assert.Contains(t, buffer.String(), "200") buffer.Reset() - PerformRequest(router, "GET", "/skipped") + PerformRequest(router, http.MethodGet, "/skipped") assert.Contains(t, buffer.String(), "") } @@ -407,11 +424,11 @@ func TestLoggerWithConfigSkippingPaths(t *testing.T) { router.GET("/logged", func(c *Context) {}) router.GET("/skipped", func(c *Context) {}) - PerformRequest(router, "GET", "/logged") + PerformRequest(router, http.MethodGet, "/logged") assert.Contains(t, buffer.String(), "200") buffer.Reset() - PerformRequest(router, "GET", "/skipped") + PerformRequest(router, http.MethodGet, "/skipped") assert.Contains(t, buffer.String(), "") } @@ -427,11 +444,11 @@ func TestLoggerWithConfigSkipper(t *testing.T) { router.GET("/logged", func(c *Context) { c.Status(http.StatusOK) }) router.GET("/skipped", func(c *Context) { c.Status(http.StatusNoContent) }) - PerformRequest(router, "GET", "/logged") + PerformRequest(router, http.MethodGet, "/logged") assert.Contains(t, buffer.String(), "200") buffer.Reset() - PerformRequest(router, "GET", "/skipped") + PerformRequest(router, http.MethodGet, "/skipped") assert.Contains(t, buffer.String(), "") } diff --git a/middleware_test.go b/middleware_test.go index acdf89c4..8dc7c3b3 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -35,7 +35,7 @@ func TestMiddlewareGeneralCase(t *testing.T) { signature += " XX " }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusOK, w.Code) @@ -71,7 +71,7 @@ func TestMiddlewareNoRoute(t *testing.T) { signature += " X " }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusNotFound, w.Code) @@ -108,7 +108,7 @@ func TestMiddlewareNoMethodEnabled(t *testing.T) { signature += " XX " }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusMethodNotAllowed, w.Code) @@ -149,7 +149,7 @@ func TestMiddlewareNoMethodDisabled(t *testing.T) { }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusNotFound, w.Code) @@ -175,7 +175,7 @@ func TestMiddlewareAbort(t *testing.T) { }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusUnauthorized, w.Code) @@ -196,14 +196,14 @@ func TestMiddlewareAbortHandlersChainAndNext(t *testing.T) { c.Next() }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusGone, w.Code) assert.Equal(t, "ACB", signature) } -// TestFailHandlersChain - ensure that Fail interrupt used middleware in fifo order as +// TestMiddlewareFailHandlersChain - ensure that Fail interrupt used middleware in fifo order as // as well as Abort func TestMiddlewareFailHandlersChain(t *testing.T) { // SETUP @@ -219,7 +219,7 @@ func TestMiddlewareFailHandlersChain(t *testing.T) { signature += "C" }) // RUN - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") // TEST assert.Equal(t, http.StatusInternalServerError, w.Code) @@ -246,8 +246,8 @@ func TestMiddlewareWrite(t *testing.T) { }) }) - w := PerformRequest(router, "GET", "/") + w := PerformRequest(router, http.MethodGet, "/") assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Equal(t, strings.Replace("hola\nbar{\"foo\":\"bar\"}{\"foo\":\"bar\"}event:test\ndata:message\n\n", " ", "", -1), strings.Replace(w.Body.String(), " ", "", -1)) + assert.Equal(t, strings.ReplaceAll("hola\nbar{\"foo\":\"bar\"}{\"foo\":\"bar\"}event:test\ndata:message\n\n", " ", ""), strings.ReplaceAll(w.Body.String(), " ", "")) } diff --git a/mode.go b/mode.go index 13aa3be0..dfef07d6 100644 --- a/mode.go +++ b/mode.go @@ -44,8 +44,10 @@ var DefaultWriter io.Writer = os.Stdout // DefaultErrorWriter is the default io.Writer used by Gin to debug errors var DefaultErrorWriter io.Writer = os.Stderr -var ginMode int32 = debugCode -var modeName atomic.Value +var ( + ginMode int32 = debugCode + modeName atomic.Value +) func init() { mode := os.Getenv(EnvGinMode) @@ -63,7 +65,7 @@ func SetMode(value string) { } switch value { - case DebugMode, "": + case DebugMode: atomic.StoreInt32(&ginMode, debugCode) case ReleaseMode: atomic.StoreInt32(&ginMode, releaseCode) diff --git a/path_test.go b/path_test.go index 2269b78e..7d86086f 100644 --- a/path_test.go +++ b/path_test.go @@ -94,7 +94,7 @@ func TestPathCleanMallocs(t *testing.T) { func BenchmarkPathClean(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { for _, test := range cleanTests { cleanPath(test.path) } @@ -134,10 +134,10 @@ func TestPathCleanLong(t *testing.T) { func BenchmarkPathCleanLong(b *testing.B) { cleanTests := genLongPaths() - b.ResetTimer() + b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { for _, test := range cleanTests { cleanPath(test.path) } diff --git a/recovery.go b/recovery.go index 515f9d2a..fdd463f3 100644 --- a/recovery.go +++ b/recovery.go @@ -17,14 +17,13 @@ import ( "runtime" "strings" "time" + + "github.com/gin-gonic/gin/internal/bytesconv" ) -var ( - dunno = []byte("???") - centerDot = []byte("·") - dot = []byte(".") - slash = []byte("/") -) +const dunno = "???" + +var dunnoBytes = []byte(dunno) // RecoveryFunc defines the function passable to CustomRecovery. type RecoveryFunc func(c *Context, err any) @@ -70,24 +69,15 @@ func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc { } } if logger != nil { - stack := stack(3) - httpRequest, _ := httputil.DumpRequest(c.Request, false) - headers := strings.Split(string(httpRequest), "\r\n") - for idx, header := range headers { - current := strings.Split(header, ":") - if current[0] == "Authorization" { - headers[idx] = current[0] + ": *" - } - } - headersToStr := strings.Join(headers, "\r\n") + const stackSkip = 3 if brokenPipe { - logger.Printf("%s\n%s%s", err, headersToStr, reset) + logger.Printf("%s\n%s%s", err, secureRequestDump(c.Request), reset) } else if IsDebugging() { logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s", - timeFormat(time.Now()), headersToStr, err, stack, reset) + timeFormat(time.Now()), secureRequestDump(c.Request), err, stack(stackSkip), reset) } else { logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s", - timeFormat(time.Now()), err, stack, reset) + timeFormat(time.Now()), err, stack(stackSkip), reset) } } if brokenPipe { @@ -103,6 +93,21 @@ func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc { } } +// secureRequestDump returns a sanitized HTTP request dump where the Authorization header, +// if present, is replaced with a masked value ("Authorization: *") to avoid leaking sensitive credentials. +// +// Currently, only the Authorization header is sanitized. All other headers and request data remain unchanged. +func secureRequestDump(r *http.Request) string { + httpRequest, _ := httputil.DumpRequest(r, false) + lines := strings.Split(bytesconv.BytesToString(httpRequest), "\r\n") + for i, line := range lines { + if strings.HasPrefix(line, "Authorization:") { + lines[i] = "Authorization: *" + } + } + return strings.Join(lines, "\r\n") +} + func defaultHandleRecovery(c *Context, _ any) { c.AbortWithStatus(http.StatusInternalServerError) } @@ -138,18 +143,18 @@ func stack(skip int) []byte { func source(lines [][]byte, n int) []byte { n-- // in stack trace, lines are 1-indexed but our array is 0-indexed if n < 0 || n >= len(lines) { - return dunno + return dunnoBytes } return bytes.TrimSpace(lines[n]) } // function returns, if possible, the name of the function containing the PC. -func function(pc uintptr) []byte { +func function(pc uintptr) string { fn := runtime.FuncForPC(pc) if fn == nil { return dunno } - name := []byte(fn.Name()) + name := fn.Name() // The name includes the path name to the package, which is unnecessary // since the file name is already included. Plus, it has center dots. // That is, we see @@ -158,13 +163,13 @@ func function(pc uintptr) []byte { // *T.ptrmethod // Also the package path might contain dot (e.g. code.google.com/...), // so first eliminate the path prefix - if lastSlash := bytes.LastIndex(name, slash); lastSlash >= 0 { + if lastSlash := strings.LastIndexByte(name, '/'); lastSlash >= 0 { name = name[lastSlash+1:] } - if period := bytes.Index(name, dot); period >= 0 { + if period := strings.IndexByte(name, '.'); period >= 0 { name = name[period+1:] } - name = bytes.ReplaceAll(name, centerDot, dot) + name = strings.ReplaceAll(name, "·", ".") return name } diff --git a/recovery_test.go b/recovery_test.go index fa8ab894..8a9e3475 100644 --- a/recovery_test.go +++ b/recovery_test.go @@ -5,7 +5,6 @@ package gin import ( - "fmt" "net" "net/http" "os" @@ -26,14 +25,14 @@ func TestPanicClean(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery", + w := PerformRequest(router, http.MethodGet, "/recovery", header{ Key: "Host", Value: "www.google.com", }, header{ Key: "Authorization", - Value: fmt.Sprintf("Bearer %s", password), + Value: "Bearer " + password, }, header{ Key: "Content-Type", @@ -56,7 +55,7 @@ func TestPanicInHandler(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Contains(t, buffer.String(), "panic recovered") @@ -67,7 +66,7 @@ func TestPanicInHandler(t *testing.T) { // Debug mode prints the request SetMode(DebugMode) // RUN - w = PerformRequest(router, "GET", "/recovery") + w = PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Contains(t, buffer.String(), "GET /recovery") @@ -84,21 +83,21 @@ func TestPanicWithAbort(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSource(t *testing.T) { bs := source(nil, 0) - assert.Equal(t, dunno, bs) + assert.Equal(t, dunnoBytes, bs) in := [][]byte{ []byte("Hello world."), []byte("Hi, gin.."), } bs = source(in, 10) - assert.Equal(t, dunno, bs) + assert.Equal(t, dunnoBytes, bs) bs = source(in, 1) assert.Equal(t, []byte("Hello world."), bs) @@ -135,7 +134,7 @@ func TestPanicWithBrokenPipe(t *testing.T) { panic(e) }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, expectCode, w.Code) assert.Contains(t, strings.ToLower(buf.String()), expectMsg) @@ -156,7 +155,7 @@ func TestCustomRecoveryWithWriter(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "panic recovered") @@ -167,7 +166,7 @@ func TestCustomRecoveryWithWriter(t *testing.T) { // Debug mode prints the request SetMode(DebugMode) // RUN - w = PerformRequest(router, "GET", "/recovery") + w = PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "GET /recovery") @@ -191,7 +190,7 @@ func TestCustomRecovery(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "panic recovered") @@ -202,7 +201,7 @@ func TestCustomRecovery(t *testing.T) { // Debug mode prints the request SetMode(DebugMode) // RUN - w = PerformRequest(router, "GET", "/recovery") + w = PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "GET /recovery") @@ -226,7 +225,7 @@ func TestRecoveryWithWriterWithCustomRecovery(t *testing.T) { panic("Oupps, Houston, we have a problem") }) // RUN - w := PerformRequest(router, "GET", "/recovery") + w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "panic recovered") @@ -237,7 +236,7 @@ func TestRecoveryWithWriterWithCustomRecovery(t *testing.T) { // Debug mode prints the request SetMode(DebugMode) // RUN - w = PerformRequest(router, "GET", "/recovery") + w = PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, buffer.String(), "GET /recovery") @@ -246,3 +245,65 @@ func TestRecoveryWithWriterWithCustomRecovery(t *testing.T) { SetMode(TestMode) } + +func TestSecureRequestDump(t *testing.T) { + tests := []struct { + name string + req *http.Request + wantContains string + wantNotContain string + }{ + { + name: "Authorization header standard case", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header.Set("Authorization", "Bearer secret-token") + return r + }(), + wantContains: "Authorization: *", + wantNotContain: "Bearer secret-token", + }, + { + name: "authorization header lowercase", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header.Set("authorization", "some-secret") + return r + }(), + wantContains: "Authorization: *", + wantNotContain: "some-secret", + }, + { + name: "Authorization header mixed case", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header.Set("AuThOrIzAtIoN", "token123") + return r + }(), + wantContains: "Authorization: *", + wantNotContain: "token123", + }, + { + name: "No Authorization header", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header.Set("Content-Type", "application/json") + return r + }(), + wantContains: "", + wantNotContain: "Authorization: *", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := secureRequestDump(tt.req) + if tt.wantContains != "" && !strings.Contains(result, tt.wantContains) { + t.Errorf("maskHeaders() = %q, want contains %q", result, tt.wantContains) + } + if tt.wantNotContain != "" && strings.Contains(result, tt.wantNotContain) { + t.Errorf("maskHeaders() = %q, want NOT contain %q", result, tt.wantNotContain) + } + }) + } +} diff --git a/render/html.go b/render/html.go index c308408d..965d84c6 100644 --- a/render/html.go +++ b/render/html.go @@ -7,6 +7,8 @@ package render import ( "html/template" "net/http" + + "github.com/gin-gonic/gin/internal/fs" ) // Delims represents a set of Left and Right delimiters for HTML template rendering. @@ -31,10 +33,12 @@ type HTMLProduction struct { // HTMLDebug contains template delims and pattern and function with file list. type HTMLDebug struct { - Files []string - Glob string - Delims Delims - FuncMap template.FuncMap + Files []string + Glob string + FileSystem http.FileSystem + Patterns []string + Delims Delims + FuncMap template.FuncMap } // HTML contains template reference and its name with given interface object. @@ -63,6 +67,7 @@ func (r HTMLDebug) Instance(name string, data any) Render { Data: data, } } + func (r HTMLDebug) loadTemplate() *template.Template { if r.FuncMap == nil { r.FuncMap = template.FuncMap{} @@ -73,7 +78,11 @@ func (r HTMLDebug) loadTemplate() *template.Template { if r.Glob != "" { return template.Must(template.New("").Delims(r.Delims.Left, r.Delims.Right).Funcs(r.FuncMap).ParseGlob(r.Glob)) } - panic("the HTML debug render was created without files or glob pattern") + if r.FileSystem != nil && len(r.Patterns) > 0 { + return template.Must(template.New("").Delims(r.Delims.Left, r.Delims.Right).Funcs(r.FuncMap).ParseFS( + fs.FileSystem{FileSystem: r.FileSystem}, r.Patterns...)) + } + panic("the HTML debug render was created without files or glob pattern or file system with patterns") } // Render (HTML) executes template and writes its result with custom ContentType for response. diff --git a/render/json.go b/render/json.go index fc8dea45..2f98676c 100644 --- a/render/json.go +++ b/render/json.go @@ -9,9 +9,10 @@ import ( "fmt" "html/template" "net/http" + "unicode" + "github.com/gin-gonic/gin/codec/json" "github.com/gin-gonic/gin/internal/bytesconv" - "github.com/gin-gonic/gin/internal/json" ) // JSON contains the given interface object. @@ -65,7 +66,7 @@ func (r JSON) WriteContentType(w http.ResponseWriter) { // WriteJSON marshals the given interface object and writes it with custom ContentType. func WriteJSON(w http.ResponseWriter, obj any) error { writeContentType(w, jsonContentType) - jsonBytes, err := json.Marshal(obj) + jsonBytes, err := json.API.Marshal(obj) if err != nil { return err } @@ -76,7 +77,7 @@ func WriteJSON(w http.ResponseWriter, obj any) error { // Render (IndentedJSON) marshals the given interface object and writes it with custom ContentType. func (r IndentedJSON) Render(w http.ResponseWriter) error { r.WriteContentType(w) - jsonBytes, err := json.MarshalIndent(r.Data, "", " ") + jsonBytes, err := json.API.MarshalIndent(r.Data, "", " ") if err != nil { return err } @@ -92,7 +93,7 @@ func (r IndentedJSON) WriteContentType(w http.ResponseWriter) { // Render (SecureJSON) marshals the given interface object and writes it with custom ContentType. func (r SecureJSON) Render(w http.ResponseWriter) error { r.WriteContentType(w) - jsonBytes, err := json.Marshal(r.Data) + jsonBytes, err := json.API.Marshal(r.Data) if err != nil { return err } @@ -115,7 +116,7 @@ func (r SecureJSON) WriteContentType(w http.ResponseWriter) { // Render (JsonpJSON) marshals the given interface object and writes it and its callback with custom ContentType. func (r JsonpJSON) Render(w http.ResponseWriter) (err error) { r.WriteContentType(w) - ret, err := json.Marshal(r.Data) + ret, err := json.API.Marshal(r.Data) if err != nil { return err } @@ -151,20 +152,23 @@ func (r JsonpJSON) WriteContentType(w http.ResponseWriter) { } // Render (AsciiJSON) marshals the given interface object and writes it with custom ContentType. -func (r AsciiJSON) Render(w http.ResponseWriter) (err error) { +func (r AsciiJSON) Render(w http.ResponseWriter) error { r.WriteContentType(w) - ret, err := json.Marshal(r.Data) + ret, err := json.API.Marshal(r.Data) if err != nil { return err } var buffer bytes.Buffer + escapeBuf := make([]byte, 0, 6) // Preallocate 6 bytes for Unicode escape sequences + for _, r := range bytesconv.BytesToString(ret) { - cvt := string(r) - if r >= 128 { - cvt = fmt.Sprintf("\\u%04x", int64(r)) + if r > unicode.MaxASCII { + escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) // Reuse escapeBuf + buffer.Write(escapeBuf) + } else { + buffer.WriteByte(byte(r)) } - buffer.WriteString(cvt) } _, err = w.Write(buffer.Bytes()) @@ -179,7 +183,7 @@ func (r AsciiJSON) WriteContentType(w http.ResponseWriter) { // Render (PureJSON) writes custom ContentType and encodes the given interface object. func (r PureJSON) Render(w http.ResponseWriter) error { r.WriteContentType(w) - encoder := json.NewEncoder(w) + encoder := json.API.NewEncoder(w) encoder.SetEscapeHTML(false) return encoder.Encode(r.Data) } diff --git a/render/reader.go b/render/reader.go index 5752d8d8..ae1a7b5e 100644 --- a/render/reader.go +++ b/render/reader.go @@ -27,7 +27,7 @@ func (r Reader) Render(w http.ResponseWriter) (err error) { } r.Headers["Content-Length"] = strconv.FormatInt(r.ContentLength, 10) } - r.writeHeaders(w, r.Headers) + r.writeHeaders(w) _, err = io.Copy(w, r.Reader) return } @@ -37,10 +37,10 @@ func (r Reader) WriteContentType(w http.ResponseWriter) { writeContentType(w, []string{r.ContentType}) } -// writeHeaders writes custom Header. -func (r Reader) writeHeaders(w http.ResponseWriter, headers map[string]string) { +// writeHeaders writes headers from r.Headers into response. +func (r Reader) writeHeaders(w http.ResponseWriter) { header := w.Header() - for k, v := range headers { + for k, v := range r.Headers { if header.Get(k) == "" { header.Set(k, v) } diff --git a/render/render_test.go b/render/render_test.go index 27a5065b..d9ae2067 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/gin-gonic/gin/internal/json" + "github.com/gin-gonic/gin/codec/json" testdata "github.com/gin-gonic/gin/testdata/protoexample" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +38,7 @@ func TestRenderJSON(t *testing.T) { err := (JSON{data}).Render(w) require.NoError(t, err) - assert.Equal(t, "{\"foo\":\"bar\",\"html\":\"\\u003cb\\u003e\"}", w.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\",\"html\":\"\\u003cb\\u003e\"}", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -60,7 +60,7 @@ func TestRenderIndentedJSON(t *testing.T) { err := (IndentedJSON{data}).Render(w) require.NoError(t, err) - assert.Equal(t, "{\n \"bar\": \"foo\",\n \"foo\": \"bar\"\n}", w.Body.String()) + assert.JSONEq(t, "{\n \"bar\": \"foo\",\n \"foo\": \"bar\"\n}", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -85,7 +85,7 @@ func TestRenderSecureJSON(t *testing.T) { err1 := (SecureJSON{"while(1);", data}).Render(w1) require.NoError(t, err1) - assert.Equal(t, "{\"foo\":\"bar\"}", w1.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\"}", w1.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w1.Header().Get("Content-Type")) w2 := httptest.NewRecorder() @@ -173,7 +173,7 @@ func TestRenderJsonpJSONError(t *testing.T) { err = jsonpJSON.Render(ew) assert.Equal(t, `write "`+`(`+`" error`, err.Error()) - data, _ := json.Marshal(jsonpJSON.Data) // error was returned while writing data + data, _ := json.API.Marshal(jsonpJSON.Data) // error was returned while writing data ew.bufString = string(data) err = jsonpJSON.Render(ew) assert.Equal(t, `write "`+string(data)+`" error`, err.Error()) @@ -194,7 +194,7 @@ func TestRenderJsonpJSONError2(t *testing.T) { e := (JsonpJSON{"", data}).Render(w) require.NoError(t, e) - assert.Equal(t, "{\"foo\":\"bar\"}", w.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\"}", w.Body.String()) assert.Equal(t, "application/javascript; charset=utf-8", w.Header().Get("Content-Type")) } @@ -217,7 +217,7 @@ func TestRenderAsciiJSON(t *testing.T) { err := (AsciiJSON{data1}).Render(w1) require.NoError(t, err) - assert.Equal(t, "{\"lang\":\"GO\\u8bed\\u8a00\",\"tag\":\"\\u003cbr\\u003e\"}", w1.Body.String()) + assert.JSONEq(t, "{\"lang\":\"GO\\u8bed\\u8a00\",\"tag\":\"\\u003cbr\\u003e\"}", w1.Body.String()) assert.Equal(t, "application/json", w1.Header().Get("Content-Type")) w2 := httptest.NewRecorder() @@ -244,7 +244,7 @@ func TestRenderPureJSON(t *testing.T) { } err := (PureJSON{data}).Render(w) require.NoError(t, err) - assert.Equal(t, "{\"foo\":\"bar\",\"html\":\"\"}\n", w.Body.String()) + assert.JSONEq(t, "{\"foo\":\"bar\",\"html\":\"\"}\n", w.Body.String()) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) } @@ -285,7 +285,14 @@ b: err := (YAML{data}).Render(w) require.NoError(t, err) - assert.Equal(t, "|4-\n a : Easy!\n b:\n \tc: 2\n \td: [3, 4]\n \t\n", w.Body.String()) + + // With github.com/goccy/go-yaml, the output format is different from gopkg.in/yaml.v3 + // We're checking that the output contains the expected data, not the exact formatting + output := w.Body.String() + assert.Contains(t, output, "a : Easy!") + assert.Contains(t, output, "b:") + assert.Contains(t, output, "c: 2") + assert.Contains(t, output, "d: [3, 4]") assert.Equal(t, "application/yaml; charset=utf-8", w.Header().Get("Content-Type")) } @@ -369,7 +376,7 @@ func TestRenderXML(t *testing.T) { } func TestRenderRedirect(t *testing.T) { - req, err := http.NewRequest("GET", "/test-redirect", nil) + req, err := http.NewRequest(http.MethodGet, "/test-redirect", nil) require.NoError(t, err) data1 := Redirect{ @@ -489,10 +496,12 @@ func TestRenderHTMLTemplateEmptyName(t *testing.T) { func TestRenderHTMLDebugFiles(t *testing.T) { w := httptest.NewRecorder() htmlRender := HTMLDebug{ - Files: []string{"../testdata/template/hello.tmpl"}, - Glob: "", - Delims: Delims{Left: "{[{", Right: "}]}"}, - FuncMap: nil, + Files: []string{"../testdata/template/hello.tmpl"}, + Glob: "", + FileSystem: nil, + Patterns: nil, + Delims: Delims{Left: "{[{", Right: "}]}"}, + FuncMap: nil, } instance := htmlRender.Instance("hello.tmpl", map[string]any{ "name": "thinkerou", @@ -508,10 +517,33 @@ func TestRenderHTMLDebugFiles(t *testing.T) { func TestRenderHTMLDebugGlob(t *testing.T) { w := httptest.NewRecorder() htmlRender := HTMLDebug{ - Files: nil, - Glob: "../testdata/template/hello*", - Delims: Delims{Left: "{[{", Right: "}]}"}, - FuncMap: nil, + Files: nil, + Glob: "../testdata/template/hello*", + FileSystem: nil, + Patterns: nil, + Delims: Delims{Left: "{[{", Right: "}]}"}, + FuncMap: nil, + } + instance := htmlRender.Instance("hello.tmpl", map[string]any{ + "name": "thinkerou", + }) + + err := instance.Render(w) + + require.NoError(t, err) + assert.Equal(t, "

Hello thinkerou

", w.Body.String()) + assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) +} + +func TestRenderHTMLDebugFS(t *testing.T) { + w := httptest.NewRecorder() + htmlRender := HTMLDebug{ + Files: nil, + Glob: "", + FileSystem: http.Dir("../testdata/template"), + Patterns: []string{"hello.tmpl"}, + Delims: Delims{Left: "{[{", Right: "}]}"}, + FuncMap: nil, } instance := htmlRender.Instance("hello.tmpl", map[string]any{ "name": "thinkerou", @@ -526,10 +558,12 @@ func TestRenderHTMLDebugGlob(t *testing.T) { func TestRenderHTMLDebugPanics(t *testing.T) { htmlRender := HTMLDebug{ - Files: nil, - Glob: "", - Delims: Delims{"{{", "}}"}, - FuncMap: nil, + Files: nil, + Glob: "", + FileSystem: nil, + Patterns: nil, + Delims: Delims{"{{", "}}"}, + FuncMap: nil, } assert.Panics(t, func() { htmlRender.Instance("", nil) }) } @@ -581,7 +615,7 @@ func TestRenderReaderNoContentLength(t *testing.T) { } func TestRenderWriteError(t *testing.T) { - data := []interface{}{"value1", "value2"} + data := []any{"value1", "value2"} prefix := "my-prefix:" r := SecureJSON{Data: data, Prefix: prefix} ew := &errorWriter{ diff --git a/render/toml.go b/render/toml.go index 40f044c8..379ac72d 100644 --- a/render/toml.go +++ b/render/toml.go @@ -15,7 +15,7 @@ type TOML struct { Data any } -var TOMLContentType = []string{"application/toml; charset=utf-8"} +var tomlContentType = []string{"application/toml; charset=utf-8"} // Render (TOML) marshals the given interface object and writes data with custom ContentType. func (r TOML) Render(w http.ResponseWriter) error { @@ -32,5 +32,5 @@ func (r TOML) Render(w http.ResponseWriter) error { // WriteContentType (TOML) writes TOML ContentType for response. func (r TOML) WriteContentType(w http.ResponseWriter) { - writeContentType(w, TOMLContentType) + writeContentType(w, tomlContentType) } diff --git a/render/yaml.go b/render/yaml.go index 042bb821..98b06442 100644 --- a/render/yaml.go +++ b/render/yaml.go @@ -7,7 +7,7 @@ package render import ( "net/http" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) // YAML contains the given interface object. diff --git a/response_writer.go b/response_writer.go index 753a0b09..6907e514 100644 --- a/response_writer.go +++ b/response_writer.go @@ -6,6 +6,7 @@ package gin import ( "bufio" + "errors" "io" "net" "net/http" @@ -16,6 +17,8 @@ const ( defaultStatus = http.StatusOK ) +var errHijackAlreadyWritten = errors.New("gin: response body already written") + // ResponseWriter ... type ResponseWriter interface { http.ResponseWriter @@ -106,6 +109,11 @@ func (w *responseWriter) Written() bool { // Hijack implements the http.Hijacker interface. func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + // Allow hijacking before any data is written (size == -1) or after headers are written (size == 0), + // but not after body data is written (size > 0). For compatibility with websocket libraries (e.g., github.com/coder/websocket) + if w.size > 0 { + return nil, nil, errHijackAlreadyWritten + } if w.size < 0 { w.size = 0 } diff --git a/response_writer_test.go b/response_writer_test.go index 259b8fa8..dfc1d2c6 100644 --- a/response_writer_test.go +++ b/response_writer_test.go @@ -5,6 +5,8 @@ package gin import ( + "bufio" + "net" "net/http" "net/http/httptest" "testing" @@ -124,6 +126,132 @@ func TestResponseWriterHijack(t *testing.T) { w.Flush() } +type mockHijacker struct { + *httptest.ResponseRecorder + hijacked bool +} + +// Hijack implements the http.Hijacker interface. It just records that it was called. +func (m *mockHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { + m.hijacked = true + return nil, nil, nil +} + +func TestResponseWriterHijackAfterWrite(t *testing.T) { + tests := []struct { + name string + action func(w ResponseWriter) error // Action to perform before hijacking + expectWrittenBeforeHijack bool + expectHijackSuccess bool + expectWrittenAfterHijack bool + expectError error + }{ + { + name: "hijack before write should succeed", + action: func(w ResponseWriter) error { return nil }, + expectWrittenBeforeHijack: false, + expectHijackSuccess: true, + expectWrittenAfterHijack: true, // Hijack itself marks the writer as written + expectError: nil, + }, + { + name: "hijack after write should fail", + action: func(w ResponseWriter) error { + _, err := w.Write([]byte("test")) + return err + }, + expectWrittenBeforeHijack: true, + expectHijackSuccess: false, + expectWrittenAfterHijack: true, + expectError: errHijackAlreadyWritten, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hijacker := &mockHijacker{ResponseRecorder: httptest.NewRecorder()} + writer := &responseWriter{} + writer.reset(hijacker) + w := ResponseWriter(writer) + + // Check initial state + assert.False(t, w.Written(), "should not be written initially") + + // Perform pre-hijack action + require.NoError(t, tc.action(w), "unexpected error during pre-hijack action") + + // Check state before hijacking + assert.Equal(t, tc.expectWrittenBeforeHijack, w.Written(), "unexpected w.Written() state before hijack") + + // Attempt to hijack + _, _, hijackErr := w.Hijack() + + // Check results + require.ErrorIs(t, hijackErr, tc.expectError, "unexpected error from Hijack()") + assert.Equal(t, tc.expectHijackSuccess, hijacker.hijacked, "unexpected hijacker.hijacked state") + assert.Equal(t, tc.expectWrittenAfterHijack, w.Written(), "unexpected w.Written() state after hijack") + }) + } +} + +// Test: WebSocket compatibility - allow hijack after WriteHeaderNow(), but block after body data. +func TestResponseWriterHijackAfterWriteHeaderNow(t *testing.T) { + tests := []struct { + name string + action func(w ResponseWriter) error + expectWrittenBeforeHijack bool + expectHijackSuccess bool + expectWrittenAfterHijack bool + expectError error + }{ + { + name: "hijack after WriteHeaderNow only should succeed (websocket pattern)", + action: func(w ResponseWriter) error { + w.WriteHeaderNow() // Simulate websocket.Accept() behavior + return nil + }, + expectWrittenBeforeHijack: true, + expectHijackSuccess: true, // NEW BEHAVIOR: allow hijack after just header write + expectWrittenAfterHijack: true, + expectError: nil, + }, + { + name: "hijack after WriteHeaderNow + Write should fail", + action: func(w ResponseWriter) error { + w.WriteHeaderNow() + _, err := w.Write([]byte("test")) + return err + }, + expectWrittenBeforeHijack: true, + expectHijackSuccess: false, + expectWrittenAfterHijack: true, + expectError: errHijackAlreadyWritten, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hijacker := &mockHijacker{ResponseRecorder: httptest.NewRecorder()} + writer := &responseWriter{} + writer.reset(hijacker) + w := ResponseWriter(writer) + + require.NoError(t, tc.action(w), "unexpected error during pre-hijack action") + + assert.Equal(t, tc.expectWrittenBeforeHijack, w.Written(), "unexpected w.Written() state before hijack") + + _, _, hijackErr := w.Hijack() + + if tc.expectError == nil { + require.NoError(t, hijackErr, "expected hijack to succeed") + } else { + require.ErrorIs(t, hijackErr, tc.expectError, "unexpected error from Hijack()") + } + assert.Equal(t, tc.expectHijackSuccess, hijacker.hijacked, "unexpected hijacker.hijacked state") + assert.Equal(t, tc.expectWrittenAfterHijack, w.Written(), "unexpected w.Written() state after hijack") + }) + } +} + func TestResponseWriterFlush(t *testing.T) { testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writer := &responseWriter{} diff --git a/routergroup_test.go b/routergroup_test.go index 6848063e..182c5589 100644 --- a/routergroup_test.go +++ b/routergroup_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" ) +var MaxHandlers = 32 + func init() { SetMode(TestMode) } @@ -193,3 +195,25 @@ func testRoutesInterface(t *testing.T, r IRoutes) { assert.Equal(t, r, r.Static("/static", ".")) assert.Equal(t, r, r.StaticFS("/static2", Dir(".", false))) } + +func TestRouterGroupCombineHandlersTooManyHandlers(t *testing.T) { + group := &RouterGroup{ + Handlers: make(HandlersChain, MaxHandlers), // Assume group already has MaxHandlers middleware + } + tooManyHandlers := make(HandlersChain, MaxHandlers) // Add MaxHandlers more, total 2 * MaxHandlers + + // This should trigger panic + assert.Panics(t, func() { + group.combineHandlers(tooManyHandlers) + }, "should panic due to too many handlers") +} + +func TestRouterGroupCombineHandlersEmptySliceNotNil(t *testing.T) { + group := &RouterGroup{ + Handlers: HandlersChain{}, + } + + result := group.combineHandlers(HandlersChain{}) + assert.NotNil(t, result, "result should not be nil even with empty handlers") + assert.Empty(t, result, "empty handlers should return empty chain") +} diff --git a/routes_test.go b/routes_test.go index d6233b09..1cae3fce 100644 --- a/routes_test.go +++ b/routes_test.go @@ -484,7 +484,7 @@ func TestRouterMiddlewareAndStatic(t *testing.T) { assert.Contains(t, w.Body.String(), "package gin") // Content-Type='text/plain; charset=utf-8' when go version <= 1.16, // else, Content-Type='text/x-go; charset=utf-8' - assert.NotEqual(t, "", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Header().Get("Content-Type")) assert.NotEqual(t, "Mon, 02 Jan 2006 15:04:05 MST", w.Header().Get("Last-Modified")) assert.Equal(t, "Mon, 02 Jan 2006 15:04:05 MST", w.Header().Get("Expires")) assert.Equal(t, "Gin Framework", w.Header().Get("x-GIN")) @@ -523,8 +523,8 @@ func TestRouteNotAllowedEnabled3(t *testing.T) { w := PerformRequest(router, http.MethodPut, "/path") assert.Equal(t, http.StatusMethodNotAllowed, w.Code) allowed := w.Header().Get("Allow") - assert.Contains(t, allowed, "GET") - assert.Contains(t, allowed, "POST") + assert.Contains(t, allowed, http.MethodGet) + assert.Contains(t, allowed, http.MethodPost) } func TestRouteNotAllowedDisabled(t *testing.T) { @@ -557,10 +557,10 @@ func TestRouterNotFoundWithRemoveExtraSlash(t *testing.T) { {"/nope", http.StatusNotFound, ""}, // NotFound } for _, tr := range testRoutes { - w := PerformRequest(router, "GET", tr.route) + w := PerformRequest(router, http.MethodGet, tr.route) assert.Equal(t, tr.code, w.Code) if w.Code != http.StatusNotFound { - assert.Equal(t, tr.location, fmt.Sprint(w.Header().Get("Location"))) + assert.Equal(t, tr.location, w.Header().Get("Location")) } } } @@ -590,7 +590,7 @@ func TestRouterNotFound(t *testing.T) { w := PerformRequest(router, http.MethodGet, tr.route) assert.Equal(t, tr.code, w.Code) if w.Code != http.StatusNotFound { - assert.Equal(t, tr.location, fmt.Sprint(w.Header().Get("Location"))) + assert.Equal(t, tr.location, w.Header().Get("Location")) } } @@ -764,7 +764,7 @@ func TestRouteContextHoldsFullPath(t *testing.T) { // Test not found router.Use(func(c *Context) { // For not found routes full path is empty - assert.Equal(t, "", c.FullPath()) + assert.Empty(t, c.FullPath()) }) w := PerformRequest(router, http.MethodGet, "/not-found") @@ -786,6 +786,6 @@ func TestEngineHandleMethodNotAllowedCornerCase(t *testing.T) { v1.GET("/orgs/:id", handlerTest1) v1.DELETE("/orgs/:id", handlerTest1) - w := PerformRequest(r, "GET", "/base/v1/user/groups") + w := PerformRequest(r, http.MethodGet, "/base/v1/user/groups") assert.Equal(t, http.StatusNotFound, w.Code) } diff --git a/test_helpers.go b/test_helpers.go index 7508c5c9..a1a7c562 100644 --- a/test_helpers.go +++ b/test_helpers.go @@ -6,7 +6,10 @@ package gin import "net/http" -// CreateTestContext returns a fresh engine and context for testing purposes +// CreateTestContext returns a fresh Engine and a Context associated with it. +// This is useful for tests that need to set up a new Gin engine instance +// along with a context, for example, to test middleware that doesn't depend on +// specific routes. The ResponseWriter `w` is used to initialize the context's writer. func CreateTestContext(w http.ResponseWriter) (c *Context, r *Engine) { r = New() c = r.allocateContext(0) @@ -15,7 +18,11 @@ func CreateTestContext(w http.ResponseWriter) (c *Context, r *Engine) { return } -// CreateTestContextOnly returns a fresh context base on the engine for testing purposes +// CreateTestContextOnly returns a fresh Context associated with the provided Engine `r`. +// This is useful for tests that operate on an existing, possibly pre-configured, +// Gin engine instance and need a new context for it. +// The ResponseWriter `w` is used to initialize the context's writer. +// The context is allocated with the `maxParams` setting from the provided engine. func CreateTestContextOnly(w http.ResponseWriter, r *Engine) (c *Context) { c = r.allocateContext(r.maxParams) c.reset() diff --git a/testdata/test_file.txt b/testdata/test_file.txt new file mode 100644 index 00000000..05fc0842 --- /dev/null +++ b/testdata/test_file.txt @@ -0,0 +1,2 @@ +This is a test file for Context.File() method testing. +It contains some sample content to verify file serving functionality. \ No newline at end of file diff --git a/tree.go b/tree.go index b0a5f982..eff07734 100644 --- a/tree.go +++ b/tree.go @@ -5,7 +5,7 @@ package gin import ( - "bytes" + "math" "net/url" "strings" "unicode" @@ -14,12 +14,6 @@ import ( "github.com/gin-gonic/gin/internal/bytesconv" ) -var ( - strColon = []byte(":") - strStar = []byte("*") - strSlash = []byte("/") -) - // Param is a single URL parameter, consisting of a key and a value. type Param struct { Key string @@ -84,17 +78,22 @@ func (n *node) addChild(child *node) { } } +// safeUint16 converts int to uint16 safely, capping at math.MaxUint16 +func safeUint16(n int) uint16 { + if n > math.MaxUint16 { + return math.MaxUint16 + } + return uint16(n) +} + func countParams(path string) uint16 { - var n uint16 - s := bytesconv.StringToBytes(path) - n += uint16(bytes.Count(s, strColon)) - n += uint16(bytes.Count(s, strStar)) - return n + colons := strings.Count(path, ":") + stars := strings.Count(path, "*") + return safeUint16(colons + stars) } func countSections(path string) uint16 { - s := bytesconv.StringToBytes(path) - return uint16(bytes.Count(s, strSlash)) + return safeUint16(strings.Count(path, "/")) } type nodeType uint8 @@ -234,7 +233,7 @@ walk: // Wildcard conflict pathSeg := path if n.nType != catchAll { - pathSeg = strings.SplitN(pathSeg, "/", 2)[0] + pathSeg, _, _ = strings.Cut(pathSeg, "/") } prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path panic("'" + pathSeg + @@ -358,7 +357,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) if len(n.path) > 0 && n.path[len(n.path)-1] == '/' { pathSeg := "" if len(n.children) != 0 { - pathSeg = strings.SplitN(n.children[0].path, "/", 2)[0] + pathSeg, _, _ = strings.Cut(n.children[0].path, "/") } panic("catch-all wildcard '" + path + "' in new path '" + fullPath + @@ -369,7 +368,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) // currently fixed width 1 for '/' i-- - if path[i] != '/' { + if i < 0 || path[i] != '/' { panic("no / before catch-all in path '" + fullPath + "'") } @@ -383,7 +382,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) } n.addChild(child) - n.indices = string('/') + n.indices = "/" n = child n.priority++ diff --git a/tree_test.go b/tree_test.go index 3aa3a594..b580007d 100644 --- a/tree_test.go +++ b/tree_test.go @@ -481,7 +481,7 @@ func TestTreeDuplicatePath(t *testing.T) { } } - //printChildren(tree, "") + // printChildren(tree, "") checkRequests(t, tree, testRequests{ {"/", false, "/", nil}, @@ -532,7 +532,7 @@ func TestTreeCatchAllConflictRoot(t *testing.T) { func TestTreeCatchMaxParams(t *testing.T) { tree := &node{} - var route = "/cmd/*filepath" + route := "/cmd/*filepath" tree.addRoute(route, fakeHandler(route)) } @@ -692,7 +692,7 @@ func TestTreeRootTrailingSlashRedirect(t *testing.T) { } func TestRedirectTrailingSlash(t *testing.T) { - var data = []struct { + data := []struct { path string }{ {"/hello/:name"}, @@ -993,3 +993,28 @@ func TestTreeInvalidEscape(t *testing.T) { } } } + +func TestWildcardInvalidSlash(t *testing.T) { + const panicMsgPrefix = "no / before catch-all in path" + + routes := map[string]bool{ + "/foo/bar": true, + "/foo/x*zy": false, + "/foo/b*r": false, + } + + for route, valid := range routes { + tree := &node{} + recv := catchPanic(func() { + tree.addRoute(route, nil) + }) + + if recv == nil != valid { + t.Fatalf("%s should be %t but got %v", route, valid, recv) + } + + if rs, ok := recv.(string); recv != nil && (!ok || !strings.HasPrefix(rs, panicMsgPrefix)) { + t.Fatalf(`"Expected panic "%s" for route '%s', got "%v"`, panicMsgPrefix, route, recv) + } + } +} diff --git a/utils_test.go b/utils_test.go index af089963..8bcf00e4 100644 --- a/utils_test.go +++ b/utils_test.go @@ -19,7 +19,7 @@ func init() { } func BenchmarkParseAccept(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { parseAccept("text/html , application/xhtml+xml,application/xml;q=0.9, */* ;q=0.8") } } @@ -29,7 +29,7 @@ type testStruct struct { } func (t *testStruct) ServeHTTP(w http.ResponseWriter, req *http.Request) { - assert.Equal(t.T, "POST", req.Method) + assert.Equal(t.T, http.MethodPost, req.Method) assert.Equal(t.T, "/path", req.URL.Path) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "hello") @@ -39,17 +39,17 @@ func TestWrap(t *testing.T) { router := New() router.POST("/path", WrapH(&testStruct{t})) router.GET("/path2", WrapF(func(w http.ResponseWriter, req *http.Request) { - assert.Equal(t, "GET", req.Method) + assert.Equal(t, http.MethodGet, req.Method) assert.Equal(t, "/path2", req.URL.Path) w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, "hola!") })) - w := PerformRequest(router, "POST", "/path") + w := PerformRequest(router, http.MethodPost, "/path") assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, "hello", w.Body.String()) - w = PerformRequest(router, "GET", "/path2") + w = PerformRequest(router, http.MethodGet, "/path2") assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "hola!", w.Body.String()) } @@ -94,7 +94,7 @@ func somefunction() { } func TestJoinPaths(t *testing.T) { - assert.Equal(t, "", joinPaths("", "")) + assert.Empty(t, joinPaths("", "")) assert.Equal(t, "/", joinPaths("", "/")) assert.Equal(t, "/a", joinPaths("/a", "")) assert.Equal(t, "/a/", joinPaths("/a/", "")) @@ -119,13 +119,13 @@ func TestBindMiddleware(t *testing.T) { called = true value = c.MustGet(BindKey).(*bindTestStruct) }) - PerformRequest(router, "GET", "/?foo=hola&bar=10") + PerformRequest(router, http.MethodGet, "/?foo=hola&bar=10") assert.True(t, called) assert.Equal(t, "hola", value.Foo) assert.Equal(t, 10, value.Bar) called = false - PerformRequest(router, "GET", "/?foo=hola&bar=1") + PerformRequest(router, http.MethodGet, "/?foo=hola&bar=1") assert.False(t, called) assert.Panics(t, func() { diff --git a/version.go b/version.go index 93ad9654..8049058c 100644 --- a/version.go +++ b/version.go @@ -5,4 +5,4 @@ package gin // Version is the current gin framework's version. -const Version = "v1.10.0" +const Version = "v1.11.0"