Skip to content

Commit 295a6e4

Browse files
authored
Add Error Response Decoding (#36)
* add onerror and receiveintowith error plus some misc fixes while I was in here * remove deprecated * bump outstanding updates
1 parent 316c0ca commit 295a6e4

15 files changed

Lines changed: 275 additions & 117 deletions

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ repos:
1010
- id: trailing-whitespace
1111
- id: detect-private-key
1212
- repo: https://github.com/google/yamlfmt
13-
rev: v0.17.2
13+
rev: v0.21.0
1414
hooks:
1515
- id: yamlfmt
1616
- repo: https://github.com/adhtruong/mirrors-typos
17-
rev: v1.38.1
17+
rev: v1.43.5
1818
hooks:
1919
- id: typos

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright 2025, theopenlane, Inc.
189+
Copyright 2026, theopenlane, Inc.
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

files.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func FilesFromContextWithKey(r *http.Request, key string) ([]File, error) {
5656
func MimeTypeValidator(validMimeTypes ...string) ValidationFunc {
5757
return func(f File) error {
5858
for _, mimeType := range validMimeTypes {
59-
if strings.EqualFold(strings.ToLower(mimeType), f.MimeType) {
59+
if strings.EqualFold((mimeType), f.MimeType) {
6060
return nil
6161
}
6262
}

go.mod

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
module github.com/theopenlane/httpsling
22

3-
go 1.25.1
3+
go 1.25.7
44

55
require (
66
github.com/felixge/httpsnoop v1.0.4
7-
github.com/google/go-querystring v1.1.0
7+
github.com/google/go-querystring v1.2.0
88
github.com/stretchr/testify v1.11.1
9-
github.com/theopenlane/utils v0.5.2
9+
github.com/theopenlane/utils v0.7.0
1010
)
1111

1212
require (
13-
go.uber.org/mock v0.5.0 // indirect
14-
golang.org/x/mod v0.18.0 // indirect
15-
golang.org/x/sync v0.10.0 // indirect
16-
golang.org/x/tools v0.22.0 // indirect
13+
go.uber.org/mock v0.6.0 // indirect
14+
golang.org/x/mod v0.27.0 // indirect
15+
golang.org/x/sync v0.17.0 // indirect
16+
golang.org/x/tools v0.36.0 // indirect
1717
)
1818

1919
require (
2020
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
21-
github.com/mazrean/formstream v1.1.2
21+
github.com/mazrean/formstream v1.1.3
2222
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
23-
github.com/theopenlane/echox v0.2.4
23+
github.com/theopenlane/echox v0.3.0
2424
gopkg.in/yaml.v3 v3.0.1 // indirect
2525
)

go.sum

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,32 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
22
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
44
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
5-
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
65
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
76
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
8-
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
9-
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
10-
github.com/mazrean/formstream v1.1.2 h1:i6mVkbv8s4puQy4yQKfrHwz7J5pjAtYRYRRKS9ptshs=
11-
github.com/mazrean/formstream v1.1.2/go.mod h1:c4sKyGJ0wmlK2W2y1rUkx7esEJBZ2to03LwUZ6rFK+0=
7+
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
8+
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
9+
github.com/mazrean/formstream v1.1.3 h1:x0dSAvfdT9RADX8KwUGyYJxZmzmUh+bukm0tqqgB7Mg=
10+
github.com/mazrean/formstream v1.1.3/go.mod h1:pUFW8MGzb8HuFgwSdqwZ0qzNqZy8pTWpdFgLmWEBQxU=
1211
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
1312
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1413
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1514
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
16-
github.com/theopenlane/echox v0.2.4 h1:bocz1Dfs7d2fkNa8foQqdmeTtkMTQNwe1v20bIGIDps=
17-
github.com/theopenlane/echox v0.2.4/go.mod h1:0cPOHe4SSQHmqP0/n2LsIEzRSogkxSX653bE+PIOVZ8=
18-
github.com/theopenlane/utils v0.5.2 h1:5Hpg+lgSGxBZwirh9DQumTHCBU9Wgopjp7Oug2FA+1c=
19-
github.com/theopenlane/utils v0.5.2/go.mod h1:d7F811pRS817S9wo9SmsSghS5GDgN32BFn6meMM9PM0=
20-
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
21-
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
22-
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
23-
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
24-
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
25-
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
26-
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
27-
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
28-
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
29-
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
30-
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
31-
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
32-
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
15+
github.com/theopenlane/echox v0.3.0 h1:uwOKEw+r1utGQoOR6dZQqAVuY5j8TcasqnTwO5+rMsA=
16+
github.com/theopenlane/echox v0.3.0/go.mod h1:yTrXnj7s3VNIg0FCvB7Dut2Elr+LqJKU/nruxx1E1cM=
17+
github.com/theopenlane/utils v0.7.0 h1:tSN9PBC8Ywn2As3TDW/1TAfWsVsodrccec40oAhiZgo=
18+
github.com/theopenlane/utils v0.7.0/go.mod h1:7U9CDoVzCAFWw/JygR5ZhCKGwhHBnuJpK3Jgh1m59+w=
19+
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
20+
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
21+
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
22+
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
23+
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
24+
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
25+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
26+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
27+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
28+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
29+
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
30+
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
3331
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
3432
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3533
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

httpclient/client.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,17 @@ func Apply(c *http.Client, opts ...Option) error {
2727
}
2828

2929
func newDefaultTransport() *http.Transport {
30-
return &http.Transport{
31-
Proxy: http.ProxyFromEnvironment,
32-
DialContext: (&net.Dialer{
33-
Timeout: 30 * time.Second, // nolint: mnd
34-
KeepAlive: 30 * time.Second, // nolint: mnd
35-
}).DialContext,
36-
MaxIdleConns: 100, // nolint: mnd
37-
IdleConnTimeout: 90 * time.Second, // nolint: mnd
38-
TLSHandshakeTimeout: 10 * time.Second, // nolint: mnd
39-
ExpectContinueTimeout: 1 * time.Second, // nolint: mnd
40-
}
30+
return &http.Transport{
31+
Proxy: http.ProxyFromEnvironment,
32+
DialContext: (&net.Dialer{
33+
Timeout: 30 * time.Second, // nolint: mnd
34+
KeepAlive: 30 * time.Second, // nolint: mnd
35+
}).DialContext,
36+
MaxIdleConns: 100, // nolint: mnd
37+
IdleConnTimeout: 90 * time.Second, // nolint: mnd
38+
TLSHandshakeTimeout: 10 * time.Second, // nolint: mnd
39+
ExpectContinueTimeout: 1 * time.Second, // nolint: mnd
40+
}
4141
}
4242

4343
// Option is a configuration option for building an http.Client

marshaling.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func (c *ContentTypeUnmarshaler) Unmarshal(data []byte, contentType string, v an
198198

199199
mediaType, _, err := mime.ParseMediaType(contentType)
200200
if err != nil {
201-
return fmt.Errorf(" %w: failed to parse content type: %s", err, contentType)
201+
return fmt.Errorf("%w: failed to parse content type: %s", err, contentType)
202202
}
203203

204204
if u := c.Unmarshalers[mediaType]; u != nil {
@@ -223,13 +223,15 @@ func (c *ContentTypeUnmarshaler) Apply(r *Requester) error {
223223
// generalMediaType will return a media type with just the suffix as the subtype, e.g.
224224
// application/vnd.api+json -> application/json
225225
func generalMediaType(s string) string {
226-
i2 := strings.LastIndex(s, "+")
227-
if i2 > -1 && len(s) > i2+1 {
228-
i := strings.Index(s, "/")
229-
if i > -1 {
230-
return s[:i+1] + s[i2+1:]
231-
}
226+
mainType, subtype, ok := strings.Cut(s, "/")
227+
if !ok || mainType == "" {
228+
return ""
229+
}
230+
231+
_, suffix, ok := strings.Cut(subtype, "+")
232+
if !ok || suffix == "" {
233+
return ""
232234
}
233235

234-
return ""
236+
return mainType + "/" + suffix
235237
}

middleware.go

Lines changed: 41 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,14 @@ func Dump(w io.Writer) Middleware {
5757
}
5858
}
5959

60-
// DumpToStout dumps requests and responses to os.Stdout
61-
// Deprecated: use DumpToStdout instead.
62-
func DumpToStout() Middleware {
63-
return Dump(os.Stdout)
60+
// DumpToStdout dumps requests and responses to os.Stdout
61+
func DumpToStdout() Middleware {
62+
return Dump(os.Stdout)
6463
}
6564

6665
// DumpToStderr dumps requests and responses to os.Stderr
6766
func DumpToStderr() Middleware {
68-
return Dump(os.Stderr)
69-
}
70-
71-
// DumpToStdout dumps requests and responses to os.Stdout.
72-
// This is a correctly spelled alias of DumpToStout.
73-
func DumpToStdout() Middleware {
74-
return Dump(os.Stdout)
67+
return Dump(os.Stderr)
7568
}
7669

7770
type logFunc func(a ...any)
@@ -113,49 +106,49 @@ func ExpectCode(code int) Middleware {
113106
//
114107
// The response body will still be read and returned.
115108
func ExpectSuccessCode() Middleware {
116-
return func(next Doer) Doer {
117-
return DoerFunc(func(req *http.Request) (*http.Response, error) {
118-
r, c := getCodeChecker(req)
119-
c.code = expectSuccessCode
120-
resp, err := next.Do(r)
121-
122-
return c.checkCode(resp, err)
123-
})
124-
}
109+
return func(next Doer) Doer {
110+
return DoerFunc(func(req *http.Request) (*http.Response, error) {
111+
r, c := getCodeChecker(req)
112+
c.code = expectSuccessCode
113+
resp, err := next.Do(r)
114+
115+
return c.checkCode(resp, err)
116+
})
117+
}
125118
}
126119

127120
// ExpectCodes generates an error if the response's status code does not match
128121
// any of the provided codes. If no codes are provided, it behaves like ExpectSuccessCode.
129122
// The response body is still read and returned.
130123
func ExpectCodes(codes ...int) Middleware {
131-
if len(codes) == 0 {
132-
return ExpectSuccessCode()
133-
}
134-
135-
allowed := make(map[int]struct{}, len(codes))
136-
for _, code := range codes {
137-
allowed[code] = struct{}{}
138-
}
139-
140-
return func(next Doer) Doer {
141-
return DoerFunc(func(req *http.Request) (*http.Response, error) {
142-
resp, err := next.Do(req)
143-
if err != nil || resp == nil {
144-
return resp, err
145-
}
146-
147-
if _, ok := allowed[resp.StatusCode]; !ok {
148-
err = rout.HTTPErrorResponse(
149-
fmt.Errorf("%w: server returned unexpected status code. expected one of: %v, received: %d",
150-
ErrUnsuccessfulResponse,
151-
codes,
152-
resp.StatusCode,
153-
))
154-
}
155-
156-
return resp, err
157-
})
158-
}
124+
if len(codes) == 0 {
125+
return ExpectSuccessCode()
126+
}
127+
128+
allowed := make(map[int]struct{}, len(codes))
129+
for _, code := range codes {
130+
allowed[code] = struct{}{}
131+
}
132+
133+
return func(next Doer) Doer {
134+
return DoerFunc(func(req *http.Request) (*http.Response, error) {
135+
resp, err := next.Do(req)
136+
if err != nil || resp == nil {
137+
return resp, err
138+
}
139+
140+
if _, ok := allowed[resp.StatusCode]; !ok {
141+
err = rout.HTTPErrorResponse(
142+
fmt.Errorf("%w: server returned unexpected status code. expected one of: %v, received: %d",
143+
ErrUnsuccessfulResponse,
144+
codes,
145+
resp.StatusCode,
146+
))
147+
}
148+
149+
return resp, err
150+
})
151+
}
159152
}
160153

161154
type ctxKey int

middleware_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func TestDumpToLog(t *testing.T) {
7474
assert.Contains(t, respLog, `{"color":"red"}`)
7575
}
7676

77-
func TestDumpToStout(t *testing.T) {
77+
func TestDumpToStdout(t *testing.T) {
7878
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
7979
w.Header().Set(HeaderContentType, ContentTypeJSON)
8080

@@ -103,7 +103,7 @@ func TestDumpToStout(t *testing.T) {
103103
outC <- buf.String()
104104
}()
105105

106-
resp, err := Receive(Get(ts.URL), DumpToStout())
106+
resp, err := Receive(Get(ts.URL), DumpToStdout())
107107
if err != nil {
108108
t.Errorf("Unexpected error: %v", err)
109109
}
@@ -122,7 +122,7 @@ func TestDumpToStout(t *testing.T) {
122122
assert.Contains(t, out, `{"color":"red"}`)
123123
}
124124

125-
func TestDumpToSterr(t *testing.T) {
125+
func TestDumpToStderr(t *testing.T) {
126126
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
127127
w.Header().Set(HeaderContentType, ContentTypeJSON)
128128

options.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,21 @@ func HeadersFromValues(h http.Header) Option {
141141
if h == nil {
142142
return nil
143143
}
144+
144145
if b.Header == nil {
145146
b.Header = make(http.Header)
146147
}
148+
147149
for k, vs := range h {
148150
// Set replaces existing values; mirror Header behavior
149151
if len(vs) == 0 {
150152
b.Header.Del(k)
151153
continue
152154
}
155+
153156
b.Header[k] = append([]string(nil), vs...)
154157
}
158+
155159
return nil
156160
})
157161
}
@@ -162,12 +166,15 @@ func HeadersFromMap(m map[string]string) Option {
162166
if m == nil {
163167
return nil
164168
}
169+
165170
if b.Header == nil {
166171
b.Header = make(http.Header)
167172
}
173+
168174
for k, v := range m {
169175
b.Header.Set(k, v)
170176
}
177+
171178
return nil
172179
})
173180
}
@@ -505,3 +512,16 @@ func WithFileErrorResponseHandler(errHandler ErrResponseHandler) Option {
505512
return nil
506513
})
507514
}
515+
516+
// OnError sets the decode target for non-2xx responses. When a response with a
517+
// non-success status code is received, the body is decoded into v and
518+
// ErrUnsuccessfulResponse is returned. v must be a non-nil pointer.
519+
// OnError is mutually exclusive with ExpectCode/ExpectSuccessCode middleware;
520+
// if both are set, the middleware error fires first and OnError is not reached.
521+
func OnError(v any) Option {
522+
return OptionFunc(func(r *Requester) error {
523+
r.errorInto = v
524+
525+
return nil
526+
})
527+
}

0 commit comments

Comments
 (0)