Documentation
¶
Overview ¶
Package httpreq is a small JSON-over-HTTP client convenience layer on top of net/http.
The standard library is fine but verbose for the most common service call: marshal a request body, set headers, send with a timeout, decode a JSON response, and turn non-2xx into an error. This package collapses that into a single options-pattern call:
var resp UserResponse
httpResp, err := httpreq.Do(ctx, "https://api/users",
httpreq.WithMethod(http.MethodPost),
httpreq.WithJSONBody(req),
httpreq.WithBearerToken(token),
httpreq.WithTimeout(5*time.Second),
httpreq.WithResponseInto(&resp),
)
Observability is built in and dependency-free: register a callback with WithObserver to receive a Trace (method, status, byte counts, duration, typed error) once per attempt, add WithConnTrace for DNS/TLS/TTFB phase timing via net/http/httptrace, or drop in SlogObserver for structured logging through log/slog. Traces carry metadata only — no bodies, no headers — so nothing sensitive leaks into your logs by accident.
Beyond the basics it covers common day-to-day needs without leaving the standard library: bearer and basic auth (WithBearerToken, WithBasicAuth), JSON / form / raw bodies (WithJSONBody, WithFormBody, WithRawBody), and flexible response handling — decode JSON (WithResponseInto), grab the raw bytes for non-JSON payloads (WithResponseBytes), stream large downloads to a writer (WithResponseWriter), or decode a structured error body on non-2xx (WithErrorInto). WithExpectStatus accepts extra status codes as success (e.g. 304), and WithRequest is an escape hatch to mutate or sign the built request before it is sent.
For debugging, render any request as a runnable curl command: WithCurl hands the string to a callback just before Do sends, Curl returns it as a value without sending, and RequestCurl renders an arbitrary *http.Request.
What's deliberately NOT here:
- Retries, circuit breaking, rate limiting. Use a dedicated transport or library for those concerns.
- GraphQL helpers, Shopify-specific extensions, etc. The lace.io ancestor of this package mixed those in; they have been removed.
- A global default client. Pass WithHTTPClient explicitly if you want to share connection pools.
Index ¶
- Constants
- func Curl(ctx context.Context, endpoint string, opts ...Option) (string, error)
- func Do(ctx context.Context, endpoint string, opts ...Option) (*http.Response, error)
- func RequestCurl(req *http.Request) (string, error)
- func SlogObserver(l *slog.Logger, level slog.Level) func(context.Context, Trace)
- type HTTPError
- type Option
- func WithBasicAuth(user, password string) Option
- func WithBearerToken(token string) Option
- func WithConnTrace() Option
- func WithCurl(fn func(curl string)) Option
- func WithErrorInto(v any) Option
- func WithExpectStatus(codes ...int) Option
- func WithFormBody(values url.Values) Option
- func WithHTTPClient(c *http.Client) Option
- func WithHeader(key, value string) Option
- func WithHeaders(h http.Header) Option
- func WithJSONBody(body any) Option
- func WithMethod(m string) Option
- func WithObserver(fn func(context.Context, Trace)) Option
- func WithQuery(q url.Values) Option
- func WithQueryParam(key, value string) Option
- func WithRawBody(body []byte) Option
- func WithRequest(fn func(*http.Request) error) Option
- func WithResponseBytes(b *[]byte) Option
- func WithResponseInto(v any) Option
- func WithResponseWriter(w io.Writer) Option
- func WithTimeout(d time.Duration) Option
- func WithUserAgent(ua string) Option
- type Trace
Examples ¶
Constants ¶
const DefaultUserAgent = "httpreq/" + Version
DefaultUserAgent is sent as the User-Agent header when the caller sets none. To suppress it entirely, set an empty User-Agent via WithUserAgent("").
const Version = "0.2.0"
Version is the package version, used only to build DefaultUserAgent. Bump it on release.
Variables ¶
This section is empty.
Functions ¶
func Curl ¶ added in v0.2.0
Curl builds the request from the given options and returns it rendered as a runnable `curl` command string, WITHOUT sending anything. It is the value form of WithCurl: use it to print a request for docs, a dry run, or a debug log, then send the same options through Do when ready.
SECURITY: the returned command contains the full request including the Authorization header and body — see WithCurl for the redaction note.
Example ¶
ExampleCurl renders a request as a runnable curl command without sending it — handy for logging or reproducing a call on the command line.
package main
import (
"context"
"fmt"
"net/http"
"github.com/ubgo/httpreq"
)
func main() {
cmd, err := httpreq.Curl(context.Background(), "https://api.example.com/users",
httpreq.WithMethod(http.MethodPost),
httpreq.WithHeader("Accept", "application/json"),
httpreq.WithJSONBody(map[string]string{"name": "alice"}),
)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(cmd)
}
Output: curl -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'User-Agent: httpreq/0.2.0' --data-raw '{"name":"alice"}' 'https://api.example.com/users'
func Do ¶
Do builds and sends the request. The returned *http.Response has its Body already drained and closed; the body bytes have been routed into the option supplied via WithResponseInto, if any.
Non-2xx responses return an *HTTPError so the caller can inspect the raw body. JSON decode errors are wrapped and returned.
Example ¶
ExampleDo shows the common case: POST a JSON body and decode the JSON response into a struct.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"alice"}`))
}))
defer srv.Close()
var out struct {
Name string `json:"name"`
}
_, err := httpreq.Do(context.Background(), srv.URL,
httpreq.WithMethod(http.MethodPost),
httpreq.WithJSONBody(map[string]int{"id": 1}),
httpreq.WithResponseInto(&out),
)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(out.Name)
}
Output: alice
func RequestCurl ¶ added in v0.2.0
RequestCurl renders any *http.Request as a runnable, copy-pasteable `curl` command string. Headers are emitted in sorted order for stable output; values are shell-quoted so the command survives special characters. The method is included as -X for anything other than GET.
The body is read via req.GetBody when available (as it is for requests built by this package), so the request's own body is left intact and the request stays sendable afterward. If GetBody is nil, req.Body is consumed and then restored with an equivalent reader.
SECURITY: the output reproduces the full request, secrets included. Redact before writing to a shared log.
Example ¶
ExampleRequestCurl renders a plain *http.Request you already hold — built with the standard library, not through httpreq — as a curl command.
package main
import (
"fmt"
"net/http"
"github.com/ubgo/httpreq"
)
func main() {
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/users", nil)
req.Header.Set("Accept", "application/json")
cmd, err := httpreq.RequestCurl(req)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(cmd)
}
Output: curl -H 'Accept: application/json' 'https://api.example.com/users'
func SlogObserver ¶ added in v0.2.0
SlogObserver returns an observer that logs each completed request through l at the given level. Failures (non-nil Trace.Err) are logged at slog.LevelError regardless of level. Only metadata is logged — never bodies or headers. If l is nil, slog.Default is used.
Wire it in with WithObserver:
httpreq.WithObserver(httpreq.SlogObserver(logger, slog.LevelInfo))
Example ¶
ExampleSlogObserver wires the batteries-included log/slog adapter. Failures log at ERROR regardless of the level passed. Output is not asserted here because log lines carry volatile timing fields.
package main
import (
"context"
"log/slog"
"os"
"github.com/ubgo/httpreq"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
_, _ = httpreq.Do(context.Background(), "https://api.example.com/health",
httpreq.WithObserver(httpreq.SlogObserver(logger, slog.LevelInfo)),
)
}
Output:
Types ¶
type HTTPError ¶
HTTPError is returned when the response status is outside 2xx. The raw response body is captured in Body so callers can decode their own error shape if needed.
Example ¶
ExampleHTTPError shows how to recover the status code and raw body from a non-2xx response via errors.As.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("missing"))
}))
defer srv.Close()
_, err := httpreq.Do(context.Background(), srv.URL)
var herr *httpreq.HTTPError
if errors.As(err, &herr) {
fmt.Printf("status=%d body=%s\n", herr.StatusCode, herr.Body)
}
}
Output: status=404 body=missing
type Option ¶
type Option func(*request) error
Option configures a single request.
func WithBasicAuth ¶ added in v0.2.0
WithBasicAuth sets `Authorization: Basic <base64(user:password)>`. It overwrites any Authorization set earlier (e.g. via WithBearerToken) — last auth option wins.
Example ¶
ExampleWithBasicAuth sends HTTP Basic credentials. It overrides any bearer token set earlier — the last auth option wins.
package main
import (
"context"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/private",
httpreq.WithBasicAuth("alice", "s3cret"),
)
}
Output:
func WithBearerToken ¶
WithBearerToken sets `Authorization: Bearer <token>`. No-op if the token is empty.
func WithConnTrace ¶ added in v0.2.0
func WithConnTrace() Option
WithConnTrace enables connection-phase timing via net/http/httptrace, populating the DNS/Connect/TLS/TTFB fields of the Trace passed to observers. It has a small per-request overhead and only matters when an observer is also registered. On a reused keep-alive connection the Connect and TLS fields stay zero because no new dial or handshake ran.
Example ¶
ExampleWithConnTrace adds connection-phase timing (DNS/Connect/TLS/TTFB) to the Trace. Connect and TLS stay zero on a reused keep-alive connection. Output is not asserted because timings vary per run.
package main
import (
"context"
"fmt"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/health",
httpreq.WithConnTrace(),
httpreq.WithObserver(func(_ context.Context, t httpreq.Trace) {
fmt.Printf("dns=%s connect=%s tls=%s ttfb=%s total=%s\n",
t.DNS, t.Connect, t.TLS, t.TTFB, t.Duration)
}),
)
}
Output:
func WithCurl ¶ added in v0.2.0
WithCurl registers a callback that receives the request rendered as a runnable `curl` command string, invoked once by Do immediately before the request is sent — so it fires even when the send later fails. Use it to log or print exactly what goes on the wire.
SECURITY: the rendered command reproduces the FULL request, including the Authorization header, cookies, and body. That is the point — it is a faithful dump — but it means secrets appear in whatever you do with the string. Redact before logging to a shared sink. A nil fn is ignored.
To obtain the curl string as a value without sending, use Curl; to render an arbitrary request you already hold, use RequestCurl.
Example ¶
ExampleWithCurl logs the exact request Do sends, right before it goes out.
package main
import (
"context"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/health",
httpreq.WithCurl(func(cmd string) {
// print it, log it, whatever you want
_ = cmd
}),
)
}
Output:
func WithErrorInto ¶ added in v0.2.0
WithErrorInto JSON-decodes the response body into v when the status is non-2xx (i.e. when an HTTPError is returned), letting you read a structured error payload. v must be a pointer. The HTTPError is still returned; if the error body is not valid JSON, v is left unchanged and no extra error is raised (inspect HTTPError.Body for the raw bytes).
Example ¶
ExampleWithErrorInto decodes a structured error payload from a non-2xx response while still returning the *HTTPError.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"invalid_field","message":"name is required"}`))
}))
defer srv.Close()
var apiErr struct {
Code string `json:"code"`
Message string `json:"message"`
}
_, err := httpreq.Do(context.Background(), srv.URL, httpreq.WithErrorInto(&apiErr))
var herr *httpreq.HTTPError
if errors.As(err, &herr) {
fmt.Printf("%d %s: %s\n", herr.StatusCode, apiErr.Code, apiErr.Message)
}
}
Output: 400 invalid_field: name is required
func WithExpectStatus ¶ added in v0.2.0
WithExpectStatus marks additional status codes as success, so they return a nil error instead of an HTTPError and their body is decoded/streamed like any 2xx. The classic case is 304 Not Modified with conditional requests, or a 3xx you handle yourself. Codes already in 2xx need not be listed.
Example ¶
ExampleWithExpectStatus treats 304 Not Modified as success instead of an error, for conditional requests.
package main
import (
"context"
"net/http"
"github.com/ubgo/httpreq"
)
func main() {
_, err := httpreq.Do(context.Background(), "https://api.example.com/resource",
httpreq.WithHeader("If-None-Match", `"etag-value"`),
httpreq.WithExpectStatus(http.StatusNotModified),
)
// err is nil on a 304 now, instead of an *HTTPError.
_ = err
}
Output:
func WithFormBody ¶ added in v0.2.0
WithFormBody URL-encodes values and sends them as an application/x-www-form-urlencoded body — the shape OAuth token endpoints and classic HTML form posts expect. Pass nil to clear a previously set body.
Example ¶
ExampleWithFormBody posts application/x-www-form-urlencoded values, the shape OAuth token endpoints expect.
package main
import (
"context"
"net/http"
"net/url"
"github.com/ubgo/httpreq"
)
func main() {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("scope", "read")
_, _ = httpreq.Do(context.Background(), "https://auth.example.com/token",
httpreq.WithMethod(http.MethodPost),
httpreq.WithBasicAuth("client-id", "client-secret"),
httpreq.WithFormBody(form),
)
}
Output:
func WithHTTPClient ¶
WithHTTPClient overrides the underlying *http.Client. Use this to share a connection pool across requests or to install custom middleware via a transport. WithTimeout still applies — it sets Client.Timeout if the supplied client has none.
func WithHeader ¶
WithHeader sets a single header. Repeat the option to set multiple. To set repeated values for the same header (e.g. multi-value Accept), call it once per value.
func WithHeaders ¶ added in v0.2.0
WithHeaders adds every key/value in h to the request, appending to any headers already set (same semantics as calling WithHeader once per value). A nil or empty h is a no-op.
Example ¶
ExampleWithHeaders and ExampleWithQuery add several headers or query params at once, appending to anything already set.
package main
import (
"context"
"net/http"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/x",
httpreq.WithHeaders(http.Header{
"Accept": {"application/json"},
"X-Request-Id": {"abc-123"},
"Accept-Language": {"en-US"},
}),
)
}
Output:
func WithJSONBody ¶
WithJSONBody marshals body as JSON and sets Content-Type: application/json. Pass nil to clear a previously set body.
func WithMethod ¶
WithMethod sets the HTTP method. Default: GET. Methods are not validated against [http.Method*] constants — the request goes out as you specify it.
func WithObserver ¶ added in v0.2.0
WithObserver registers a callback invoked exactly once when the request attempt completes — on success and on every failure path. See Trace for what is delivered (metadata only; no bodies or headers). Repeat the option to register multiple observers; all are called in registration order.
The callback runs synchronously on the calling goroutine after the body has been read, so keep it fast: record a metric, emit a log line, annotate a span. Do not block on I/O inside it. A nil fn is ignored.
Example ¶
ExampleWithObserver registers an observer that receives one Trace per request attempt. The Trace carries metadata only — never bodies or headers.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
_, _ = httpreq.Do(context.Background(), srv.URL,
httpreq.WithObserver(func(_ context.Context, t httpreq.Trace) {
fmt.Printf("%s -> %d\n", t.Method, t.StatusCode)
}),
)
}
Output: GET -> 204
func WithQuery ¶ added in v0.2.0
WithQuery adds every key/value in q to the URL query string, appending to any parameters already set (same semantics as calling WithQueryParam once per value). A nil or empty q is a no-op.
Example ¶
package main
import (
"context"
"net/url"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/search",
httpreq.WithQuery(url.Values{
"q": {"golang"},
"page": {"2"},
"tag": {"http", "client"},
}),
)
}
Output:
func WithQueryParam ¶
WithQueryParam appends a URL query parameter. Repeat for multiple values of the same key.
func WithRawBody ¶
WithRawBody sends body bytes verbatim. Caller must set Content-Type via WithHeader if it matters. Useful for form posts, protobuf, or pre-encoded payloads.
func WithRequest ¶ added in v0.2.0
WithRequest registers a hook that receives the fully built *http.Request after all other options are applied and just before it is sent, letting you set anything the options don't model: request signing (an auth header computed over the assembled request), fields net/http keeps off the header map (http.Request.Host, cookies via http.Request.AddCookie, http.Request.Close, http.Request.Trailer), or any one-off tweak.
Returning an error aborts Do (and Curl) before sending, wrapped as "httpreq: request hook". Repeat the option to chain hooks; they run in order. The hook also runs for Curl/WithCurl, so a curl dump reflects the final, mutated request. A nil hook is ignored.
This is an escape hatch — with it comes the ability to build an invalid request. Prefer a dedicated option when one exists.
Example ¶
ExampleWithRequest signs the fully built request just before it is sent — the escape hatch for anything the options don't model.
package main
import (
"context"
"net/http"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/x",
httpreq.WithRequest(func(req *http.Request) error {
// Set fields net/http keeps off the header map, or sign the request.
req.Host = "internal.service"
req.Header.Set("X-Signature", "computed-over-the-request")
return nil
}),
)
}
Output:
func WithResponseBytes ¶ added in v0.2.0
WithResponseBytes captures the raw response body into *b, regardless of status and regardless of whether WithResponseInto is also set. Use it for non-JSON responses (HTML, text, XML, binary) or when you need the exact bytes alongside a decoded value. On an empty response *b is set to an empty, non-nil slice.
Example ¶
ExampleWithResponseBytes captures a non-JSON response body verbatim — useful for HTML, text, XML, or binary payloads that WithResponseInto can't decode.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<h1>hello</h1>"))
}))
defer srv.Close()
var raw []byte
if _, err := httpreq.Do(context.Background(), srv.URL, httpreq.WithResponseBytes(&raw)); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(raw))
}
Output: <h1>hello</h1>
func WithResponseInto ¶
WithResponseInto JSON-decodes the response body into v. v must be a pointer. Skip this option to discard the body.
func WithResponseWriter ¶ added in v0.2.0
WithResponseWriter streams a successful response body straight to w via io.Copy instead of buffering it in memory — the way to handle downloads or responses larger than RAM. Compose sinks with io.MultiWriter (e.g. write to a file and a hash at once).
It applies only to success responses (status < 300, or a code allowed by WithExpectStatus). Error responses are still buffered so the HTTPError keeps the raw body. Because the body is streamed, not held, this option is mutually exclusive with WithResponseInto and WithResponseBytes on the same call — the writer takes precedence and those are not populated. A nil w is ignored (the body is buffered as usual).
Example ¶
ExampleWithResponseWriter streams the response body to a writer instead of buffering it in memory — the way to download large files. Compose sinks with io.MultiWriter to, e.g., write to a file and hash in one pass.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"github.com/ubgo/httpreq"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("streamed payload"))
}))
defer srv.Close()
// Stream straight to stdout (use an *os.File for a real download).
_, err := httpreq.Do(context.Background(), srv.URL, httpreq.WithResponseWriter(os.Stdout))
if err != nil {
fmt.Println("error:", err)
return
}
}
Output: streamed payload
func WithTimeout ¶
WithTimeout caps the total request time including dialing, headers, body, and response reading. Default: 30 seconds. Pass 0 to use the caller's context deadline alone.
func WithUserAgent ¶ added in v0.2.0
WithUserAgent sets the User-Agent header. Pass an empty string to suppress the User-Agent entirely (no header sent). When this option is not used, DefaultUserAgent is sent.
Example ¶
ExampleWithUserAgent sets a custom User-Agent. Without this option, httpreq.DefaultUserAgent is sent; pass "" to suppress the header entirely.
package main
import (
"context"
"github.com/ubgo/httpreq"
)
func main() {
_, _ = httpreq.Do(context.Background(), "https://api.example.com/x",
httpreq.WithUserAgent("my-service/1.4.2"),
)
}
Output:
type Trace ¶ added in v0.2.0
type Trace struct {
Method string
URL string // final URL including query params
ReqBytes int // request body size; 0 for bodyless or streamed bodies
RespBytes int // response body size actually read
StatusCode int // 0 if the request never got a response
Duration time.Duration // wall time around send + full body read
Err error // nil on success; network err, *HTTPError, or decode err
Attempt int // 1 today; reserved for a future retry feature
// Connection-phase timings, set only with WithConnTrace(). See the type
// doc for the keep-alive caveat.
DNS time.Duration
Connect time.Duration
TLS time.Duration
TTFB time.Duration // start of send to first response byte
}
Trace is metadata about one completed request attempt, delivered to the callbacks registered via WithObserver. It fires once per attempt on every path — success, non-2xx (HTTPError), network failure, and decode failure — so it is a complete lifecycle signal for logs, metrics, and spans.
SHAPE CONTRACT: Trace carries metadata ONLY. It never contains request or response bodies and never contains headers (so no Authorization / cookies / API keys leak into your logs by accident). If you need header or body content in your observability, install a custom http.RoundTripper via WithHTTPClient where you own the redaction policy.
The DNS/Connect/TLS/TTFB fields are populated only when WithConnTrace is set; otherwise they are zero. Connect and TLS are zero on a reused keep-alive connection (no new dial/handshake happened).