Skip to content

Commit e4422b5

Browse files
authored
Fix review follow-ups and add semantic release tagging (#13)
* fix review followups and release tagging * harden release workflow followups
1 parent 916b770 commit e4422b5

28 files changed

Lines changed: 483 additions & 91 deletions

.github/workflows/release.yml

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ on:
55
tags:
66
- "v*"
77
workflow_dispatch:
8+
inputs:
9+
bump:
10+
description: "Semantic version bump"
11+
required: true
12+
default: patch
13+
type: choice
14+
options:
15+
- patch
16+
- minor
17+
- major
818

919
concurrency:
1020
group: release-${{ github.ref }}
@@ -57,12 +67,89 @@ jobs:
5767
GOWORK: off
5868
run: go test ./...
5969

70+
tag-release:
71+
name: Create release tag
72+
runs-on: ubuntu-latest
73+
needs: verify
74+
if: github.event_name == 'workflow_dispatch'
75+
outputs:
76+
tag_name: ${{ steps.tag.outputs.tag_name }}
77+
steps:
78+
- name: Checkout
79+
uses: actions/checkout@v4
80+
with:
81+
fetch-depth: 0
82+
83+
- name: Configure git
84+
run: |
85+
git config user.name "github-actions[bot]"
86+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
87+
88+
- name: Create and push tag
89+
id: tag
90+
env:
91+
BUMP: ${{ inputs.bump }}
92+
run: |
93+
set -euo pipefail
94+
95+
latest_tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1)"
96+
if [ -z "$latest_tag" ]; then
97+
latest_tag="v0.0.0"
98+
fi
99+
100+
version="${latest_tag#v}"
101+
IFS='.' read -r major minor patch <<< "$version"
102+
if ! [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ && "$patch" =~ ^[0-9]+$ ]]; then
103+
echo "could not parse semver from '$latest_tag'" >&2
104+
exit 1
105+
fi
106+
107+
case "$BUMP" in
108+
major)
109+
major=$((major + 1))
110+
minor=0
111+
patch=0
112+
;;
113+
minor)
114+
minor=$((minor + 1))
115+
patch=0
116+
;;
117+
patch)
118+
patch=$((patch + 1))
119+
;;
120+
*)
121+
echo "unsupported bump: $BUMP" >&2
122+
exit 1
123+
;;
124+
esac
125+
126+
tag_name="v${major}.${minor}.${patch}"
127+
if git rev-parse -q --verify "refs/tags/$tag_name" >/dev/null; then
128+
echo "tag $tag_name already exists locally" >&2
129+
exit 1
130+
fi
131+
if git ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1; then
132+
echo "tag $tag_name already exists on origin" >&2
133+
exit 1
134+
fi
135+
git tag "$tag_name"
136+
git push origin "$tag_name"
137+
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
138+
60139
github-release:
61140
name: Publish GitHub release
62141
runs-on: ubuntu-latest
63-
needs: verify
142+
needs:
143+
- verify
144+
- tag-release
145+
if: |
146+
!failure() && !cancelled()
147+
&& needs.verify.result == 'success'
148+
&& (needs.tag-release.result == 'success' || needs.tag-release.result == 'skipped')
149+
&& (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
64150
steps:
65151
- name: Create GitHub release
66152
uses: softprops/action-gh-release@v2
67153
with:
154+
tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.tag-release.outputs.tag_name || github.ref_name }}
68155
generate_release_notes: true

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
## Features
88

99
- Parse JSON request bodies with a default `1 MiB` limit
10+
- Return `413 Payload Too Large` when the configured body limit is exceeded
1011
- Bind path params explicitly through a router-specific extractor
1112
- Validate automatically during `ParseRequest` when a global validator is configured
1213
- Keep `ParseRequest` panic-safe for invalid inputs and return regular errors instead
@@ -205,12 +206,12 @@ flowchart TD
205206

206207
## Examples
207208

208-
Examples live in [`examples/`](/home/rluders/Projects/rluders/httpsuite/examples).
209+
Examples live in [`examples/`](examples/).
209210

210-
- [`examples/stdmux`](/home/rluders/Projects/rluders/httpsuite/examples/stdmux/main.go): core-only with `http.ServeMux`
211-
- [`examples/gorillamux`](/home/rluders/Projects/rluders/httpsuite/examples/gorillamux/main.go): path params with Gorilla Mux
212-
- [`examples/chi`](/home/rluders/Projects/rluders/httpsuite/examples/chi/main.go): global validation with Chi
213-
- [`examples/restapi`](/home/rluders/Projects/rluders/httpsuite/examples/restapi/main.go): fuller REST API example with pagination-style metadata and custom problems
211+
- [`examples/stdmux`](examples/stdmux/main.go): core-only with `http.ServeMux`
212+
- [`examples/gorillamux`](examples/gorillamux/main.go): path params with Gorilla Mux
213+
- [`examples/chi`](examples/chi/main.go): global validation with Chi
214+
- [`examples/restapi`](examples/restapi/main.go): fuller REST API example with pagination-style metadata and custom problems
214215

215216
`examples/restapi` shows:
216217

@@ -236,6 +237,15 @@ Examples live in [`examples/`](/home/rluders/Projects/rluders/httpsuite/examples
236237
- global validator support added via `SetValidator` and `RegisterDefault`
237238
- response metadata is generic, with optional `PageMeta` and `CursorMeta`
238239

240+
## Release workflow
241+
242+
The release workflow supports two paths:
243+
244+
- push an existing `v*` tag to verify and publish that release
245+
- run `Release` with `workflow_dispatch` and choose `major`, `minor`, or `patch`
246+
247+
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.
248+
239249
## Tutorial
240250

241251
- [Improving Request Validation and Response Handling in Go Microservices](https://medium.com/@rluders/improving-request-validation-and-response-handling-in-go-microservices-cc54208123f2)

examples/chi/go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module chi_example
22

3-
go 1.23
3+
go 1.25.0
44

55
require (
66
github.com/go-chi/chi/v5 v5.2.0
@@ -14,10 +14,10 @@ require (
1414
github.com/go-playground/universal-translator v0.18.1 // indirect
1515
github.com/go-playground/validator/v10 v10.24.0 // indirect
1616
github.com/leodido/go-urn v1.4.0 // indirect
17-
golang.org/x/crypto v0.32.0 // indirect
18-
golang.org/x/net v0.34.0 // indirect
19-
golang.org/x/sys v0.29.0 // indirect
20-
golang.org/x/text v0.21.0 // indirect
17+
golang.org/x/crypto v0.49.0 // indirect
18+
golang.org/x/net v0.51.0 // indirect
19+
golang.org/x/sys v0.42.0 // indirect
20+
golang.org/x/text v0.35.0 // indirect
2121
)
2222

2323
replace github.com/rluders/httpsuite/v3 => ../..

examples/chi/go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
1818
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1919
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
2020
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
21-
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
22-
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
23-
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
24-
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
25-
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
26-
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
27-
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
28-
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
21+
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
22+
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
23+
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
24+
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
25+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
26+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
27+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
28+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
2929
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
3030
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

examples/restapi/go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module restapi_example
22

3-
go 1.23
3+
go 1.25.0
44

55
require (
66
github.com/go-chi/chi/v5 v5.2.0
@@ -14,10 +14,10 @@ require (
1414
github.com/go-playground/universal-translator v0.18.1 // indirect
1515
github.com/go-playground/validator/v10 v10.24.0 // indirect
1616
github.com/leodido/go-urn v1.4.0 // indirect
17-
golang.org/x/crypto v0.32.0 // indirect
18-
golang.org/x/net v0.34.0 // indirect
19-
golang.org/x/sys v0.29.0 // indirect
20-
golang.org/x/text v0.21.0 // indirect
17+
golang.org/x/crypto v0.49.0 // indirect
18+
golang.org/x/net v0.51.0 // indirect
19+
golang.org/x/sys v0.42.0 // indirect
20+
golang.org/x/text v0.35.0 // indirect
2121
)
2222

2323
replace github.com/rluders/httpsuite/v3 => ../..

examples/restapi/go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
1818
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1919
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
2020
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
21-
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
22-
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
23-
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
24-
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
25-
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
26-
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
27-
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
28-
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
21+
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
22+
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
23+
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
24+
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
25+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
26+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
27+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
28+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
2929
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
3030
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

examples/restapi/main.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,7 @@ func main() {
128128
pageSize := readPositiveInt(r, "page_size", 2)
129129

130130
users := store.List()
131-
start := (page - 1) * pageSize
132-
if start > len(users) {
133-
start = len(users)
134-
}
135-
end := start + pageSize
136-
if end > len(users) {
137-
end = len(users)
138-
}
131+
start, end := clampPageWindow(page, pageSize, len(users))
139132

140133
httpsuite.Reply().
141134
Meta(httpsuite.NewPageMeta(page, pageSize, len(users))).
@@ -209,6 +202,35 @@ func readPositiveInt(r *http.Request, key string, fallback int) int {
209202
return value
210203
}
211204

205+
func clampPageWindow(page, pageSize, total int) (int, int) {
206+
if total <= 0 {
207+
return 0, 0
208+
}
209+
if page <= 1 {
210+
page = 1
211+
}
212+
if pageSize <= 0 {
213+
pageSize = total
214+
}
215+
216+
totalPages := 1 + (total-1)/pageSize
217+
if page > totalPages {
218+
return total, total
219+
}
220+
221+
start := (page - 1) * pageSize
222+
if start > total {
223+
start = total
224+
}
225+
226+
end := total
227+
if remaining := total - start; pageSize < remaining {
228+
end = start + pageSize
229+
}
230+
231+
return start, end
232+
}
233+
212234
func nilParamExtractor(*http.Request, string) string {
213235
return ""
214236
}

examples/restapi/main_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestClampPageWindow(t *testing.T) {
6+
t.Parallel()
7+
8+
tests := []struct {
9+
name string
10+
page int
11+
pageSize int
12+
total int
13+
wantFrom int
14+
wantTo int
15+
}{
16+
{name: "first page", page: 1, pageSize: 2, total: 5, wantFrom: 0, wantTo: 2},
17+
{name: "after last page", page: 10, pageSize: 2, total: 5, wantFrom: 5, wantTo: 5},
18+
{name: "very large page", page: int(^uint(0) >> 1), pageSize: 2, total: 5, wantFrom: 5, wantTo: 5},
19+
{name: "very large page size", page: 1, pageSize: int(^uint(0) >> 1), total: 5, wantFrom: 0, wantTo: 5},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
from, to := clampPageWindow(tt.page, tt.pageSize, tt.total)
25+
if from != tt.wantFrom || to != tt.wantTo {
26+
t.Fatalf("expected (%d,%d), got (%d,%d)", tt.wantFrom, tt.wantTo, from, to)
27+
}
28+
})
29+
}
30+
}

examples/stdmux/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ func (r *SampleRequest) SetParam(fieldName, value string) error {
3232
}
3333

3434
func StdMuxParamExtractor(r *http.Request, key string) string {
35+
if key != "id" {
36+
return ""
37+
}
3538
// Remove "/submit/" (7 characters) from the URL path to get just the "id"
3639
// Example: /submit/123 -> 123
3740
return r.URL.Path[len("/submit/"):] // Skip the "/submit/" part

go.work

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
go 1.23
1+
go 1.25.0
22

33
use (
44
.

0 commit comments

Comments
 (0)