From 84e531499da4139926bf90bc6a585821d51e5a55 Mon Sep 17 00:00:00 2001 From: Matthew Anderson <42154938+matoszz@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:42:56 -0500 Subject: [PATCH 1/3] upgrade go, functional and safety enhancements, misc updates --- .golangci.yaml | 2 +- .pre-commit-config.yaml | 8 ++-- README.md | 26 +++++++++---- echo/context.go | 2 +- example_test.go | 30 +++++++-------- files.go | 2 +- go.mod | 6 +-- go.sum | 16 ++++---- httpclient/client.go | 23 ++++++------ httptestutil/dump.go | 4 +- marshaling.go | 28 +++++++------- marshaling_test.go | 12 +++--- middleware.go | 65 +++++++++++++++++++++++++++------ middleware_test.go | 13 ++++--- mocks_test.go | 22 +++++------ options.go | 81 ++++++++++++++++++++++++++--------------- options_test.go | 6 +-- packagefunctions.go | 31 +++++++++++++++- requester.go | 52 +++++++++++++++++++++++--- requester_test.go | 32 +++++++++++++--- response.go | 4 +- retry.go | 12 +++--- 22 files changed, 321 insertions(+), 156 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 0a9493c..1fbbcac 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -41,4 +41,4 @@ formatters: extra-rules: true goimports: local-prefixes: - - github.com/theopenlane/httpsling \ No newline at end of file + - github.com/theopenlane/httpsling diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9f54a8..aa8fc2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,15 +5,15 @@ default_language_version: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: detect-private-key - repo: https://github.com/google/yamlfmt - rev: v0.15.0 + rev: v0.17.2 hooks: - id: yamlfmt - - repo: https://github.com/crate-ci/typos - rev: v1.29.4 + - repo: https://github.com/adhtruong/mirrors-typos + rev: v1.38.1 hooks: - id: typos diff --git a/README.md b/README.md index 0f18003..4039c75 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build status](https://badge.buildkite.com/f74a461120ffcadbf7796d5aac8ae8c03a1cbcfda142220074.svg)](https://buildkite.com/theopenlane/httpsling) +[![Build status](https://badge.buildkite.com/f74a461120ffcadbf7796d5aac8ae8c03a1cbcfda142220074.svg)](https://buildkite.com/theopenlane/httpsling?branch=main) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=theopenlane_httpsling&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=theopenlane_httpsling) [![Go Report Card](https://goreportcard.com/badge/github.com/theopenlane/httpsling)](https://goreportcard.com/report/github.com/theopenlane/httpsling) [![Go Reference](https://pkg.go.dev/badge/github.com/theopenlane/httpsling.svg)](https://pkg.go.dev/github.com/theopenlane/httpsling) @@ -33,7 +33,7 @@ func main() { } // Perform a GET request - var out map[string]interface{} + var out map[string]any resp, err := requester.Receive(&out, httpsling.Get("resource")) if err != nil { log.Fatal(err) @@ -56,9 +56,17 @@ RequestWithContext(context.Context, ...Option) (*http.Request, error) Send(...Option) (*http.Response, error) SendWithContext(context.Context, ...Option) (*http.Response, error) -// build and send the request and parse the response into an interface -Receive(interface{}, ...Option) (*http.Response, []byte, error) -ReceiveWithContext(context.Context, interface{}, ...Option) (*http.Response, error) +// build and send the request and parse the response into a value +Receive(any, ...Option) (*http.Response, error) +ReceiveWithContext(context.Context, any, ...Option) (*http.Response, error) + +// typed helper using generics +ReceiveInto[T any](...Option) (*http.Response, T, error) +ReceiveIntoWithContext[T any](context.Context, ...Option) (*http.Response, T, error) + +// stream response body into an io.Writer +ReceiveTo(io.Writer, ...Option) (*http.Response, int64, error) +ReceiveToWithContext(context.Context, io.Writer, ...Option) (*http.Response, int64, error) ``` ### Configuring BaseURL @@ -130,7 +138,7 @@ The library provides a `Receive` to construct and dispatch HTTP. Here are exampl ```go resp, err := requester.ReceiveWithContext(context.Background(), &out, httpsling.Post("/path"), - httpsling.Body(map[string]interface{}{"key": "value"}) + httpsling.Body(map[string]any{"key": "value"}) ) ``` @@ -139,7 +147,7 @@ The library provides a `Receive` to construct and dispatch HTTP. Here are exampl ```go resp, err := requester.ReceiveWithContext(context.Background(), &out, httpsling.Put("/path/123456"), - httpsling.Body(map[string]interface{}{"key": "newValue"}) + httpsling.Body(map[string]any{"key": "newValue"}) ) ``` @@ -187,6 +195,8 @@ log.Printf("Status Code: %d\n", resp.StatusCode) log.Printf("Response Data: %s\n", out.Data) ``` +Note: Receive fully reads the response into memory to unmarshal, and restores `resp.Body` so it remains readable by callers. + ### Evaluating Response Success To assess whether the HTTP request was successful: @@ -210,4 +220,4 @@ This library was inspired by and built upon the work of several other HTTP clien ## Contributing -See [contributing](.github/CONTRIBUTING.md) for details. \ No newline at end of file +See [contributing](.github/CONTRIBUTING.md) for details. diff --git a/echo/context.go b/echo/context.go index ba4d7c1..20f16bd 100644 --- a/echo/context.go +++ b/echo/context.go @@ -39,6 +39,6 @@ func (a *EchoContextAdapter) Err() error { // Value implements the Value method of the context.Context interface // used to retrieve a value associated with a specific key from the context -func (a *EchoContextAdapter) Value(key interface{}) interface{} { +func (a *EchoContextAdapter) Value(key any) any { return a.c.Get(fmt.Sprintf("%v", key)) } diff --git a/example_test.go b/example_test.go index e775179..9043b62 100644 --- a/example_test.go +++ b/example_test.go @@ -13,21 +13,21 @@ import ( ) func Example() { - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - w.Write([]byte(`{"color":"red"}`)) - })) - defer s.Close() - - var out map[string]string - resp, _ := Receive( - out, - Get(s.URL), - ) - - fmt.Println(resp.StatusCode) - fmt.Printf("%s", out) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"color":"red"}`)) + })) + defer s.Close() + + var out map[string]string + resp, _ := Receive( + &out, + Get(s.URL), + ) + + fmt.Println(resp.StatusCode) + fmt.Printf("%v", out) } func Example_receive() { diff --git a/files.go b/files.go index a87bc98..10282dc 100644 --- a/files.go +++ b/files.go @@ -65,7 +65,7 @@ func MimeTypeValidator(validMimeTypes ...string) ValidationFunc { } } -// ChainValidators returns a validator that accepts multiple validating criteras +// ChainValidators returns a validator that accepts multiple validating criteria func ChainValidators(validators ...ValidationFunc) ValidationFunc { return func(f File) error { for _, validator := range validators { diff --git a/go.mod b/go.mod index fb0c676..63372ed 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/theopenlane/httpsling -go 1.23.5 +go 1.25.2 require ( github.com/felixge/httpsnoop v1.0.4 github.com/google/go-querystring v1.1.0 github.com/stretchr/testify v1.11.1 - github.com/theopenlane/utils v0.4.4 + github.com/theopenlane/utils v0.5.2 ) require ( @@ -20,6 +20,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/mazrean/formstream v1.1.2 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/theopenlane/echox v0.2.1 + github.com/theopenlane/echox v0.2.4 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b278fdd..1adaad2 100644 --- a/go.sum +++ b/go.sum @@ -13,20 +13,20 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI 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.1 h1:ZhVkimmWxpKITf67oM57SrLWeIdnV8+dNXlC+VzlRaQ= -github.com/theopenlane/echox v0.2.1/go.mod h1:4j/Hx0uoLk5gVzdA83Qqz7xBEmqpoEP+OnzVaw2p6/o= -github.com/theopenlane/utils v0.4.4 h1:4Xb2T+4bjMtf4OL73bWQ1a8zllTt43ryVflRzVaUgmU= -github.com/theopenlane/utils v0.4.4/go.mod h1:lNzPjqQoDM5565s5FRqkmBGO77twAkY3Hxgd38ESo6I= +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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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= diff --git a/httpclient/client.go b/httpclient/client.go index dbda600..44399ec 100644 --- a/httpclient/client.go +++ b/httpclient/client.go @@ -27,18 +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 - DualStack: true, - }).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 diff --git a/httptestutil/dump.go b/httptestutil/dump.go index 3976e5e..cbe2f80 100644 --- a/httptestutil/dump.go +++ b/httptestutil/dump.go @@ -64,7 +64,7 @@ func DumpToStdout(ts *httptest.Server) { Dump(ts, os.Stdout) } -type logFunc func(a ...interface{}) +type logFunc func(a ...any) // Write implements io.Writer func (f logFunc) Write(p []byte) (n int, err error) { @@ -74,6 +74,6 @@ func (f logFunc) Write(p []byte) (n int, err error) { } // DumpToLog writes requests and responses to a logging function -func DumpToLog(ts *httptest.Server, logf func(a ...interface{})) { +func DumpToLog(ts *httptest.Server, logf func(a ...any)) { Dump(ts, logFunc(logf)) } diff --git a/marshaling.go b/marshaling.go index 905b871..51ef357 100644 --- a/marshaling.go +++ b/marshaling.go @@ -17,16 +17,16 @@ var DefaultUnmarshaler Unmarshaler = NewContentTypeUnmarshaler() // Marshaler marshals values into a []byte type Marshaler interface { - Marshal(v interface{}) (data []byte, contentType string, err error) + Marshal(v any) (data []byte, contentType string, err error) } // Unmarshaler unmarshals a []byte response body into a value type Unmarshaler interface { - Unmarshal(data []byte, contentType string, v interface{}) error + Unmarshal(data []byte, contentType string, v any) error } // MarshalFunc adapts a function to the Marshaler interface -type MarshalFunc func(v interface{}) ([]byte, string, error) +type MarshalFunc func(v any) ([]byte, string, error) // Apply implements Option func (f MarshalFunc) Apply(r *Requester) error { @@ -35,12 +35,12 @@ func (f MarshalFunc) Apply(r *Requester) error { } // Marshal implements the Marshaler interface -func (f MarshalFunc) Marshal(v interface{}) ([]byte, string, error) { +func (f MarshalFunc) Marshal(v any) ([]byte, string, error) { return f(v) } // UnmarshalFunc adapts a function to the Unmarshaler interface -type UnmarshalFunc func(data []byte, contentType string, v interface{}) error +type UnmarshalFunc func(data []byte, contentType string, v any) error // Apply implements Option func (f UnmarshalFunc) Apply(r *Requester) error { @@ -49,7 +49,7 @@ func (f UnmarshalFunc) Apply(r *Requester) error { } // Unmarshal implements the Unmarshaler interface -func (f UnmarshalFunc) Unmarshal(data []byte, contentType string, v interface{}) error { +func (f UnmarshalFunc) Unmarshal(data []byte, contentType string, v any) error { return f(data, contentType, v) } @@ -59,12 +59,12 @@ type JSONMarshaler struct { } // Unmarshal implements Unmarshaler -func (m *JSONMarshaler) Unmarshal(data []byte, _ string, v interface{}) error { +func (m *JSONMarshaler) Unmarshal(data []byte, _ string, v any) error { return json.Unmarshal(data, v) } // Marshal implements Marshaler -func (m *JSONMarshaler) Marshal(v interface{}) (data []byte, contentType string, err error) { +func (m *JSONMarshaler) Marshal(v any) (data []byte, contentType string, err error) { if m.Indent { data, err = json.MarshalIndent(v, "", " ") } else { @@ -87,12 +87,12 @@ type XMLMarshaler struct { } // Unmarshal implements Unmarshaler -func (*XMLMarshaler) Unmarshal(data []byte, _ string, v interface{}) error { +func (*XMLMarshaler) Unmarshal(data []byte, _ string, v any) error { return xml.Unmarshal(data, v) } // Marshal implements Marshaler -func (m *XMLMarshaler) Marshal(v interface{}) (data []byte, contentType string, err error) { +func (m *XMLMarshaler) Marshal(v any) (data []byte, contentType string, err error) { if m.Indent { data, err = xml.MarshalIndent(v, "", " ") } else { @@ -114,14 +114,14 @@ type TextUnmarshaler struct { } // Unmarshal implements Unmarshaler -func (*TextUnmarshaler) Unmarshal(data []byte, _ string, v interface{}) error { +func (*TextUnmarshaler) Unmarshal(data []byte, _ string, v any) error { *(v.(*string)) = string(data) return nil } // Marshal implements Marshaler -func (m *TextUnmarshaler) Marshal(v interface{}) (data []byte, contentType string, err error) { +func (m *TextUnmarshaler) Marshal(v any) (data []byte, contentType string, err error) { data = []byte(fmt.Sprintf("%v", v)) return data, ContentTypeTextUTF8, nil @@ -137,7 +137,7 @@ func (m *TextUnmarshaler) Apply(r *Requester) error { type FormMarshaler struct{} // Marshal implements Marshaler -func (*FormMarshaler) Marshal(v interface{}) (data []byte, contentType string, err error) { +func (*FormMarshaler) Marshal(v any) (data []byte, contentType string, err error) { switch t := v.(type) { case map[string][]string: urlV := url.Values(t) @@ -191,7 +191,7 @@ func defaultUnmarshalers() map[string]Unmarshaler { } // Unmarshal implements Unmarshaler -func (c *ContentTypeUnmarshaler) Unmarshal(data []byte, contentType string, v interface{}) error { +func (c *ContentTypeUnmarshaler) Unmarshal(data []byte, contentType string, v any) error { if c.Unmarshalers == nil { c.Unmarshalers = defaultUnmarshalers() } diff --git a/marshaling_test.go b/marshaling_test.go index 6c9d458..6743bbb 100644 --- a/marshaling_test.go +++ b/marshaling_test.go @@ -14,7 +14,7 @@ import ( func TestJSONMarshalerMarshal(t *testing.T) { m := JSONMarshaler{} - v := map[string]interface{}{"color": "red"} + v := map[string]any{"color": "red"} expected, err := json.Marshal(v) require.NoError(t, err) @@ -37,12 +37,12 @@ func TestJSONMarshalerUnmarshal(t *testing.T) { m := JSONMarshaler{} d := []byte(`{"color":"red"}`) - var v interface{} + var v any err := m.Unmarshal(d, "", &v) require.NoError(t, err) - require.Equal(t, map[string]interface{}{"color": "red"}, v) + require.Equal(t, map[string]any{"color": "red"}, v) } type testModel struct { @@ -142,7 +142,7 @@ func TestContentTypeUnmarshalerApply(t *testing.T) { func TestFormMarshalerMarshal(t *testing.T) { testCases := []struct { - input interface{} + input any output string }{ { @@ -174,7 +174,7 @@ func TestFormMarshalerMarshal(t *testing.T) { } func TestMarshalFuncApply(t *testing.T) { - var mf MarshalFunc = func(_ interface{}) (bytes []byte, s string, e error) { + var mf MarshalFunc = func(_ any) (bytes []byte, s string, e error) { return nil, "red", nil } @@ -196,7 +196,7 @@ func ExampleFormMarshaler() { } func ExampleJSONMarshaler() { - req, _ := Request(&JSONMarshaler{Indent: false}, Body(map[string]interface{}{"color": "red"})) + req, _ := Request(&JSONMarshaler{Indent: false}, Body(map[string]any{"color": "red"})) b, _ := io.ReadAll(req.Body) diff --git a/middleware.go b/middleware.go index 035e70d..99b4e47 100644 --- a/middleware.go +++ b/middleware.go @@ -58,16 +58,23 @@ 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) + return Dump(os.Stdout) } // DumpToStderr dumps requests and responses to os.Stderr func DumpToStderr() Middleware { - return Dump(os.Stderr) + return Dump(os.Stderr) } -type logFunc func(a ...interface{}) +// DumpToStdout dumps requests and responses to os.Stdout. +// This is a correctly spelled alias of DumpToStout. +func DumpToStdout() Middleware { + return Dump(os.Stdout) +} + +type logFunc func(a ...any) func (f logFunc) Write(p []byte) (n int, err error) { f(string(p)) @@ -81,7 +88,7 @@ func (f logFunc) Write(p []byte) (n int, err error) { // logf will be invoked once for the request, and once for the response. // Each invocation will only have a single argument (the entire request // or response is logged as a single string value). -func DumpToLog(logf func(a ...interface{})) Middleware { +func DumpToLog(logf func(a ...any)) Middleware { return Dump(logFunc(logf)) } @@ -106,15 +113,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 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 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 + }) + } } type ctxKey int diff --git a/middleware_test.go b/middleware_test.go index c48a53e..421f143 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -53,9 +53,9 @@ func TestDumpToLog(t *testing.T) { defer ts.Close() - var args []interface{} + var args []any - resp, err := Receive(Get(ts.URL), DumpToLog(func(a ...interface{}) { + resp, err := Receive(Get(ts.URL), DumpToLog(func(a ...any) { args = append(args, a...) })) if err != nil { @@ -295,7 +295,7 @@ func ExampleMiddleware() { } func ExampleDumpToLog() { - resp, err := Send(DumpToLog(func(a ...interface{}) { + resp, err := Send(DumpToLog(func(a ...any) { fmt.Println(a...) })) if err != nil { @@ -311,9 +311,10 @@ func ExampleDumpToLog() { defer resp.Body.Close() - var t *testing.T - - resp, err = Send(DumpToLog(t.Log)) + // Example with a custom logger function; do not use t.Log in examples as t may be nil. + resp, err = Send(DumpToLog(func(a ...any) { + fmt.Println(a...) + })) if err != nil { fmt.Println(err) } diff --git a/mocks_test.go b/mocks_test.go index dec74d1..a50ff86 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -16,13 +16,13 @@ import ( func TestMockHandler(t *testing.T) { h := MockHandler(201, JSON(false), - Body(map[string]interface{}{"color": "blue"}), + Body(map[string]any{"color": "blue"}), ) ts := httptest.NewServer(h) defer ts.Close() - var out map[string]interface{} + var out map[string]any resp, err := Receive(&out, Get(ts.URL)) require.NoError(t, err) @@ -40,9 +40,9 @@ func TestChannelHandler(t *testing.T) { defer ts.Close() in <- MockResponse(201, JSON(false), // nolint: bodyclose - Body(map[string]interface{}{"color": "blue"})) + Body(map[string]any{"color": "blue"})) - var out map[string]interface{} + var out map[string]any resp, err := Receive(&out, Get(ts.URL)) require.NoError(t, err) @@ -56,7 +56,7 @@ func TestChannelHandler(t *testing.T) { func TestMockResponse(t *testing.T) { resp := MockResponse(201, JSON(false), - Body(map[string]interface{}{"color": "red"}), + Body(map[string]any{"color": "red"}), ) defer resp.Body.Close() @@ -77,7 +77,7 @@ func TestMockResponse(t *testing.T) { func TestMockDoer(t *testing.T) { d := MockDoer(201, JSON(false), - Body(map[string]interface{}{"color": "blue"}), + Body(map[string]any{"color": "blue"}), ) req, err := Request(Get("/profile"), d) @@ -104,7 +104,7 @@ func TestChannelDoer(t *testing.T) { in <- MockResponse(201, // nolint: bodyclose JSON(false), - Body(map[string]interface{}{"color": "blue"}), + Body(map[string]any{"color": "blue"}), ) req, err := Request(Get("/profile"), d) @@ -129,12 +129,12 @@ func TestChannelDoer(t *testing.T) { func ExampleMockDoer() { d := MockDoer(201, JSON(false), - Body(map[string]interface{}{"color": "blue"}), + Body(map[string]any{"color": "blue"}), ) // Since DoerFunc is an Option, it can be passed directly to functions // which accept Options. - var out map[string]interface{} + var out map[string]any resp, _ := Receive(&out, d) defer resp.Body.Close() @@ -154,13 +154,13 @@ func ExampleMockDoer() { func ExampleMockHandler() { h := MockHandler(201, JSON(false), - Body(map[string]interface{}{"color": "blue"}), + Body(map[string]any{"color": "blue"}), ) ts := httptest.NewServer(h) defer ts.Close() - var out map[string]interface{} + var out map[string]any resp, _ := Receive(&out, URL(ts.URL)) defer resp.Body.Close() diff --git a/options.go b/options.go index 3d8c9e2..9448d27 100644 --- a/options.go +++ b/options.go @@ -135,6 +135,43 @@ func Header(key, value string) Option { }) } +// HeadersFromValues sets multiple headers from an http.Header map using Header.Set semantics. +func HeadersFromValues(h http.Header) Option { + return OptionFunc(func(b *Requester) error { + 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 + }) +} + +// HeadersFromMap sets multiple headers from a simple string map using Header.Set semantics. +func HeadersFromMap(m map[string]string) Option { + return OptionFunc(func(b *Requester) error { + 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 + }) +} + // DeleteHeader deletes a header key, using Header.Del() func DeleteHeader(key string) Option { return OptionFunc(func(b *Requester) error { @@ -244,7 +281,7 @@ func AppendPath(elements ...string) Option { } // QueryParams adds params to the Requester.QueryParams member -func QueryParams(queryStructs ...interface{}) Option { +func QueryParams(queryStructs ...any) Option { return OptionFunc(func(s *Requester) error { if s.QueryParams == nil { s.QueryParams = url.Values{} @@ -303,7 +340,7 @@ func QueryParam(k, v string) Option { } // Body sets the body of the request -func Body(body interface{}) Option { +func Body(body any) Option { return OptionFunc(func(b *Requester) error { b.Body = body @@ -388,6 +425,14 @@ func Form() Option { return WithMarshaler(&FormMarshaler{}) } +// JSONBody sets JSON marshaling and attaches the given body value. +func JSONBody(v any) Option { + return joinOpts( + JSON(false), + Body(v), + ) +} + // Client replaces Requester.Doer with an *http.Client func Client(opts ...httpclient.Option) Option { return OptionFunc(func(b *Requester) error { @@ -420,6 +465,11 @@ func WithDoer(d Doer) Option { }) } +// WithHTTPClient replaces Requester.Doer with the given *http.Client. +func WithHTTPClient(c *http.Client) Option { + return WithDoer(c) +} + // WithMaxFileSize sets the maximum file size for file uploads func WithMaxFileSize(i int64) Option { return OptionFunc(func(r *Requester) error { @@ -428,30 +478,3 @@ func WithMaxFileSize(i int64) Option { return nil }) } - -// WithValidationFunc allows you to set a function that can be used to perform validations -func WithValidationFunc(validationFunc ValidationFunc) Option { - return OptionFunc(func(r *Requester) error { - r.validationFunc = validationFunc - - return nil - }) -} - -// WithNameFuncGenerator allows you configure how you'd like to rename your uploaded files -func WithNameFuncGenerator(nameFunc NameGeneratorFunc) Option { - return OptionFunc(func(r *Requester) error { - r.fileNameFuncGenerator = nameFunc - - return nil - }) -} - -// WithFileErrorResponseHandler allows you to configure how you'd like to handle errors when a file upload fails either to your own server or the destination server or both -func WithFileErrorResponseHandler(errHandler ErrResponseHandler) Option { - return OptionFunc(func(r *Requester) error { - r.fileUploaderrorResponseHandler = errHandler - - return nil - }) -} diff --git a/options_test.go b/options_test.go index f5d7421..6880ec7 100644 --- a/options_test.go +++ b/options_test.go @@ -458,11 +458,11 @@ func TestBody(t *testing.T) { type testMarshaler struct{} -func (*testMarshaler) Unmarshal(_ []byte, _ string, _ interface{}) error { +func (*testMarshaler) Unmarshal(_ []byte, _ string, _ any) error { panic("implement me") } -func (*testMarshaler) Marshal(_ interface{}) (data []byte, contentType string, err error) { +func (*testMarshaler) Marshal(_ any) (data []byte, contentType string, err error) { panic("implement me") } @@ -635,7 +635,7 @@ func ExampleBody() { } func ExampleBody_map() { - req, _ := Request(Body(map[string]interface{}{"color": "red"})) + req, _ := Request(Body(map[string]any{"color": "red"})) b, _ := io.ReadAll(req.Body) diff --git a/packagefunctions.go b/packagefunctions.go index 394840e..c5f0ff0 100644 --- a/packagefunctions.go +++ b/packagefunctions.go @@ -2,6 +2,7 @@ package httpsling import ( "context" + "io" "net/http" ) @@ -28,11 +29,37 @@ func SendWithContext(ctx context.Context, opts ...Option) (*http.Response, error } // Receive uses the DefaultRequester to create a request, execute it, and read the response -func Receive(into interface{}, opts ...Option) (*http.Response, error) { +func Receive(into any, opts ...Option) (*http.Response, error) { return DefaultRequester.Receive(into, opts...) } // ReceiveWithContext does the same as Receive(), but attaches a Context to the request -func ReceiveWithContext(ctx context.Context, into interface{}, opts ...Option) (*http.Response, error) { +func ReceiveWithContext(ctx context.Context, into any, opts ...Option) (*http.Response, error) { return DefaultRequester.ReceiveWithContext(ctx, into, opts...) } + +// ReceiveInto builds, sends and unmarshals into a typed value using the DefaultRequester. +func ReceiveInto[T any](opts ...Option) (*http.Response, T, error) { + var out T + resp, err := DefaultRequester.ReceiveWithContext(context.Background(), &out, opts...) + + return resp, out, err +} + +// ReceiveIntoWithContext does the same as ReceiveInto but with a context. +func ReceiveIntoWithContext[T any](ctx context.Context, opts ...Option) (*http.Response, T, error) { + var out T + resp, err := DefaultRequester.ReceiveWithContext(ctx, &out, opts...) + + return resp, out, err +} + +// ReceiveTo streams the response body into the writer using the DefaultRequester. +func ReceiveTo(w io.Writer, opts ...Option) (*http.Response, int64, error) { + return DefaultRequester.ReceiveTo(context.Background(), w, opts...) +} + +// 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...) +} diff --git a/requester.go b/requester.go index f2075c2..77d6d82 100644 --- a/requester.go +++ b/requester.go @@ -17,7 +17,8 @@ type Requester struct { Method string // URL is the URL to request URL *url.URL - // Header supplies the request headers; if the Content-Type header is set here, it will override the Content-Type header supplied by the Marshaler + // Header supplies the request headers; note: if a Marshaler provides a Content-Type, + // it will override any Content-Type set here when building the http.Request Header http.Header // GetBody is a function that returns a ReadCloser for the request body GetBody func() (io.ReadCloser, error) @@ -35,7 +36,7 @@ type Requester struct { QueryParams url.Values // Body can be set to a string, []byte, io.Reader, or a struct; if set to a string, []byte, or io.Reader, the value will be used as the body of the request // If set to a struct, the Marshaler will be used to marshal the value into the request body - Body interface{} + Body any // Marshaler will be used to marshal the Body value into the body of the request. Marshaler Marshaler // Doer holds the HTTP client for used to execute httpsling @@ -92,7 +93,8 @@ func cloneValues(v url.Values) url.Values { v2 := make(url.Values, len(v)) for key, value := range v { - v2[key] = value + // deep copy slices to avoid aliasing + v2[key] = append([]string(nil), value...) } return v2 @@ -256,12 +258,12 @@ func (r *Requester) Do(req *http.Request) (*http.Response, error) { } // Receive creates a new HTTP request and returns the response -func (r *Requester) Receive(into interface{}, opts ...Option) (resp *http.Response, err error) { +func (r *Requester) Receive(into any, opts ...Option) (resp *http.Response, err error) { return r.ReceiveWithContext(context.Background(), into, opts...) } // ReceiveWithContext does the same as Receive, but requires a context -func (r *Requester) ReceiveWithContext(ctx context.Context, into interface{}, opts ...Option) (resp *http.Response, err error) { +func (r *Requester) ReceiveWithContext(ctx context.Context, into any, opts ...Option) (resp *http.Response, err error) { // if the first option is an Option, we need to copy those over and set into to nil if opt, ok := into.(Option); ok { opts = append(opts, nil) @@ -298,6 +300,15 @@ func (r *Requester) ReceiveWithContext(ctx context.Context, into interface{}, op err = unmarshaler.Unmarshal(body, resp.Header.Get(HeaderContentType), into) } + // return a shallow copy with a restored Body so callers can still read it, + // without mutating the original response reference that may be observed by + // middleware (e.g., retry tests expecting closed bodies in previous attempts) + if resp != nil { + respCopy := *resp + respCopy.Body = io.NopCloser(bytes.NewReader(body)) + return &respCopy, err + } + return resp, err } @@ -330,6 +341,37 @@ func readBody(resp *http.Response) ([]byte, error) { return buf.Bytes(), nil } +// ReceiveTo streams the response body to the provided writer without buffering +// the entire response in memory. It returns the response and the number of bytes +// copied. The response body is fully consumed and closed by this method. +func (r *Requester) ReceiveTo(ctx context.Context, w io.Writer, opts ...Option) (*http.Response, int64, error) { + reqs, err := r.withOpts(opts...) + if err != nil { + return nil, 0, err + } + + req, err := reqs.RequestWithContext(ctx) + if err != nil { + return nil, 0, err + } + + resp, err := reqs.Do(req) + if err != nil { + return resp, 0, err + } + + if resp == nil || resp.Body == nil || resp.Body == http.NoBody { + return resp, 0, nil + } + + defer resp.Body.Close() + n, copyErr := io.Copy(w, resp.Body) + + return resp, n, copyErr +} + +// Note: generic helpers exist at the package level (ReceiveInto, ReceiveIntoWithContext). + // Params returns the QueryParams func (r *Requester) Params() url.Values { if r.QueryParams == nil { diff --git a/requester_test.go b/requester_test.go index fb576d6..5bf23d0 100644 --- a/requester_test.go +++ b/requester_test.go @@ -220,11 +220,11 @@ func TestRequesterRequestBody(t *testing.T) { } func TestRequesterRequestMarshaler(t *testing.T) { - var capturedV interface{} + var capturedV any requester := Requester{ Body: []string{"blue"}, - Marshaler: MarshalFunc(func(v interface{}) ([]byte, string, error) { + Marshaler: MarshalFunc(func(v any) ([]byte, string, error) { capturedV = v return []byte("red"), "orange", nil }), @@ -242,7 +242,7 @@ func TestRequesterRequestMarshaler(t *testing.T) { require.Equal(t, "orange", req.Header.Get("Content-Type")) t.Run("errors", func(t *testing.T) { - requester.Marshaler = MarshalFunc(func(v interface{}) ([]byte, string, error) { + requester.Marshaler = MarshalFunc(func(v any) ([]byte, string, error) { return nil, "", errors.New("boom") // nolint: err113 }) @@ -270,6 +270,26 @@ func TestRequesterRequestContentLength(t *testing.T) { require.EqualValues(t, 10, req.ContentLength) } +func TestReceiveRestoresResponseBody(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(HeaderContentType, ContentTypeText) + w.WriteHeader(200) + w.Write([]byte("pong")) // nolint: errcheck + })) + defer ts.Close() + + var out string + resp, err := Receive(&out, Get(ts.URL)) + require.NoError(t, err) + require.NotNil(t, resp) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "pong", string(b)) + + resp.Body.Close() +} + func TestRequesterRequestGetBody(t *testing.T) { reqs, err := New(Body("1234")) require.NoError(t, err) @@ -463,7 +483,7 @@ func TestRequesterReceiveWithopts(t *testing.T) { resp, err := MustNew( Get(ts.URL, "/profile"), - UnmarshalFunc(func(data []byte, contentType string, v interface{}) error { + UnmarshalFunc(func(data []byte, contentType string, v any) error { called = true return nil }), @@ -495,7 +515,7 @@ func TestRequesterReceiveContext(t *testing.T) { t.Run("success", func(t *testing.T) { cases := []struct { - into interface{} + into any }{ {&testModel{}}, {nil}, @@ -797,7 +817,7 @@ func ExampleRequester_Request() { req, _ := r.Request( JSON(true), - Body(map[string]interface{}{"size": "big"}), + Body(map[string]any{"size": "big"}), ) fmt.Printf("%s %s %s\n", req.Method, req.URL.String(), req.Proto) diff --git a/response.go b/response.go index 65390e3..18cb290 100644 --- a/response.go +++ b/response.go @@ -4,7 +4,7 @@ import "net/http" // IsSuccess checks if the response status code indicates success func IsSuccess(resp *http.Response) bool { - code := resp.StatusCode + code := resp.StatusCode - return code >= http.StatusOK && code <= http.StatusIMUsed + return code >= http.StatusOK && code < 300 } diff --git a/retry.go b/retry.go index 8f07736..7bf032b 100644 --- a/retry.go +++ b/retry.go @@ -186,9 +186,9 @@ func Retry(config *RetryConfig) Middleware { return func(next Doer) Doer { return DoerFunc(func(req *http.Request) (*http.Response, error) { - if bodyEmpty(req) { - return next.Do(req) - } + if unreplayableBody(req) { + return next.Do(req) + } var ( resp *http.Response @@ -229,8 +229,10 @@ func Retry(config *RetryConfig) Middleware { } } -func bodyEmpty(req *http.Request) bool { - return req.Body != nil && req.Body != http.NoBody && req.GetBody == nil +// unreplayableBody indicates whether the request body cannot be replayed +// (i.e., non-nil, not http.NoBody, and GetBody is nil). +func unreplayableBody(req *http.Request) bool { + return req.Body != nil && req.Body != http.NoBody && req.GetBody == nil } type errCloser struct { From 4b5cdd4b5a1a130103768155b0715f7ee59a2c37 Mon Sep 17 00:00:00 2001 From: Matthew Anderson <42154938+matoszz@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:56:23 -0500 Subject: [PATCH 2/3] too soon i guess --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 63372ed..9409529 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/theopenlane/httpsling -go 1.25.2 +go 1.25.1 require ( github.com/felixge/httpsnoop v1.0.4 From d89260bd2256ec8495c1adbc591553413eb9b5f0 Mon Sep 17 00:00:00 2001 From: Matthew Anderson <42154938+matoszz@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:12:18 -0500 Subject: [PATCH 3/3] revert function removal --- options.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/options.go b/options.go index 9448d27..988cc4a 100644 --- a/options.go +++ b/options.go @@ -478,3 +478,30 @@ func WithMaxFileSize(i int64) Option { return nil }) } + +// WithValidationFunc allows you to set a function that can be used to perform validations +func WithValidationFunc(validationFunc ValidationFunc) Option { + return OptionFunc(func(r *Requester) error { + r.validationFunc = validationFunc + + return nil + }) +} + +// WithNameFuncGenerator allows you configure how you'd like to rename your uploaded files +func WithNameFuncGenerator(nameFunc NameGeneratorFunc) Option { + return OptionFunc(func(r *Requester) error { + r.fileNameFuncGenerator = nameFunc + + return nil + }) +} + +// WithFileErrorResponseHandler allows you to configure how you'd like to handle errors when a file upload fails either to your own server or the destination server or both +func WithFileErrorResponseHandler(errHandler ErrResponseHandler) Option { + return OptionFunc(func(r *Requester) error { + r.fileUploaderrorResponseHandler = errHandler + + return nil + }) +}