Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -57,12 +67,89 @@ 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: |
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)
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}"
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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

github-release:
name: Publish GitHub release
runs-on: ubuntu-latest
needs: verify
needs:
- verify
- tag-release
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
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.tag-release.outputs.tag_name || github.ref_name }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
generate_release_notes: true
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions examples/chi/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module chi_example

go 1.23
go 1.25.0

require (
github.com/go-chi/chi/v5 v5.2.0
Expand All @@ -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 => ../..
Expand Down
16 changes: 8 additions & 8 deletions examples/chi/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
10 changes: 5 additions & 5 deletions examples/restapi/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module restapi_example

go 1.23
go 1.25.0

require (
github.com/go-chi/chi/v5 v5.2.0
Expand All @@ -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 => ../..
Expand Down
16 changes: 8 additions & 8 deletions examples/restapi/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
38 changes: 30 additions & 8 deletions examples/restapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))).
Expand Down Expand Up @@ -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 ""
}
30 changes: 30 additions & 0 deletions examples/restapi/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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 {
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)
}
})
}
}
3 changes: 3 additions & 0 deletions examples/stdmux/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.work
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
go 1.23
go 1.25.0

use (
.
Expand Down
21 changes: 8 additions & 13 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -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=
2 changes: 1 addition & 1 deletion problem_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading