From 7db2a87c09272311a10497413418960e3856b8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Sat, 18 Apr 2026 18:59:07 -0300 Subject: [PATCH 1/2] fix review followups and release tagging --- .github/workflows/release.yml | 71 ++++++++++++++++++++++++- README.md | 20 +++++-- examples/chi/go.mod | 10 ++-- examples/chi/go.sum | 16 +++--- examples/restapi/go.mod | 10 ++-- examples/restapi/go.sum | 16 +++--- examples/restapi/main.go | 38 ++++++++++--- examples/restapi/main_test.go | 31 +++++++++++ examples/stdmux/main.go | 3 ++ go.work | 2 +- go.work.sum | 21 +++----- problem_config.go | 2 +- problem_details.go | 29 +++++++++- problem_details_test.go | 37 ++++++++++++- request_decode.go | 22 ++++++-- request_decode_test.go | 24 +++++++++ request_internal.go | 3 +- request_test.go | 4 +- response_builder.go | 10 +++- response_builder_test.go | 6 +-- response_meta.go | 6 +-- response_meta_test.go | 42 +++++++++++++++ response_test.go | 44 +++++++++++++++ response_write.go | 32 ++++++++++- validation/playground/go.mod | 10 ++-- validation/playground/go.sum | 21 +++++--- validation/playground/validator.go | 17 ++++++ validation/playground/validator_test.go | 11 +++- 28 files changed, 468 insertions(+), 90 deletions(-) create mode 100644 examples/restapi/main_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8ee2ef..c1d92c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,16 @@ on: tags: - "v*" workflow_dispatch: + inputs: + bump: + description: "Semantic version bump" + required: true + default: patch + type: choice + options: + - patch + - minor + - major concurrency: group: release-${{ github.ref }} @@ -57,12 +67,71 @@ jobs: GOWORK: off run: go test ./... + tag-release: + name: Create release tag + runs-on: ubuntu-latest + needs: verify + if: github.event_name == 'workflow_dispatch' + outputs: + tag_name: ${{ steps.tag.outputs.tag_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + id: tag + env: + BUMP: ${{ inputs.bump }} + run: | + latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n 1)" + if [ -z "$latest_tag" ]; then + latest_tag="v0.0.0" + fi + + version="${latest_tag#v}" + IFS='.' read -r major minor patch <<< "$version" + + case "$BUMP" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + echo "unsupported bump: $BUMP" >&2 + exit 1 + ;; + esac + + tag_name="v${major}.${minor}.${patch}" + git tag "$tag_name" + git push origin "$tag_name" + echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT" + github-release: name: Publish GitHub release runs-on: ubuntu-latest - needs: verify + needs: + - verify + - tag-release + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' steps: - name: Create GitHub release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.tag-release.outputs.tag_name || github.ref_name }} generate_release_notes: true diff --git a/README.md b/README.md index fa45c11..e8d675b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## Features - Parse JSON request bodies with a default `1 MiB` limit +- Return `413 Payload Too Large` when the configured body limit is exceeded - Bind path params explicitly through a router-specific extractor - Validate automatically during `ParseRequest` when a global validator is configured - Keep `ParseRequest` panic-safe for invalid inputs and return regular errors instead @@ -205,12 +206,12 @@ flowchart TD ## Examples -Examples live in [`examples/`](/home/rluders/Projects/rluders/httpsuite/examples). +Examples live in [`examples/`](examples/). -- [`examples/stdmux`](/home/rluders/Projects/rluders/httpsuite/examples/stdmux/main.go): core-only with `http.ServeMux` -- [`examples/gorillamux`](/home/rluders/Projects/rluders/httpsuite/examples/gorillamux/main.go): path params with Gorilla Mux -- [`examples/chi`](/home/rluders/Projects/rluders/httpsuite/examples/chi/main.go): global validation with Chi -- [`examples/restapi`](/home/rluders/Projects/rluders/httpsuite/examples/restapi/main.go): fuller REST API example with pagination-style metadata and custom problems +- [`examples/stdmux`](examples/stdmux/main.go): core-only with `http.ServeMux` +- [`examples/gorillamux`](examples/gorillamux/main.go): path params with Gorilla Mux +- [`examples/chi`](examples/chi/main.go): global validation with Chi +- [`examples/restapi`](examples/restapi/main.go): fuller REST API example with pagination-style metadata and custom problems `examples/restapi` shows: @@ -236,6 +237,15 @@ Examples live in [`examples/`](/home/rluders/Projects/rluders/httpsuite/examples - global validator support added via `SetValidator` and `RegisterDefault` - response metadata is generic, with optional `PageMeta` and `CursorMeta` +## Release workflow + +The release workflow supports two paths: + +- push an existing `v*` tag to verify and publish that release +- run `Release` with `workflow_dispatch` and choose `major`, `minor`, or `patch` + +On manual dispatch, the workflow finds the latest `v*` tag, bumps it according to the selected semantic version part, pushes the new tag, and publishes the GitHub release for that tag. + ## Tutorial - [Improving Request Validation and Response Handling in Go Microservices](https://medium.com/@rluders/improving-request-validation-and-response-handling-in-go-microservices-cc54208123f2) diff --git a/examples/chi/go.mod b/examples/chi/go.mod index 718a54d..0222d0d 100644 --- a/examples/chi/go.mod +++ b/examples/chi/go.mod @@ -1,6 +1,6 @@ module chi_example -go 1.23 +go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.0 @@ -14,10 +14,10 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.24.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/examples/chi/go.sum b/examples/chi/go.sum index 2e1428f..711b6c4 100644 --- a/examples/chi/go.sum +++ b/examples/chi/go.sum @@ -18,13 +18,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/restapi/go.mod b/examples/restapi/go.mod index 358e0c8..3f79ffa 100644 --- a/examples/restapi/go.mod +++ b/examples/restapi/go.mod @@ -1,6 +1,6 @@ module restapi_example -go 1.23 +go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.0 @@ -14,10 +14,10 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.24.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/examples/restapi/go.sum b/examples/restapi/go.sum index 2e1428f..711b6c4 100644 --- a/examples/restapi/go.sum +++ b/examples/restapi/go.sum @@ -18,13 +18,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/restapi/main.go b/examples/restapi/main.go index 84fbd08..1061753 100644 --- a/examples/restapi/main.go +++ b/examples/restapi/main.go @@ -128,14 +128,7 @@ func main() { pageSize := readPositiveInt(r, "page_size", 2) users := store.List() - start := (page - 1) * pageSize - if start > len(users) { - start = len(users) - } - end := start + pageSize - if end > len(users) { - end = len(users) - } + start, end := clampPageWindow(page, pageSize, len(users)) httpsuite.Reply(). Meta(httpsuite.NewPageMeta(page, pageSize, len(users))). @@ -209,6 +202,35 @@ func readPositiveInt(r *http.Request, key string, fallback int) int { return value } +func clampPageWindow(page, pageSize, total int) (int, int) { + if total <= 0 { + return 0, 0 + } + if page <= 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = total + } + + totalPages := 1 + (total-1)/pageSize + if page > totalPages { + return total, total + } + + start := (page - 1) * pageSize + if start > total { + start = total + } + + end := total + if remaining := total - start; pageSize < remaining { + end = start + pageSize + } + + return start, end +} + func nilParamExtractor(*http.Request, string) string { return "" } diff --git a/examples/restapi/main_test.go b/examples/restapi/main_test.go new file mode 100644 index 0000000..c5c196d --- /dev/null +++ b/examples/restapi/main_test.go @@ -0,0 +1,31 @@ +package main + +import "testing" + +func TestClampPageWindow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + page int + pageSize int + total int + wantFrom int + wantTo int + }{ + {name: "first page", page: 1, pageSize: 2, total: 5, wantFrom: 0, wantTo: 2}, + {name: "after last page", page: 10, pageSize: 2, total: 5, wantFrom: 5, wantTo: 5}, + {name: "very large page", page: int(^uint(0) >> 1), pageSize: 2, total: 5, wantFrom: 5, wantTo: 5}, + {name: "very large page size", page: 1, pageSize: int(^uint(0) >> 1), total: 5, wantFrom: 0, wantTo: 5}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + from, to := clampPageWindow(tt.page, tt.pageSize, tt.total) + if from != tt.wantFrom || to != tt.wantTo { + t.Fatalf("expected (%d,%d), got (%d,%d)", tt.wantFrom, tt.wantTo, from, to) + } + }) + } +} diff --git a/examples/stdmux/main.go b/examples/stdmux/main.go index acd5fb7..edd17ed 100644 --- a/examples/stdmux/main.go +++ b/examples/stdmux/main.go @@ -32,6 +32,9 @@ func (r *SampleRequest) SetParam(fieldName, value string) error { } func StdMuxParamExtractor(r *http.Request, key string) string { + if key != "id" { + return "" + } // Remove "/submit/" (7 characters) from the URL path to get just the "id" // Example: /submit/123 -> 123 return r.URL.Path[len("/submit/"):] // Skip the "/submit/" part diff --git a/go.work b/go.work index 7012834..21573d5 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.23 +go 1.25.0 use ( . diff --git a/go.work.sum b/go.work.sum index 23f3403..1be0826 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,17 +1,12 @@ -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/problem_config.go b/problem_config.go index a1787fd..da92f53 100644 --- a/problem_config.go +++ b/problem_config.go @@ -39,7 +39,7 @@ func mergeProblemConfig(config *ProblemConfig) ProblemConfig { for key, value := range config.ErrorTypePaths { merged.ErrorTypePaths[key] = value } - return merged.Clone() + return merged } // Clone returns a deep copy of the config. diff --git a/problem_details.go b/problem_details.go index 0b21364..a744de3 100644 --- a/problem_details.go +++ b/problem_details.go @@ -1,6 +1,9 @@ package httpsuite -import "net/http" +import ( + "encoding/json" + "net/http" +) const BlankURL = "about:blank" @@ -20,6 +23,30 @@ type ValidationErrorDetail struct { Message string `json:"message"` } +// MarshalJSON serializes RFC 9457 extension members at the top level. +func (p ProblemDetails) MarshalJSON() ([]byte, error) { + payload := map[string]any{ + "type": p.Type, + "title": p.Title, + "status": p.Status, + } + if p.Detail != "" { + payload["detail"] = p.Detail + } + if p.Instance != "" { + payload["instance"] = p.Instance + } + for key, value := range p.Extensions { + switch key { + case "", "type", "title", "status", "detail", "instance", "extensions": + continue + default: + payload[key] = value + } + } + return json.Marshal(payload) +} + // NewProblemDetails creates a ProblemDetails instance with standard fields. func NewProblemDetails(status int, problemType, title, detail string) *ProblemDetails { if status < 100 || status > 599 { diff --git a/problem_details_test.go b/problem_details_test.go index 7d40462..ac86921 100644 --- a/problem_details_test.go +++ b/problem_details_test.go @@ -1,6 +1,9 @@ package httpsuite -import "testing" +import ( + "encoding/json" + "testing" +) func TestNewProblemDetailsDefaults(t *testing.T) { t.Parallel() @@ -16,3 +19,35 @@ func TestNewProblemDetailsDefaults(t *testing.T) { t.Fatalf("expected fallback title, got %q", details.Title) } } + +func TestProblemDetailsMarshalJSONFlattensExtensions(t *testing.T) { + t.Parallel() + + problem := &ProblemDetails{ + Type: BlankURL, + Title: "Bad Request", + Status: 400, + Detail: "broken", + Extensions: map[string]interface{}{ + "trace_id": "trace-123", + "extensions": "ignored", + }, + } + + body, err := json.Marshal(problem) + if err != nil { + t.Fatalf("marshal problem details: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal problem details: %v", err) + } + + if _, exists := payload["extensions"]; exists { + t.Fatalf("expected flattened extensions, got nested payload %q", string(body)) + } + if payload["trace_id"] != "trace-123" { + t.Fatalf("expected trace_id extension, got %#v", payload["trace_id"]) + } +} diff --git a/request_decode.go b/request_decode.go index b41d337..856986e 100644 --- a/request_decode.go +++ b/request_decode.go @@ -80,17 +80,29 @@ func DecodeRequestBody[T any](r *http.Request, maxBodyBytes int64) (T, error) { } var trailing json.RawMessage - if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - err = errors.New("request body must contain a single JSON document") + if err := decoder.Decode(&trailing); err != nil { + if errors.Is(err, io.EOF) { + return request, nil } + + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return request, &BodyDecodeError{ + Kind: BodyDecodeErrorBodyTooLarge, + Err: err, + Limit: maxBytesErr.Limit, + } + } + return request, &BodyDecodeError{ Kind: BodyDecodeErrorMultipleDocuments, Err: err, } } - - return request, nil + return request, &BodyDecodeError{ + Kind: BodyDecodeErrorMultipleDocuments, + Err: errors.New("request body must contain a single JSON document"), + } } type nilResponseWriter struct{} diff --git a/request_decode_test.go b/request_decode_test.go index b234599..de74c5c 100644 --- a/request_decode_test.go +++ b/request_decode_test.go @@ -62,6 +62,30 @@ func TestDecodeRequestBody(t *testing.T) { t.Fatalf("expected body too large error, got %s", decodeErr.Kind) } }) + + t.Run("multiple json documents", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(`{"id":1}{"id":2}`)) + _, err := DecodeRequestBody[*testRequest](req, defaultMaxBodyBytes) + var decodeErr *BodyDecodeError + if !errors.As(err, &decodeErr) { + t.Fatalf("expected BodyDecodeError, got %v", err) + } + if decodeErr.Kind != BodyDecodeErrorMultipleDocuments { + t.Fatalf("expected multiple documents error, got %s", decodeErr.Kind) + } + }) + + t.Run("trailing decode body too large", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(`{} {"name":"trailing"}`)) + _, err := DecodeRequestBody[*testRequest](req, 3) + var decodeErr *BodyDecodeError + if !errors.As(err, &decodeErr) { + t.Fatalf("expected BodyDecodeError, got %v", err) + } + if decodeErr.Kind != BodyDecodeErrorBodyTooLarge { + t.Fatalf("expected body too large error, got %s", decodeErr.Kind) + } + }) } func BenchmarkParseRequestBody(b *testing.B) { diff --git a/request_internal.go b/request_internal.go index 59fe4c3..4296b45 100644 --- a/request_internal.go +++ b/request_internal.go @@ -46,10 +46,11 @@ func problemFromDecodeError(err error, problems *ProblemConfig) (*ProblemDetails if errors.As(err, &decodeErr) { switch decodeErr.Kind { case BodyDecodeErrorBodyTooLarge: + status = http.StatusRequestEntityTooLarge return NewProblemDetails( status, problems.TypeURL("bad_request_error"), - "Request Body Too Large", + "Payload Too Large", decodeErr.Error(), ), status case BodyDecodeErrorMultipleDocuments: diff --git a/request_test.go b/request_test.go index dd62389..28a02f1 100644 --- a/request_test.go +++ b/request_test.go @@ -87,8 +87,8 @@ func TestParseRequest(t *testing.T) { pathParams: []string{"id"}, opts: &ParseOptions{MaxBodyBytes: 8}, wantErr: true, - wantStatus: http.StatusBadRequest, - wantTitle: "Request Body Too Large", + wantStatus: http.StatusRequestEntityTooLarge, + wantTitle: "Payload Too Large", wantDetailContains: "exceeds the limit", }, { diff --git a/response_builder.go b/response_builder.go index 005d17e..bf69a7a 100644 --- a/response_builder.go +++ b/response_builder.go @@ -54,9 +54,12 @@ func (b *ReplyBuilder) Header(key, value string) *ReplyBuilder { // Headers merges multiple response headers for a fluent helper chain. func (b *ReplyBuilder) Headers(headers http.Header) *ReplyBuilder { + if b.headers == nil { + b.headers = make(http.Header) + } for key, values := range headers { for _, value := range values { - b.Header(key, value) + b.headers.Add(key, value) } } return b @@ -104,9 +107,12 @@ func (b *ResponseBuilder[T]) Header(key, value string) *ResponseBuilder[T] { // Headers merges multiple response headers. func (b *ResponseBuilder[T]) Headers(headers http.Header) *ResponseBuilder[T] { + if b.headers == nil { + b.headers = make(http.Header) + } for key, values := range headers { for _, value := range values { - b.Header(key, value) + b.headers.Add(key, value) } } return b diff --git a/response_builder_test.go b/response_builder_test.go index 39af22e..1616a11 100644 --- a/response_builder_test.go +++ b/response_builder_test.go @@ -76,7 +76,7 @@ func TestReplyBuilderHelpers(t *testing.T) { t.Run("headers then created", func(t *testing.T) { w := httptest.NewRecorder() Reply(). - Headers(http.Header{"X-Trace-ID": []string{"trace-123"}}). + Headers(http.Header{"X-Trace-ID": []string{"trace-123", "trace-456"}}). Created(w, testResponse{Key: "value"}, "/users/1") if w.Code != http.StatusCreated { @@ -85,8 +85,8 @@ func TestReplyBuilderHelpers(t *testing.T) { if got := w.Header().Get("Location"); got != "/users/1" { t.Fatalf("expected location header, got %q", got) } - if got := w.Header().Get("X-Trace-ID"); got != "trace-123" { - t.Fatalf("expected trace header, got %q", got) + if got := w.Header().Values("X-Trace-ID"); len(got) != 2 || got[0] != "trace-123" || got[1] != "trace-456" { + t.Fatalf("expected repeated trace headers, got %#v", got) } }) } diff --git a/response_meta.go b/response_meta.go index 5f8f330..adf8c6d 100644 --- a/response_meta.go +++ b/response_meta.go @@ -2,7 +2,7 @@ package httpsuite // Response represents the structure of an HTTP response, including an optional body and metadata. type Response[T any] struct { - Data T `json:"data,omitempty"` + Data T `json:"data"` Meta any `json:"meta,omitempty"` } @@ -21,8 +21,8 @@ type Meta = PageMeta type CursorMeta struct { NextCursor string `json:"next_cursor,omitempty"` PrevCursor string `json:"prev_cursor,omitempty"` - HasNext bool `json:"has_next,omitempty"` - HasPrev bool `json:"has_prev,omitempty"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` } // NewPageMeta builds page-based metadata and derives total pages when possible. diff --git a/response_meta_test.go b/response_meta_test.go index 1fcedbd..cb6d3da 100644 --- a/response_meta_test.go +++ b/response_meta_test.go @@ -2,6 +2,7 @@ package httpsuite import ( "encoding/json" + "strings" "testing" ) @@ -32,3 +33,44 @@ func TestMetaSerialization(t *testing.T) { t.Fatalf("expected valid cursor response json") } } + +func TestResponseSerializationPreservesMeaningfulZeroValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response any + wantContains []string + }{ + { + name: "zero data value", + response: Response[int]{Data: 0}, + wantContains: []string{`"data":0`}, + }, + { + name: "false bool data value", + response: Response[bool]{Data: false}, + wantContains: []string{`"data":false`}, + }, + { + name: "cursor false flags", + response: Response[[]string]{Data: []string{"a"}, Meta: NewCursorMeta("", "", false, false)}, + wantContains: []string{`"has_next":false`, `"has_prev":false`}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + body, err := json.Marshal(tt.response) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + for _, want := range tt.wantContains { + if !strings.Contains(string(body), want) { + t.Fatalf("expected %q in %q", want, string(body)) + } + } + }) + } +} diff --git a/response_test.go b/response_test.go index 4eee12b..a127a9a 100644 --- a/response_test.go +++ b/response_test.go @@ -92,3 +92,47 @@ func TestSendResponse(t *testing.T) { }) } } + +func TestSendResponseDoesNotLeakEncodeErrors(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + SendResponse[any](w, http.StatusOK, map[string]any{"bad": func() {}}, nil, nil) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + + var problem ProblemDetails + if err := json.NewDecoder(w.Body).Decode(&problem); err != nil { + t.Fatalf("decode problem details: %v", err) + } + if problem.Detail != "The server could not serialize the response." { + t.Fatalf("unexpected detail %q", problem.Detail) + } +} + +func TestProblemResponseFallsBackWhenProblemEncodingFails(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + problem := NewBadRequestProblem("invalid") + problem.Extensions = map[string]interface{}{"bad": func() {}} + + ProblemResponse(w, problem) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + + var got ProblemDetails + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode fallback problem details: %v", err) + } + if got.Title != "Internal Server Error" { + t.Fatalf("expected fallback title, got %q", got.Title) + } + if got.Detail != "An internal server error occurred." { + t.Fatalf("expected fallback detail, got %q", got.Detail) + } +} diff --git a/response_write.go b/response_write.go index dc74a0b..9df71fd 100644 --- a/response_write.go +++ b/response_write.go @@ -26,7 +26,7 @@ func writeResponse[T any](w http.ResponseWriter, code int, data T, problem *Prob http.StatusInternalServerError, GetProblemTypeURL("server_error"), "Internal Server Error", - err.Error(), + "The server could not serialize the response.", ) writeProblemDetail(w, http.StatusInternalServerError, internalError, headers) return @@ -41,6 +41,15 @@ func writeResponse[T any](w http.ResponseWriter, code int, data T, problem *Prob } func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails, headers http.Header) { + if problem == nil { + problem = NewProblemDetails( + http.StatusInternalServerError, + GetProblemTypeURL("server_error"), + "Internal Server Error", + "An internal server error occurred.", + ) + } + effectiveStatus := code if effectiveStatus < 400 || effectiveStatus > 599 { effectiveStatus = problem.Status @@ -52,10 +61,29 @@ func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails normalized := *problem normalized.Status = effectiveStatus + var buffer bytes.Buffer + if err := json.NewEncoder(&buffer).Encode(normalized); err != nil { + log.Printf("Failed to encode problem details: %v", err) + + fallback := NewProblemDetails( + http.StatusInternalServerError, + GetProblemTypeURL("server_error"), + "Internal Server Error", + "An internal server error occurred.", + ) + buffer.Reset() + if fallbackErr := json.NewEncoder(&buffer).Encode(fallback); fallbackErr != nil { + log.Printf("Failed to encode fallback problem details: %v", fallbackErr) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + effectiveStatus = http.StatusInternalServerError + } + applyHeaders(w, headers) w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") w.WriteHeader(effectiveStatus) - if err := json.NewEncoder(w).Encode(normalized); err != nil { + if _, err := w.Write(buffer.Bytes()); err != nil { log.Printf("Failed to encode problem details: %v", err) } } diff --git a/validation/playground/go.mod b/validation/playground/go.mod index 648688a..31b2619 100644 --- a/validation/playground/go.mod +++ b/validation/playground/go.mod @@ -1,6 +1,6 @@ module github.com/rluders/httpsuite/validation/playground -go 1.23 +go 1.25.0 require ( github.com/go-playground/validator/v10 v10.24.0 @@ -12,10 +12,10 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/validation/playground/go.sum b/validation/playground/go.sum index 7b54a5d..1834c4e 100644 --- a/validation/playground/go.sum +++ b/validation/playground/go.sum @@ -1,7 +1,9 @@ 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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 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= @@ -11,13 +13,16 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/validation/playground/validator.go b/validation/playground/validator.go index 7480f49..485904d 100644 --- a/validation/playground/validator.go +++ b/validation/playground/validator.go @@ -3,6 +3,8 @@ package playground import ( "errors" "net/http" + "reflect" + "strings" playgroundvalidator "github.com/go-playground/validator/v10" "github.com/rluders/httpsuite/v3" @@ -31,6 +33,7 @@ func NewWithValidator(validate *playgroundvalidator.Validate, problems *httpsuit if validate == nil { validate = playgroundvalidator.New() } + registerJSONTagNames(validate) return &Validator{ validate: validate, @@ -38,6 +41,20 @@ func NewWithValidator(validate *playgroundvalidator.Validate, problems *httpsuit } } +func registerJSONTagNames(validate *playgroundvalidator.Validate) { + validate.RegisterTagNameFunc(func(field reflect.StructField) string { + name := field.Tag.Get("json") + if name == "" { + return field.Name + } + name = strings.Split(name, ",")[0] + if name == "-" || name == "" { + return field.Name + } + return name + }) +} + // Validate validates the request and converts errors into ProblemDetails. func (v *Validator) Validate(request any) *httpsuite.ProblemDetails { if err := v.validate.Struct(request); err != nil { diff --git a/validation/playground/validator_test.go b/validation/playground/validator_test.go index 973637b..0d802e6 100644 --- a/validation/playground/validator_test.go +++ b/validation/playground/validator_test.go @@ -7,8 +7,8 @@ import ( ) type request struct { - Name string `validate:"required"` - Age int `validate:"required,min=18"` + Name string `json:"name" validate:"required"` + Age int `json:"age" validate:"required,min=18"` } func TestValidate(t *testing.T) { @@ -22,6 +22,13 @@ func TestValidate(t *testing.T) { if problem.Title != "Validation Error" { t.Fatalf("expected title %q, got %q", "Validation Error", problem.Title) } + errorsValue, ok := problem.Extensions["errors"].([]httpsuite.ValidationErrorDetail) + if !ok { + t.Fatalf("expected validation error details, got %#v", problem.Extensions["errors"]) + } + if len(errorsValue) == 0 || errorsValue[0].Field != "name" { + t.Fatalf("expected json field name in validation error, got %#v", errorsValue) + } } func TestRegisterDefault(t *testing.T) { From d1f0b18614951062d8811fadc45dce7f3dd18e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Sat, 18 Apr 2026 19:08:18 -0300 Subject: [PATCH 2/2] harden release workflow followups --- .github/workflows/release.yml | 22 ++++++++++++++++++++-- examples/restapi/main_test.go | 1 - request_decode.go | 5 +---- response_write.go | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1d92c9..9a35cc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,13 +90,19 @@ jobs: env: BUMP: ${{ inputs.bump }} run: | - latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n 1)" + set -euo pipefail + + latest_tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1)" if [ -z "$latest_tag" ]; then latest_tag="v0.0.0" fi version="${latest_tag#v}" IFS='.' read -r major minor patch <<< "$version" + if ! [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ && "$patch" =~ ^[0-9]+$ ]]; then + echo "could not parse semver from '$latest_tag'" >&2 + exit 1 + fi case "$BUMP" in major) @@ -118,6 +124,14 @@ jobs: esac tag_name="v${major}.${minor}.${patch}" + if git rev-parse -q --verify "refs/tags/$tag_name" >/dev/null; then + echo "tag $tag_name already exists locally" >&2 + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1; then + echo "tag $tag_name already exists on origin" >&2 + exit 1 + fi git tag "$tag_name" git push origin "$tag_name" echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT" @@ -128,7 +142,11 @@ jobs: needs: - verify - tag-release - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: | + !failure() && !cancelled() + && needs.verify.result == 'success' + && (needs.tag-release.result == 'success' || needs.tag-release.result == 'skipped') + && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') steps: - name: Create GitHub release uses: softprops/action-gh-release@v2 diff --git a/examples/restapi/main_test.go b/examples/restapi/main_test.go index c5c196d..57a440d 100644 --- a/examples/restapi/main_test.go +++ b/examples/restapi/main_test.go @@ -20,7 +20,6 @@ func TestClampPageWindow(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { from, to := clampPageWindow(tt.page, tt.pageSize, tt.total) if from != tt.wantFrom || to != tt.wantTo { diff --git a/request_decode.go b/request_decode.go index 856986e..48d684d 100644 --- a/request_decode.go +++ b/request_decode.go @@ -99,10 +99,7 @@ func DecodeRequestBody[T any](r *http.Request, maxBodyBytes int64) (T, error) { Err: err, } } - return request, &BodyDecodeError{ - Kind: BodyDecodeErrorMultipleDocuments, - Err: errors.New("request body must contain a single JSON document"), - } + return request, &BodyDecodeError{Kind: BodyDecodeErrorMultipleDocuments} } type nilResponseWriter struct{} diff --git a/response_write.go b/response_write.go index 9df71fd..ee3b34f 100644 --- a/response_write.go +++ b/response_write.go @@ -84,7 +84,7 @@ func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") w.WriteHeader(effectiveStatus) if _, err := w.Write(buffer.Bytes()); err != nil { - log.Printf("Failed to encode problem details: %v", err) + log.Printf("Failed to write problem details response body: %v", err) } }