diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4ad3fef..e756293 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.18.0" + ".": "0.19.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index f729551..fb86afe 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 52 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-c6da5deb317c83b7a10434593eb22ec7cb27009aba0b92efaefbbe21884054ad.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/hypeman-75aa32bfceac1a349267baf08a13df9d8dc37fd07525f45675d064654eac0e1f.yml openapi_spec_hash: ff73a0e1f7a8bd5a5d1ae38d994bb9cd config_hash: ed668fae8826ff533f38df16c9664f44 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1327172..8f08cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.19.0 (2026-05-08) + +Full Changelog: [v0.18.0...v0.19.0](https://github.com/kernel/hypeman-go/compare/v0.18.0...v0.19.0) + +### Features + +* **go:** add default http client with timeout ([4562f58](https://github.com/kernel/hypeman-go/commit/4562f586a6f26caebf5908b9e899cd578a3af4c1)) +* support setting headers via env ([3b6d6cd](https://github.com/kernel/hypeman-go/commit/3b6d6cd3ff96bef7d0f01e58116ca74eef5c8e1c)) + + +### Bug Fixes + +* **go:** avoid panic when http.DefaultTransport is wrapped ([90abdb2](https://github.com/kernel/hypeman-go/commit/90abdb2fe804de224236e5a0556b5675afb49c09)) + + +### Chores + +* avoid embedding reflect.Type for dead code elimination ([768a2a1](https://github.com/kernel/hypeman-go/commit/768a2a12c13728f05946bd4c0f24e5f3a90f8620)) +* **internal:** more robust bootstrap script ([53ef042](https://github.com/kernel/hypeman-go/commit/53ef042e66ce7e050877be0e08613c41e66026de)) +* redact api-key headers in debug logs ([0f70339](https://github.com/kernel/hypeman-go/commit/0f703395ab4135be91ac30c47ed94bf2e61989b5)) + ## 0.18.0 (2026-04-17) Full Changelog: [v0.17.0...v0.18.0](https://github.com/kernel/hypeman-go/compare/v0.17.0...v0.18.0) diff --git a/README.md b/README.md index 5fd1408..cc2ac62 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Or to pin the version: ```sh -go get -u 'github.com/kernel/hypeman-go@v0.18.0' +go get -u 'github.com/kernel/hypeman-go@v0.19.0' ``` diff --git a/client.go b/client.go index 6105b35..acf25cf 100644 --- a/client.go +++ b/client.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "slices" + "strings" "github.com/kernel/hypeman-go/internal/requestconfig" "github.com/kernel/hypeman-go/option" @@ -31,13 +32,21 @@ type Client struct { // DefaultClientOptions read from the environment (HYPEMAN_API_KEY, // HYPEMAN_BASE_URL). This should be used to initialize new clients. func DefaultClientOptions() []option.RequestOption { - defaults := []option.RequestOption{option.WithEnvironmentProduction()} + defaults := []option.RequestOption{option.WithHTTPClient(defaultHTTPClient()), option.WithEnvironmentProduction()} if o, ok := os.LookupEnv("HYPEMAN_BASE_URL"); ok { defaults = append(defaults, option.WithBaseURL(o)) } if o, ok := os.LookupEnv("HYPEMAN_API_KEY"); ok { defaults = append(defaults, option.WithAPIKey(o)) } + if o, ok := os.LookupEnv("HYPEMAN_CUSTOM_HEADERS"); ok { + for _, line := range strings.Split(o, "\n") { + colon := strings.Index(line, ":") + if colon >= 0 { + defaults = append(defaults, option.WithHeader(strings.TrimSpace(line[:colon]), strings.TrimSpace(line[colon+1:]))) + } + } + } return defaults } diff --git a/default_http_client.go b/default_http_client.go new file mode 100644 index 0000000..bfda19f --- /dev/null +++ b/default_http_client.go @@ -0,0 +1,30 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman + +import ( + "net/http" + "time" +) + +// defaultResponseHeaderTimeout bounds the time between a fully written request +// and the server's response headers. It does not apply to the response body, +// so long-running streams are unaffected. Without this, a server that accepts +// the connection but never responds would hang the request indefinitely. +const defaultResponseHeaderTimeout = 10 * time.Minute + +// defaultHTTPClient returns an [*http.Client] used when the caller does not +// supply one via [option.WithHTTPClient]. When [http.DefaultTransport] is the +// stdlib [*http.Transport], it is cloned and a [http.Transport.ResponseHeaderTimeout] +// is set so stuck connections fail fast instead of compounding across retries. +// If [http.DefaultTransport] has been wrapped (for example by otelhttp for +// distributed tracing), the wrapping is preserved and the header timeout is +// skipped. +func defaultHTTPClient() *http.Client { + if t, ok := http.DefaultTransport.(*http.Transport); ok { + t = t.Clone() + t.ResponseHeaderTimeout = defaultResponseHeaderTimeout + return &http.Client{Transport: t} + } + return &http.Client{Transport: http.DefaultTransport} +} diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index fd5efd2..edaf378 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -58,7 +58,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string arrayFmt string root bool @@ -76,7 +76,7 @@ func (e *encoder) marshal(value any, writer *multipart.Writer) error { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, arrayFmt: e.arrayFmt, root: e.root, diff --git a/internal/apijson/decoder.go b/internal/apijson/decoder.go index 0225b9f..b6b9332 100644 --- a/internal/apijson/decoder.go +++ b/internal/apijson/decoder.go @@ -80,7 +80,7 @@ type decoderField struct { } type decoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool } @@ -108,7 +108,7 @@ func (d *decoderBuilder) unmarshalWithExactness(raw []byte, to any) (exactness, func (d *decoderBuilder) typeDecoder(t reflect.Type) decoderFunc { entry := decoderEntry{ - Type: t, + typ: t, dateFormat: d.dateFormat, root: d.root, } diff --git a/internal/apijson/encoder.go b/internal/apijson/encoder.go index bf61641..fc38322 100644 --- a/internal/apijson/encoder.go +++ b/internal/apijson/encoder.go @@ -46,7 +46,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool } @@ -63,7 +63,7 @@ func (e *encoder) marshal(value any) ([]byte, error) { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, root: e.root, } diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go index ac31823..ef4114c 100644 --- a/internal/apiquery/encoder.go +++ b/internal/apiquery/encoder.go @@ -29,7 +29,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool settings QuerySettings @@ -42,7 +42,7 @@ type Pair struct { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, root: e.root, settings: e.settings, diff --git a/internal/version.go b/internal/version.go index 8dc40e7..1117f72 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.18.0" // x-release-please-version +const PackageVersion = "0.19.0" // x-release-please-version diff --git a/option/middleware.go b/option/middleware.go index 8ec9dd6..4be0987 100644 --- a/option/middleware.go +++ b/option/middleware.go @@ -8,6 +8,10 @@ import ( "net/http/httputil" ) +// sensitiveLogHeaders are redacted before request and response content is +// written to the debug logger. +var sensitiveLogHeaders = []string{"authorization", "api-key", "x-api-key", "cookie", "set-cookie"} + // WithDebugLog logs the HTTP request and response content. // If the logger parameter is nil, it uses the default logger. // @@ -20,7 +24,7 @@ func WithDebugLog(logger *log.Logger) RequestOption { logger = log.Default() } - if reqBytes, err := httputil.DumpRequest(req, true); err == nil { + if reqBytes, err := dumpRedactedRequest(req); err == nil { logger.Printf("Request Content:\n%s\n", reqBytes) } @@ -29,10 +33,48 @@ func WithDebugLog(logger *log.Logger) RequestOption { return resp, err } - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { + if respBytes, err := dumpRedactedResponse(resp); err == nil { logger.Printf("Response Content:\n%s\n", respBytes) } return resp, err }) } + +// dumpRedactedRequest dumps req with sensitive headers replaced. The +// original headers are restored via defer so a panic in DumpRequest cannot +// leak the placeholder map into the live request sent downstream. +func dumpRedactedRequest(req *http.Request) ([]byte, error) { + origHeaders := req.Header + req.Header = redactDebugHeaders(origHeaders) + defer func() { req.Header = origHeaders }() + return httputil.DumpRequest(req, true) +} + +func dumpRedactedResponse(resp *http.Response) ([]byte, error) { + origHeaders := resp.Header + resp.Header = redactDebugHeaders(origHeaders) + defer func() { resp.Header = origHeaders }() + return httputil.DumpResponse(resp, true) +} + +func redactDebugHeaders(headers http.Header) http.Header { + var redacted http.Header + for _, name := range sensitiveLogHeaders { + values := headers.Values(name) + if len(values) == 0 { + continue + } + if redacted == nil { + redacted = headers.Clone() + } + redacted.Del(name) + for range values { + redacted.Add(name, "***") + } + } + if redacted == nil { + return headers + } + return redacted +} diff --git a/scripts/bootstrap b/scripts/bootstrap index 5ab3066..46547f1 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response