httpx
packageAPI reference for the httpx
package.
Imports
(25)crypto/ecdsa
STD
crypto/elliptic
STD
crypto/rand
STD
crypto/x509
STD
crypto/x509/pkix
STD
encoding/pem
STD
math/big
STD
os
STD
path/filepath
STD
testing
STD
time
STD
crypto/tls
STD
fmt
STD
net
STD
net/http
STD
sort
STD
strings
STD
sync
INT
github.com/mirkobrombin/go-foundation/v2/core/options
STD
net/http/httptest
STD
reflect
INT
github.com/mirkobrombin/go-foundation/v2/core/resiliency
INT
github.com/mirkobrombin/go-foundation/v2/core/contracts
STD
log/slog
STD
net/url
writeSelfSigned
writeSelfSigned generates a throwaway self-signed certificate for host and
writes cert and key PEM files into the test temp dir.
Parameters
Returns
func writeSelfSigned(t *testing.T, host string) (certFile, keyFile string)
{
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: host},
DNSNames: []string{host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
certFile = filepath.Join(dir, "cert.pem")
keyFile = filepath.Join(dir, "key.pem")
certOut, _ := os.Create(certFile)
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyOut, _ := os.Create(keyFile)
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
t.Fatal(err)
}
return certFile, keyFile
}
Hooks
Hooks is the request lifecycle extension contract.
type Hooks interface
Example
type Auth struct{ ... }
func (a Auth) BeforeRequest(r *http.Request) { ... }
func (a Auth) HandleRequest(w, r) bool { ... }
func (a Auth) AfterRequest(w, r) { ... }
Methods
VirtualHost
VirtualHost binds one host name to a handler and its lifecycle hooks.
Host matching is case-insensitive and ignores the request port: a virtual
host registered as “example.com” answers “example.com”, “EXAMPLE.com” and
“example.com:8443”.
type VirtualHost struct
Fields
| Name | Type | Description |
|---|---|---|
| Host | string | |
| Handler | http.Handler | |
| Hooks | []Hooks |
VHostMux
VHostMux dispatches requests to virtual hosts by Host header. It is the
counterpart of a path router for the multi-tenant shape: instead of
path-based routes for one application, it routes host names to independent
handlers in one process.
type VHostMux struct
Methods
AddVirtualHost registers a virtual host. Registering the same host twice replaces the previous one. Host is normalized (lowercased, port stripped).
Parameters
func (*VHostMux) AddVirtualHost(vh VirtualHost)
{
m.mu.Lock()
defer m.mu.Unlock()
m.hosts[normalizeHost(vh.Host)] = &vh
}
RemoveVirtualHost unregisters the virtual host for host, returning whether one existed.
Parameters
Returns
func (*VHostMux) RemoveVirtualHost(host string) bool
{
m.mu.Lock()
defer m.mu.Unlock()
key := normalizeHost(host)
if _, ok := m.hosts[key]; !ok {
return false
}
delete(m.hosts, key)
return true
}
Hosts returns the sorted list of registered host names.
Returns
func (*VHostMux) Hosts() []string
{
m.mu.RLock()
defer m.mu.RUnlock()
hosts := make([]string, 0, len(m.hosts))
for h := range m.hosts {
hosts = append(hosts, h)
}
sort.Strings(hosts)
return hosts
}
ServeHTTP implements http.Handler.
Parameters
func (*VHostMux) ServeHTTP(w http.ResponseWriter, r *http.Request)
{
m.mu.RLock()
vh, ok := m.hosts[normalizeHost(r.Host)]
fallback := m.fallback
m.mu.RUnlock()
if !ok {
if fallback != nil {
fallback.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
return
}
for _, h := range vh.Hooks {
h.BeforeRequest(r)
}
handled := false
for _, h := range vh.Hooks {
if h.HandleRequest(w, r) {
handled = true
break
}
}
if !handled && vh.Handler != nil {
vh.Handler.ServeHTTP(w, r)
}
for i := len(vh.Hooks) - 1; i >= 0; i-- {
vh.Hooks[i].AfterRequest(w, r)
}
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.RWMutex | |
| hosts | map[string]*VirtualHost | |
| fallback | http.Handler |
Option
Option configures a VHostMux.
type Option options.Option[VHostMux]
WithFallback
WithFallback sets the handler used when no virtual host matches the request
host. Without a fallback, unmatched hosts get 404.
Parameters
Returns
func WithFallback(h http.Handler) Option
{
return func(m *VHostMux) { m.fallback = h }
}
Uses
NewVHostMux
NewVHostMux creates an empty mux.
Parameters
Returns
func NewVHostMux(opts ...Option) *VHostMux
{
m := &VHostMux{hosts: make(map[string]*VirtualHost)}
for _, opt := range opts {
opt(m)
}
return m
}
normalizeHost
normalizeHost lowercases and strips any port from a host value.
Parameters
Returns
func normalizeHost(host string) string
{
host = strings.ToLower(strings.TrimSpace(host))
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
CertResolver
CertResolver maps host names to TLS key pairs, providing SNI-based
certificate selection for a listener that terminates TLS for many host
names.
type CertResolver struct
Methods
Add loads a certificate and key from disk and registers them for host. Host is normalized the same way as VHostMux hosts.
Parameters
Returns
func (*CertResolver) Add(host, certFile, keyFile string) error
{
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("httpx: certificate for %s: %w", host, err)
}
r.mu.Lock()
defer r.mu.Unlock()
r.certs[normalizeHost(host)] = &cert
return nil
}
Remove drops the certificate for host, returning whether one existed.
Parameters
Returns
func (*CertResolver) Remove(host string) bool
{
r.mu.Lock()
defer r.mu.Unlock()
key := normalizeHost(host)
if _, ok := r.certs[key]; !ok {
return false
}
delete(r.certs, key)
return true
}
GetCertificate implements the tls.Config.GetCertificate callback: it returns the certificate registered for the SNI server name, or nil (which fails the handshake) when no host name matches.
Parameters
Returns
func (*CertResolver) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
{
r.mu.RLock()
defer r.mu.RUnlock()
cert, ok := r.certs[normalizeHost(hello.ServerName)]
if !ok {
return nil, fmt.Errorf("httpx: no certificate for %q", hello.ServerName)
}
return cert, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.RWMutex | |
| certs | map[string]*tls.Certificate |
NewCertResolver
NewCertResolver creates an empty resolver.
Returns
func NewCertResolver() *CertResolver
{
return &CertResolver{certs: make(map[string]*tls.Certificate)}
}
recordingHooks
type recordingHooks struct
Methods
Parameters
func (recordingHooks) BeforeRequest(r *http.Request)
{
*h.events = append(*h.events, h.name+":before")
}
Parameters
Returns
func (recordingHooks) HandleRequest(w http.ResponseWriter, r *http.Request) bool
{
*h.events = append(*h.events, h.name+":handle")
return h.handle
}
Parameters
func (recordingHooks) AfterRequest(w http.ResponseWriter, r *http.Request)
{
*h.events = append(*h.events, h.name+":after")
}
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| handle | bool | |
| events | *[]string |
TestVHostMuxDispatchesByHostIgnoringCaseAndPort
Parameters
func TestVHostMuxDispatchesByHostIgnoringCaseAndPort(t *testing.T)
{
mux := NewVHostMux()
called := false
mux.AddVirtualHost(VirtualHost{
Host: "Example.COM",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
}),
})
for _, host := range []string{"example.com", "EXAMPLE.com:8443"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = host
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot {
t.Errorf("host %q: got %d, want %d", host, rec.Code, http.StatusTeapot)
}
}
if !called {
t.Error("virtual host handler was not called")
}
}
TestVHostMuxUnknownHost
Parameters
func TestVHostMuxUnknownHost(t *testing.T)
{
mux := NewVHostMux()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "unknown.example"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
mux = NewVHostMux(WithFallback(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
})))
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("fallback: got %d, want 502", rec.Code)
}
}
TestVHostMuxHookOrderAndShortCircuit
Parameters
func TestVHostMuxHookOrderAndShortCircuit(t *testing.T)
{
var events []string
mux := NewVHostMux()
mux.AddVirtualHost(VirtualHost{
Host: "a.example",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
events = append(events, "vhost:handler")
}),
Hooks: []Hooks{
recordingHooks{name: "first", handle: true, events: &events},
recordingHooks{name: "second", events: &events},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "a.example"
mux.ServeHTTP(rec, req)
want := []string{
"first:before", "second:before",
"first:handle", // short-circuits: no second:handle, no site:handler
"second:after", "first:after", // reverse order
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("got %v, want %v", events, want)
}
}
TestVHostMuxHooksPassThroughToHandler
Parameters
func TestVHostMuxHooksPassThroughToHandler(t *testing.T)
{
var events []string
mux := NewVHostMux()
mux.AddVirtualHost(VirtualHost{
Host: "a.example",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
events = append(events, "vhost:handler")
}),
Hooks: []Hooks{recordingHooks{name: "p", events: &events}},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "a.example"
mux.ServeHTTP(rec, req)
want := []string{"p:before", "p:handle", "vhost:handler", "p:after"}
if !reflect.DeepEqual(events, want) {
t.Fatalf("got %v, want %v", events, want)
}
}
TestVHostMuxRemoveSite
Parameters
func TestVHostMuxRemoveSite(t *testing.T)
{
mux := NewVHostMux()
mux.AddVirtualHost(VirtualHost{Host: "a.example", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})})
if got := mux.Hosts(); !reflect.DeepEqual(got, []string{"a.example"}) {
t.Fatalf("hosts: %v", got)
}
if !mux.RemoveVirtualHost("A.example:443") {
t.Fatal("RemoveVirtualHost should report removal with normalized host")
}
if mux.RemoveVirtualHost("a.example") {
t.Fatal("RemoveVirtualHost should report false for unknown host")
}
}
TestCertResolverSNI
Parameters
func TestCertResolverSNI(t *testing.T)
{
certFile, keyFile := writeSelfSigned(t, "a.example")
r := NewCertResolver()
if err := r.Add("A.example", certFile, keyFile); err != nil {
t.Fatal(err)
}
cert, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "a.example"})
if err != nil || cert == nil {
t.Fatalf("SNI lookup failed: %v", err)
}
if _, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example"}); err == nil {
t.Fatal("expected error for unknown SNI name")
}
if !r.Remove("a.example") {
t.Fatal("expected Remove to succeed")
}
if _, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "a.example"}); err == nil {
t.Fatal("expected error after removal")
}
}
TestCertResolverRejectsBadPair
Parameters
func TestCertResolverRejectsBadPair(t *testing.T)
{
r := NewCertResolver()
if err := r.Add("a.example", "/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
t.Fatal("expected error for missing files")
} else if got := err.Error(); !containsSub(got, "a.example") {
t.Errorf("error %q does not name the host", got)
}
}
containsSub
Parameters
Returns
func containsSub(s, sub string) bool
{
return len(sub) == 0 || (len(s) >= len(sub) && fmt.Sprintf("%s", s) != "" && indexOf(s, sub) >= 0)
}
indexOf
Parameters
Returns
func indexOf(s, sub string) int
{
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
Middleware
Middleware wraps an http.RoundTripper.
type Middleware func(next http.RoundTripper) http.RoundTripper
Client
Client is an extensible HTTP client with middleware and optional resiliency hooks.
type Client struct
Methods
WithRetry attaches retry options to the client.
Parameters
Returns
func (*Client) WithRetry(opts ...func(*resiliency.RetryOptions)) *Client
{
c.retryOpts = opts
return c
}
WithBreaker attaches a circuit breaker to the client.
Parameters
Returns
func (*Client) WithBreaker(b *resiliency.CircuitBreaker) *Client
{
c.breaker = b
return c
}
transport returns the cached composed middleware chain, building it on first use.
Returns
func (*Client) transport() http.RoundTripper
{
c.buildOnce.Do(func() {
rt := c.rt
for i := len(c.mw) - 1; i >= 0; i-- {
rt = c.mw[i](rt)
}
c.builtTransport = rt
})
return c.builtTransport
}
Do sends a request applying middleware and optional resiliency behaviors. When retry options are set, requests with a body must provide req.GetBody so the body can be re-read on each attempt. Requests without a body (e.g. GET) are retried unconditionally.
Parameters
Returns
func (*Client) Do(req *http.Request) (*http.Response, error)
{
if len(c.retryOpts) > 0 && req.Body != nil && req.GetBody == nil {
return nil, fmt.Errorf("httpx: retry requires req.GetBody to be set when request has a body")
}
rt := c.transport()
var resp *http.Response
attemptNumber := 0
fn := func() error {
// Clone the request and reset the body for each attempt so retries
// send the complete payload rather than an already-consumed reader.
attempt := req.Clone(req.Context())
if attemptNumber > 0 && req.GetBody != nil {
body, err := req.GetBody()
if err != nil {
return err
}
attempt.Body = body
}
attemptNumber++
client := *c.client
client.Transport = rt
r, err := client.Do(attempt)
if err != nil && r != nil && r.Body != nil {
_ = r.Body.Close()
r = nil
}
resp = r
return err
}
var err error
if c.breaker != nil {
if len(c.retryOpts) > 0 {
err = c.breaker.Execute(func() error {
return resiliency.Retry(req.Context(), fn, c.retryOpts...)
})
} else {
err = c.breaker.Execute(fn)
}
return resp, err
}
if len(c.retryOpts) > 0 {
err = resiliency.Retry(req.Context(), fn, c.retryOpts...)
return resp, err
}
err = fn()
return resp, err
}
Fields
| Name | Type | Description |
|---|---|---|
| client | *http.Client | |
| rt | http.RoundTripper | |
| mw | []Middleware | |
| retryOpts | []func(*resiliency.RetryOptions) | |
| breaker | *resiliency.CircuitBreaker | |
| builtTransport | http.RoundTripper | |
| buildOnce | sync.Once |
New
New creates a new Client wrapping c (or a default client with 15s timeout if nil).
Middleware options (WithRetry, WithBreaker) must be configured before the first
call to Do; the transport chain is assembled once on first use and cannot be
modified afterward.
Parameters
Returns
func New(c *http.Client, mw ...Middleware) *Client
{
if c == nil {
c = &http.Client{Timeout: 15 * time.Second}
}
rt := c.Transport
if rt == nil {
rt = http.DefaultTransport
}
return &Client{client: c, rt: rt, mw: mw}
}
roundTripperFunc
type roundTripperFunc func(*http.Request) (*http.Response, error)
cloneRequest
Parameters
Returns
func cloneRequest(r *http.Request) *http.Request
{
cloned := r.Clone(r.Context())
cloned.Header = make(http.Header, len(r.Header))
for k, v := range r.Header {
cloned.Header[k] = append([]string(nil), v...)
}
return cloned
}
Header
Header returns a Middleware that sets the specified header on every request.
Parameters
Returns
func Header(key, value string) Middleware
{
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
if !sameOrigin(r.URL, originalRequestURL(r)) {
return next.RoundTrip(r)
}
cloned := cloneRequest(r)
cloned.Header.Set(key, value)
return next.RoundTrip(cloned)
})
}
}
Uses
originalRequestURL
Parameters
Returns
func originalRequestURL(request *http.Request) *url.URL
{
if request == nil {
return nil
}
original := request.URL
for response := request.Response; response != nil && response.Request != nil; response = response.Request.Response {
original = response.Request.URL
}
return original
}
Logging
Logging returns a Middleware that logs each request and its duration.
Parameters
Returns
func Logging(logger *slog.Logger) Middleware
{
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := next.RoundTrip(r)
dur := time.Since(start)
requestURL := safeLogURL(r.URL)
if err != nil {
logger.Error("http request failed", "method", r.Method, "url", requestURL, "duration", dur, "error", err)
} else {
logger.Info("http request", "method", r.Method, "url", requestURL, "status", resp.StatusCode, "duration", dur)
}
return resp, err
})
}
}
Uses
safeLogURL
Parameters
Returns
func safeLogURL(value *url.URL) string
{
if value == nil {
return ""
}
safe := *value
safe.User = nil
safe.RawQuery = ""
safe.ForceQuery = false
safe.Fragment = ""
return safe.String()
}
RequestID
RequestID returns a Middleware that injects a unique request ID into the given header if absent.
Parameters
Returns
func RequestID(header string) Middleware
{
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
if r.Header.Get(header) == "" {
cloned := cloneRequest(r)
cloned.Header.Set(header, newRequestID())
return next.RoundTrip(cloned)
}
return next.RoundTrip(r)
})
}
}
Uses
newRequestID
Returns
func newRequestID() string
{
return time.Now().Format("20060102150405.000000")
}