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
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ repos:
- id: trailing-whitespace
- id: detect-private-key
- repo: https://github.com/google/yamlfmt
rev: v0.17.2
rev: v0.21.0
hooks:
- id: yamlfmt
- repo: https://github.com/adhtruong/mirrors-typos
rev: v1.38.1
rev: v1.43.5
hooks:
- id: typos
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2025, theopenlane, Inc.
Copyright 2026, theopenlane, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
2 changes: 1 addition & 1 deletion files.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func FilesFromContextWithKey(r *http.Request, key string) ([]File, error) {
func MimeTypeValidator(validMimeTypes ...string) ValidationFunc {
return func(f File) error {
for _, mimeType := range validMimeTypes {
if strings.EqualFold(strings.ToLower(mimeType), f.MimeType) {
if strings.EqualFold((mimeType), f.MimeType) {
return nil
}
}
Expand Down
18 changes: 9 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
module github.com/theopenlane/httpsling

go 1.25.1
go 1.25.7

require (
github.com/felixge/httpsnoop v1.0.4
github.com/google/go-querystring v1.1.0
github.com/google/go-querystring v1.2.0
github.com/stretchr/testify v1.11.1
github.com/theopenlane/utils v0.5.2
github.com/theopenlane/utils v0.7.0
)

require (
go.uber.org/mock v0.5.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/tools v0.22.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/tools v0.36.0 // indirect
)

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

func newDefaultTransport() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second, // nolint: mnd
KeepAlive: 30 * time.Second, // nolint: mnd
}).DialContext,
MaxIdleConns: 100, // nolint: mnd
IdleConnTimeout: 90 * time.Second, // nolint: mnd
TLSHandshakeTimeout: 10 * time.Second, // nolint: mnd
ExpectContinueTimeout: 1 * time.Second, // nolint: mnd
}
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second, // nolint: mnd
KeepAlive: 30 * time.Second, // nolint: mnd
}).DialContext,
MaxIdleConns: 100, // nolint: mnd
IdleConnTimeout: 90 * time.Second, // nolint: mnd
TLSHandshakeTimeout: 10 * time.Second, // nolint: mnd
ExpectContinueTimeout: 1 * time.Second, // nolint: mnd
}
}

// Option is a configuration option for building an http.Client
Expand Down
18 changes: 10 additions & 8 deletions marshaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func (c *ContentTypeUnmarshaler) Unmarshal(data []byte, contentType string, v an

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

if u := c.Unmarshalers[mediaType]; u != nil {
Expand All @@ -223,13 +223,15 @@ func (c *ContentTypeUnmarshaler) Apply(r *Requester) error {
// generalMediaType will return a media type with just the suffix as the subtype, e.g.
// application/vnd.api+json -> application/json
func generalMediaType(s string) string {
i2 := strings.LastIndex(s, "+")
if i2 > -1 && len(s) > i2+1 {
i := strings.Index(s, "/")
if i > -1 {
return s[:i+1] + s[i2+1:]
}
mainType, subtype, ok := strings.Cut(s, "/")
if !ok || mainType == "" {
return ""
}

_, suffix, ok := strings.Cut(subtype, "+")
if !ok || suffix == "" {
return ""
}

return ""
return mainType + "/" + suffix
}
89 changes: 41 additions & 48 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,14 @@ func Dump(w io.Writer) Middleware {
}
}

// DumpToStout dumps requests and responses to os.Stdout
// Deprecated: use DumpToStdout instead.
func DumpToStout() Middleware {
return Dump(os.Stdout)
// DumpToStdout dumps requests and responses to os.Stdout
func DumpToStdout() Middleware {
return Dump(os.Stdout)
}

// DumpToStderr dumps requests and responses to os.Stderr
func DumpToStderr() Middleware {
return Dump(os.Stderr)
}

// DumpToStdout dumps requests and responses to os.Stdout.
// This is a correctly spelled alias of DumpToStout.
func DumpToStdout() Middleware {
return Dump(os.Stdout)
return Dump(os.Stderr)
}

type logFunc func(a ...any)
Expand Down Expand Up @@ -113,49 +106,49 @@ func ExpectCode(code int) Middleware {
//
// The response body will still be read and returned.
func ExpectSuccessCode() Middleware {
return func(next Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
r, c := getCodeChecker(req)
c.code = expectSuccessCode
resp, err := next.Do(r)

return c.checkCode(resp, err)
})
}
return func(next Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
r, c := getCodeChecker(req)
c.code = expectSuccessCode
resp, err := next.Do(r)

return c.checkCode(resp, err)
})
}
}

// ExpectCodes generates an error if the response's status code does not match
// any of the provided codes. If no codes are provided, it behaves like ExpectSuccessCode.
// The response body is still read and returned.
func ExpectCodes(codes ...int) Middleware {
if len(codes) == 0 {
return ExpectSuccessCode()
}

allowed := make(map[int]struct{}, len(codes))
for _, code := range codes {
allowed[code] = struct{}{}
}

return func(next Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
resp, err := next.Do(req)
if err != nil || resp == nil {
return resp, err
}

if _, ok := allowed[resp.StatusCode]; !ok {
err = rout.HTTPErrorResponse(
fmt.Errorf("%w: server returned unexpected status code. expected one of: %v, received: %d",
ErrUnsuccessfulResponse,
codes,
resp.StatusCode,
))
}

return resp, err
})
}
if len(codes) == 0 {
return ExpectSuccessCode()
}

allowed := make(map[int]struct{}, len(codes))
for _, code := range codes {
allowed[code] = struct{}{}
}

return func(next Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
resp, err := next.Do(req)
if err != nil || resp == nil {
return resp, err
}

if _, ok := allowed[resp.StatusCode]; !ok {
err = rout.HTTPErrorResponse(
fmt.Errorf("%w: server returned unexpected status code. expected one of: %v, received: %d",
ErrUnsuccessfulResponse,
codes,
resp.StatusCode,
))
}

return resp, err
})
}
}

type ctxKey int
Expand Down
6 changes: 3 additions & 3 deletions middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func TestDumpToLog(t *testing.T) {
assert.Contains(t, respLog, `{"color":"red"}`)
}

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

Expand Down Expand Up @@ -103,7 +103,7 @@ func TestDumpToStout(t *testing.T) {
outC <- buf.String()
}()

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

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

Expand Down
20 changes: 20 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,21 @@ func HeadersFromValues(h http.Header) Option {
if h == nil {
return nil
}

if b.Header == nil {
b.Header = make(http.Header)
}

for k, vs := range h {
// Set replaces existing values; mirror Header behavior
if len(vs) == 0 {
b.Header.Del(k)
continue
}

b.Header[k] = append([]string(nil), vs...)
}

return nil
})
}
Expand All @@ -162,12 +166,15 @@ func HeadersFromMap(m map[string]string) Option {
if m == nil {
return nil
}

if b.Header == nil {
b.Header = make(http.Header)
}

for k, v := range m {
b.Header.Set(k, v)
}

return nil
})
}
Expand Down Expand Up @@ -505,3 +512,16 @@ func WithFileErrorResponseHandler(errHandler ErrResponseHandler) Option {
return nil
})
}

// OnError sets the decode target for non-2xx responses. When a response with a
// non-success status code is received, the body is decoded into v and
// ErrUnsuccessfulResponse is returned. v must be a non-nil pointer.
// OnError is mutually exclusive with ExpectCode/ExpectSuccessCode middleware;
// if both are set, the middleware error fires first and OnError is not reached.
func OnError(v any) Option {
return OptionFunc(func(r *Requester) error {
r.errorInto = v

return nil
})
}
16 changes: 16 additions & 0 deletions packagefunctions.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ func ReceiveTo(w io.Writer, opts ...Option) (*http.Response, int64, error) {
return DefaultRequester.ReceiveTo(context.Background(), w, opts...)
}

// ReceiveIntoWithError sends a request and decodes into S on success (2xx) or E on
// non-success. The error return wraps ErrUnsuccessfulResponse for non-2xx responses.
func ReceiveIntoWithError[S, E any](ctx context.Context, opts ...Option) (*http.Response, S, E, error) {
var success S

var failure E

merged := make([]Option, len(opts)+1)
copy(merged, opts)
merged[len(opts)] = OnError(&failure)

resp, err := DefaultRequester.ReceiveWithContext(ctx, &success, merged...)

return resp, success, failure, err
}

// ReceiveToWithContext streams the response body into the writer with a context.
func ReceiveToWithContext(ctx context.Context, w io.Writer, opts ...Option) (*http.Response, int64, error) {
return DefaultRequester.ReceiveTo(ctx, w, opts...)
Expand Down
Loading
Loading