Skip to content

Commit 9fe94b7

Browse files
author
Dean Karn
authored
Helper Functions & building Blocks (#30)
1 parent 615d6ba commit 9fe94b7

File tree

12 files changed

+361
-19
lines changed

12 files changed

+361
-19
lines changed

.github/workflows/go.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,8 @@ jobs:
2929
restore-keys: |
3030
${{ runner.os }}-v1-go-
3131
32-
- name: Checkout code
33-
uses: actions/checkout@v3
34-
3532
- name: Test
36-
run: go test ./...
33+
run: go test -race -cover ./...
3734

3835
golangci:
3936
name: lint

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [5.17.0] - 2023-05-08
10+
### Added
11+
- bytesext.Bytes alias to int64 for better code clarity.
12+
- errorext.DoRetryable(...) building block for automating retryable errors.
13+
- sqlext.DoTransaction(...) building block for abstracting away transactions.
14+
- httpext.DoRetryableResponse(...) & httpext.DoRetryable(...) building blocks for automating retryable http requests.
15+
- httpext.DecodeResponse(...) building block for decoding http responses.
16+
- httpext.ErrRetryableStatusCode error for retryable http status code detection and handling.
17+
- errorsext.ErrMaxAttemptsReached error for retryable retryable logic & reuse.
18+
919
## [5.16.0] - 2023-04-16
1020
### Added
1121
- sliceext.Reverse(...)
@@ -27,7 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2737
### Added
2838
- Added `timext.NanoTime` for fast low level monotonic time with nanosecond precision.
2939

30-
[Unreleased]: https://github.com/go-playground/pkg/compare/v5.16.0...HEAD
40+
[Unreleased]: https://github.com/go-playground/pkg/compare/v5.17.0...HEAD
41+
[5.17.0]: https://github.com/go-playground/pkg/compare/v5.16.0...v5.17.0
3142
[5.16.0]: https://github.com/go-playground/pkg/compare/v5.15.2...v5.16.0
3243
[5.15.2]: https://github.com/go-playground/pkg/compare/v5.15.1...v5.15.2
3344
[5.15.1]: https://github.com/go-playground/pkg/compare/v5.15.0...v5.15.1

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# pkg
22

3-
![Project status](https://img.shields.io/badge/version-5.16.0-green.svg)
4-
[![Build Status](https://travis-ci.org/go-playground/pkg.svg?branch=master)](https://travis-ci.org/go-playground/pkg)
3+
![Project status](https://img.shields.io/badge/version-5.17.0-green.svg)
4+
[![Lint & Test](https://github.com/go-playground/pkg/actions/workflows/go.yml/badge.svg)](https://github.com/go-playground/pkg/actions/workflows/go.yml)
55
[![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master)
66
[![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://pkg.go.dev/mod/github.com/go-playground/pkg/v5)
77
![License](https://img.shields.io/dub/l/vibe-d.svg)

bytes/size.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package bytesext
22

3+
// Bytes is a type alias to int64 in order to better express the desired data type.
4+
type Bytes = int64
5+
36
// Common byte unit sizes
47
const (
58
BYTE = 1

database/sql/transaction.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
package sqlext
5+
6+
import (
7+
"context"
8+
"database/sql"
9+
resultext "github.com/go-playground/pkg/v5/values/result"
10+
)
11+
12+
// DoTransaction is a helper function that abstracts some complexities of dealing with a transaction and rolling it back.
13+
func DoTransaction[T any](ctx context.Context, opts *sql.TxOptions, conn *sql.DB, fn func(context.Context, *sql.Tx) resultext.Result[T, error]) resultext.Result[T, error] {
14+
tx, err := conn.BeginTx(ctx, opts)
15+
if err != nil {
16+
return resultext.Err[T, error](err)
17+
}
18+
result := fn(ctx, tx)
19+
if result.IsErr() {
20+
_ = tx.Rollback()
21+
return result
22+
}
23+
err = tx.Commit()
24+
if err != nil {
25+
return resultext.Err[T, error](err)
26+
}
27+
return result
28+
}

errors/do.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
package errorsext
5+
6+
import (
7+
"context"
8+
optionext "github.com/go-playground/pkg/v5/values/option"
9+
resultext "github.com/go-playground/pkg/v5/values/result"
10+
)
11+
12+
// RetryableFn is a function that can be retried.
13+
type RetryableFn[T, E any] func(ctx context.Context) resultext.Result[T, E]
14+
15+
// IsRetryableFn is called to determine if the error is retryable and optionally returns the reason for logging and metrics.
16+
type IsRetryableFn[E any] func(err E) (reason string, isRetryable bool)
17+
18+
// OnRetryFn is called after IsRetryableFn returns true and before the retry is attempted.
19+
//
20+
// this allows for interception, short-circuiting and adding of backoff strategies.
21+
type OnRetryFn[E any] func(ctx context.Context, originalErr E, reason string, attempt int) optionext.Option[E]
22+
23+
// DoRetryable will execute the provided functions code and automatically retry using the provided retry function.
24+
func DoRetryable[T, E any](ctx context.Context, isRetryFn IsRetryableFn[E], onRetryFn OnRetryFn[E], fn RetryableFn[T, E]) resultext.Result[T, E] {
25+
var attempt int
26+
for {
27+
result := fn(ctx)
28+
if result.IsErr() {
29+
err := result.Err()
30+
if reason, isRetryable := isRetryFn(err); isRetryable {
31+
if opt := onRetryFn(ctx, err, reason, attempt); opt.IsSome() {
32+
return resultext.Err[T, E](opt.Unwrap())
33+
}
34+
attempt++
35+
continue
36+
}
37+
}
38+
return result
39+
}
40+
}

errors/retryable.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import (
66
"syscall"
77
)
88

9+
var (
10+
// ErrMaxAttemptsReached is a placeholder error to use when some retryable even has reached its maximum number of
11+
// attempts.
12+
ErrMaxAttemptsReached = errors.New("max attempts reached")
13+
)
14+
915
// IsRetryableHTTP returns if the provided error is considered retryable HTTP error. It also returns the
1016
// type, in string form, for optional logging and metrics use.
1117
func IsRetryableHTTP(err error) (retryType string, isRetryable bool) {

net/http/helpers.go

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"mime"
99
"net"
1010
"net/http"
11+
"net/url"
1112
"path/filepath"
1213
"strings"
1314

@@ -102,11 +103,9 @@ func ClientIP(r *http.Request) (clientIP string) {
102103
return
103104
}
104105

105-
//
106106
// JSONStream uses json.Encoder to stream the JSON reponse body.
107107
//
108108
// This differs from the JSON helper which unmarshalls into memory first allowing the capture of JSON encoding errors.
109-
//
110109
func JSONStream(w http.ResponseWriter, status int, i interface{}) error {
111110
w.Header().Set(ContentType, ApplicationJSON)
112111
w.WriteHeader(status)
@@ -218,10 +217,17 @@ func DecodeMultipartForm(r *http.Request, qp QueryParamsOption, maxMemory int64,
218217
// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test'
219218
// is added to parsed JSON and replaces any values that may have been present
220219
func DecodeJSON(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) {
221-
var body io.Reader = r.Body
222-
if encoding := r.Header.Get(ContentEncoding); encoding == Gzip {
220+
var values url.Values
221+
if qp == QueryParams {
222+
values = r.URL.Query()
223+
}
224+
return decodeJSON(r.Header, r.Body, qp, values, maxMemory, v)
225+
}
226+
227+
func decodeJSON(headers http.Header, body io.Reader, qp QueryParamsOption, values url.Values, maxMemory int64, v interface{}) (err error) {
228+
if encoding := headers.Get(ContentEncoding); encoding == Gzip {
223229
var gzr *gzip.Reader
224-
gzr, err = gzip.NewReader(r.Body)
230+
gzr, err = gzip.NewReader(body)
225231
if err != nil {
226232
return
227233
}
@@ -232,7 +238,7 @@ func DecodeJSON(r *http.Request, qp QueryParamsOption, maxMemory int64, v interf
232238
}
233239
err = json.NewDecoder(ioext.LimitReader(body, maxMemory)).Decode(v)
234240
if qp == QueryParams && err == nil {
235-
err = DecodeQueryParams(r, v)
241+
err = decodeQueryParams(values, v)
236242
}
237243
return
238244
}
@@ -245,10 +251,17 @@ func DecodeJSON(r *http.Request, qp QueryParamsOption, maxMemory int64, v interf
245251
// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test'
246252
// is added to parsed XML and replaces any values that may have been present
247253
func DecodeXML(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) {
248-
var body io.Reader = r.Body
249-
if encoding := r.Header.Get(ContentEncoding); encoding == Gzip {
254+
var values url.Values
255+
if qp == QueryParams {
256+
values = r.URL.Query()
257+
}
258+
return decodeXML(r.Header, r.Body, qp, values, maxMemory, v)
259+
}
260+
261+
func decodeXML(headers http.Header, body io.Reader, qp QueryParamsOption, values url.Values, maxMemory int64, v interface{}) (err error) {
262+
if encoding := headers.Get(ContentEncoding); encoding == Gzip {
250263
var gzr *gzip.Reader
251-
gzr, err = gzip.NewReader(r.Body)
264+
gzr, err = gzip.NewReader(body)
252265
if err != nil {
253266
return
254267
}
@@ -259,14 +272,18 @@ func DecodeXML(r *http.Request, qp QueryParamsOption, maxMemory int64, v interfa
259272
}
260273
err = xml.NewDecoder(ioext.LimitReader(body, maxMemory)).Decode(v)
261274
if qp == QueryParams && err == nil {
262-
err = DecodeQueryParams(r, v)
275+
err = decodeQueryParams(values, v)
263276
}
264277
return
265278
}
266279

267280
// DecodeQueryParams takes the URL Query params flag.
268281
func DecodeQueryParams(r *http.Request, v interface{}) (err error) {
269-
err = DefaultFormDecoder.Decode(v, r.URL.Query())
282+
return decodeQueryParams(r.URL.Query(), v)
283+
}
284+
285+
func decodeQueryParams(values url.Values, v interface{}) (err error) {
286+
err = DefaultFormDecoder.Decode(v, values)
270287
return
271288
}
272289

@@ -275,7 +292,7 @@ const (
275292
nakedApplicationXML string = "application/xml"
276293
)
277294

278-
// Decode takes the request and attempts to discover it's content type via
295+
// Decode takes the request and attempts to discover its content type via
279296
// the http headers and then decode the request body into the provided struct.
280297
// Example if header was "application/json" would decode using
281298
// json.NewDecoder(ioext.LimitReader(r.Body, maxMemory)).Decode(v).

net/http/helpers_go1.18.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
package httpext
5+
6+
import (
7+
"errors"
8+
bytesext "github.com/go-playground/pkg/v5/bytes"
9+
"net/http"
10+
"strings"
11+
)
12+
13+
// DecodeResponse takes the response and attempts to discover its content type via
14+
// the http headers and then decode the request body into the provided type.
15+
//
16+
// Example if header was "application/json" would decode using
17+
// json.NewDecoder(ioext.LimitReader(r.Body, maxMemory)).Decode(v).
18+
func DecodeResponse[T any](r *http.Response, maxMemory bytesext.Bytes) (result T, err error) {
19+
typ := r.Header.Get(ContentType)
20+
if idx := strings.Index(typ, ";"); idx != -1 {
21+
typ = typ[:idx]
22+
}
23+
switch typ {
24+
case nakedApplicationJSON:
25+
err = decodeJSON(r.Header, r.Body, NoQueryParams, nil, maxMemory, &result)
26+
case nakedApplicationXML:
27+
err = decodeXML(r.Header, r.Body, NoQueryParams, nil, maxMemory, &result)
28+
default:
29+
err = errors.New("unsupported content type")
30+
}
31+
return
32+
}

net/http/helpers_test_go1.18.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
package httpext
5+
6+
import (
7+
. "github.com/go-playground/assert/v2"
8+
bytesext "github.com/go-playground/pkg/v5/bytes"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
)
13+
14+
func TestDecodeResponse(t *testing.T) {
15+
16+
type result struct {
17+
ID int `json:"id" xml:"id"`
18+
}
19+
20+
tests := []struct {
21+
name string
22+
handler http.HandlerFunc
23+
expected result
24+
}{
25+
{
26+
name: "Test JSON",
27+
handler: func(w http.ResponseWriter, r *http.Request) {
28+
Equal(t, JSON(w, http.StatusOK, result{ID: 3}), nil)
29+
},
30+
expected: result{ID: 3},
31+
},
32+
{
33+
name: "Test XML",
34+
handler: func(w http.ResponseWriter, r *http.Request) {
35+
Equal(t, XML(w, http.StatusOK, result{ID: 5}), nil)
36+
},
37+
expected: result{ID: 5},
38+
},
39+
}
40+
41+
for _, tc := range tests {
42+
tc := tc
43+
t.Run(tc.name, func(t *testing.T) {
44+
mux := http.NewServeMux()
45+
mux.HandleFunc("/", tc.handler)
46+
47+
server := httptest.NewServer(mux)
48+
defer server.Close()
49+
50+
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
51+
Equal(t, err, nil)
52+
53+
resp, err := http.DefaultClient.Do(req)
54+
Equal(t, err, nil)
55+
Equal(t, resp.StatusCode, http.StatusOK)
56+
57+
res, err := DecodeResponse[result](resp, bytesext.MiB)
58+
Equal(t, err, nil)
59+
Equal(t, tc.expected.ID, res.ID)
60+
})
61+
}
62+
}

0 commit comments

Comments
 (0)