Documentation
¶
Overview ¶
Package client is the Receiver-side HTTP client for the Transmitter endpoints defined by OpenID Shared Signals Framework 1.0 §7.
The package translates between Go method calls and the spec's HTTP shapes: it issues the configured request, reads the response, and converts non-2xx outcomes into a typed error a Receiver can route on. RFC 7807 problem-details bodies are decoded into the ssf.ProblemDetails structure the root package already defines; common status codes are wrapped with the matching root-package sentinel (for example ssf.ErrUnauthorized on 401) so callers can pattern-match with errors.Is without inspecting status codes directly.
The full Client type and the per-endpoint wrappers ship in a follow-up commit. This file establishes ParseHTTPError, the shared non-2xx-response parser those wrappers will call.
Index ¶
Examples ¶
Constants ¶
const ( DefaultConfigPath = "/streams" DefaultStatusPath = "/streams/status" DefaultAddSubjectPath = "/streams/subjects:add" DefaultRemoveSubjectPath = "/streams/subjects:remove" DefaultVerifyPath = "/streams/verify" DefaultPollPath = "/streams/poll" )
Default URL paths the Client appends to its base URL for each Transmitter endpoint. The values mirror the default paths the github.com/hstern/go-ssf/transmitter package mounts via transmitter.MuxHandler; a Transmitter deployed with the out-of-the-box mux is reachable from a Client constructed with no WithEndpoints override.
Variables ¶
This section is empty.
Functions ¶
func FetchTransmitterConfig ¶
func FetchTransmitterConfig(ctx context.Context, baseURL string, opts ...DiscoveryOption) (*ssf.TransmitterConfig, error)
FetchTransmitterConfig fetches the well-known metadata document from a Transmitter and decodes it into a *ssf.TransmitterConfig.
The URL is assembled by appending transmitter.WellKnownPath to baseURL; baseURL is the Transmitter's origin (scheme + host, no trailing slash). A trailing slash on baseURL is tolerated and collapsed so the resulting URL has exactly one slash between origin and well-known path.
On a 2xx response the body is decoded and the resulting config returned; on any non-2xx the response is handed to ParseHTTPError and the returned error preserves the spec-level cause via the sentinel-joining behavior documented on that function. On a transport error (DNS, connection refused, TLS handshake) the HTTPDoer's error is wrapped with a fixed prefix and returned.
The function is uncached: every call performs an HTTP round-trip. Consumers that want in-process caching keyed by baseURL should hold a *ConfigCache and call ConfigCache.FetchTransmitterConfig instead — cache lifetime is a deployment concern and the library declines to make that decision globally.
The supplied context.Context flows through to the HTTP request, so cancellation and deadlines apply to the discovery fetch the same way they apply to any other HTTPDoer-backed call.
Example ¶
ExampleFetchTransmitterConfig discovers a Transmitter's metadata document from its well-known endpoint per OpenID Shared Signals Framework 1.0 §3, parses it into a ssf.TransmitterConfig, and reads back a couple of fields.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
ssf "github.com/hstern/go-ssf"
"github.com/hstern/go-ssf/client"
"github.com/hstern/go-ssf/transmitter"
)
func main() {
want := &ssf.TransmitterConfig{
Issuer: "https://transmitter.example.com",
JWKSURI: "https://transmitter.example.com/jwks.json",
DeliveryMethodsSupported: []string{ssf.DeliveryMethodPush},
SpecVersion: ssf.SpecVersion,
}
mux := http.NewServeMux()
mux.Handle(transmitter.WellKnownPath, transmitter.WellKnownHandler(want))
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
got, err := client.FetchTransmitterConfig(context.Background(), srv.URL)
if err != nil {
// handle err
return
}
// json.Marshal turns the discovery shape into bytes the example
// can print deterministically — the field order is fixed.
out, _ := json.Marshal(struct {
Iss string `json:"issuer"`
SpecVer string `json:"spec_version"`
}{got.Issuer, got.SpecVersion})
fmt.Println(string(out))
}
Output: {"issuer":"https://transmitter.example.com","spec_version":"1.0"}
func ParseHTTPError ¶
ParseHTTPError converts a non-2xx HTTP response from a Transmitter into a typed error.
On a 2xx status the function returns nil; the caller still owns closing resp.Body. On any other status it reads up to [maxErrorBodyBytes] from resp.Body, attempts to decode the body as RFC 7807 problem-details when the response's Content-Type indicates problem+json, and builds an *ssf.HTTPError carrying the status code, the raw bytes, and the parsed *ssf.ProblemDetails when available.
When the status maps onto one of the library's sentinel errors (see the table in [mapStatusToSentinel]) the return value joins the *ssf.HTTPError with the sentinel via errors.Join so callers can pattern-match either way:
err := client.ParseHTTPError(resp)
var httpErr *ssf.HTTPError
if errors.As(err, &httpErr) { // recover status, body, problem-details
log.Printf("transmitter returned %d", httpErr.StatusCode)
}
if errors.Is(err, ssf.ErrUnauthorized) { // act on the spec-level cause
refreshToken()
}
The caller is responsible for closing resp.Body. The function does not, because the same response may already be in the middle of a caller's read sequence (status-code-then-body) and the function's contract is "give me a useful error for this response," not "take ownership of the response lifetime."
Types ¶
type Client ¶
type Client interface {
// GetConfig fetches the stream configuration identified by
// streamID per spec §7.1.1.
GetConfig(ctx context.Context, streamID string) (*ssf.StreamConfig, error)
// ListConfig fetches a page of stream configurations the caller
// is authorized to see per spec §7.1.1. pageToken is the opaque
// continuation token returned by the previous call (empty for
// the first page); nextToken is empty when the listing is
// exhausted.
ListConfig(ctx context.Context, pageToken string) (configs []*ssf.StreamConfig, nextToken string, err error)
// CreateConfig POSTs cfg to the stream-configuration endpoint
// per spec §7.1.1 and returns the server-assigned canonical
// representation.
CreateConfig(ctx context.Context, cfg *ssf.StreamConfig) (*ssf.StreamConfig, error)
// UpdateConfig PATCHes the stream identified by cfg.StreamID
// with cfg and returns the post-update canonical representation
// per spec §7.1.1.
UpdateConfig(ctx context.Context, cfg *ssf.StreamConfig) (*ssf.StreamConfig, error)
// DeleteConfig removes the stream identified by streamID per
// spec §7.1.1.
DeleteConfig(ctx context.Context, streamID string) error
// GetStatus fetches the lifecycle state of the stream identified
// by streamID per spec §7.1.2. When subject is non-empty the
// response is scoped to that single subject; nil/empty returns
// the stream-wide status.
GetStatus(ctx context.Context, streamID string, subject json.RawMessage) (*ssf.StatusResponse, error)
// UpdateStatus requests a lifecycle transition on the stream
// identified by streamID per spec §7.1.2.
UpdateStatus(ctx context.Context, streamID string, req *ssf.StatusUpdateRequest) (*ssf.StatusResponse, error)
// AddSubject registers a subject as in-scope for the stream
// identified by streamID per spec §7.1.3.
AddSubject(ctx context.Context, streamID string, req *ssf.AddSubjectRequest) error
// RemoveSubject removes a subject from the stream identified by
// streamID per spec §7.1.3.
RemoveSubject(ctx context.Context, streamID string, req *ssf.RemoveSubjectRequest) error
// Verify initiates the verification flow on the stream
// identified by streamID per spec §7.1.4.
Verify(ctx context.Context, streamID string, req *ssf.VerificationRequest) error
// PollEvents POSTs an RFC 8936 §2.4 poll request for the stream
// identified by streamID and returns the response carrying any
// pending SETs.
PollEvents(ctx context.Context, streamID string, req *ssf.PollRequest) (*ssf.PollResponse, error)
}
Client is the Receiver-side contract for the HTTP endpoints defined by OpenID Shared Signals Framework 1.0 §7 and RFC 8936 §2.4. The 11 methods are 1:1 with the spec endpoints and have signatures identical to transmitter.Transmitter — so a Receiver that holds its Transmitter behind that interface can switch between an in-process implementation and a remote HTTP Transmitter by swapping the value, without reshaping its call sites.
The default implementation is HTTP-backed and is constructed with NewClient. Transport is pluggable through HTTPDoer; authentication is pluggable either through WithAuthorizationHeader (a static header value applied to every request) or by wrapping the doer to mint a fresh credential per request. The HTTP-backed Client is safe for concurrent use by multiple goroutines as long as the supplied HTTPDoer is.
Errors returned by Client methods follow ParseHTTPError's contract: on a non-2xx response the returned error is an *ssf.HTTPError joined with the matching root-package sentinel (when one applies), so callers can branch with errors.Is for the spec-level cause and with errors.As for the raw status, body, and RFC 7807 problem-details.
Test doubles and alternative transports satisfy Client by implementing the same 11 methods; the interface deliberately exposes no HTTP-shaped surface (no net/http.ResponseWriter, no path parameters) so a fake driving an in-process Transmitter is as usable as the HTTP-backed default.
func NewClient ¶
NewClient constructs an HTTP-backed Client targeting the Transmitter at baseURL. The base URL is parsed and validated at construction time — an empty string or a string that does not parse as an absolute URL with an http(s) scheme is rejected with a wrapped *ssf.ValidationError, so misconfiguration surfaces synchronously rather than as a confusing error from the first method call.
Options apply after defaults: by default the client uses http.DefaultClient as its transport, sets no Authorization header, and uses the DefaultConfigPath / DefaultStatusPath / DefaultAddSubjectPath / DefaultRemoveSubjectPath / DefaultVerifyPath / DefaultPollPath endpoint paths.
The returned Client is safe for concurrent use by multiple goroutines provided its HTTPDoer is — http.DefaultClient and any *http.Client are.
Example ¶
ExampleNewClient constructs a client.Client pointed at a stub Transmitter and issues one stream-configuration call. The stub stands in for a real Transmitter; a production client targets the Transmitter's deployed origin URL.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/hstern/go-ssf/client"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"stream_id":"s-42","iss":"https://tx.example","delivery":{"method":"urn:ietf:rfc:8935"}}`))
}))
defer srv.Close()
c, err := client.NewClient(srv.URL,
client.WithAuthorizationHeader("Bearer demo-token"))
if err != nil {
// handle err
return
}
cfg, err := c.GetConfig(context.Background(), "s-42")
if err != nil {
// handle err
return
}
fmt.Println("stream:", cfg.StreamID)
fmt.Println("iss:", cfg.Iss)
}
Output: stream: s-42 iss: https://tx.example
type ConfigCache ¶
type ConfigCache struct {
// contains filtered or unexported fields
}
ConfigCache is an in-process cache of *ssf.TransmitterConfig documents keyed by Transmitter base URL. A Receiver pointing at one or more Transmitters holds a single *ConfigCache and calls ConfigCache.FetchTransmitterConfig from every site that needs the metadata; the cache short-circuits the HTTP round-trip while the stored entry is still fresh.
Freshness is the minimum of:
- the [Cache-Control] max-age on the response, when present;
- the TTL passed to NewConfigCache.
A response carrying [Cache-Control] no-store or no-cache is not retained, matching RFC 9111 §5.2 for intermediate caches that cannot perform conditional revalidation. A response carrying max-age=0 is fetched but not retained.
ConfigCache is safe for concurrent use. The zero value is not usable; construct one with NewConfigCache.
func NewConfigCache ¶
func NewConfigCache(ttl time.Duration) *ConfigCache
NewConfigCache returns a *ConfigCache with the supplied default TTL. The TTL is an upper bound: an individual entry's freshness is the minimum of the TTL and the response's [Cache-Control] max-age. A non-positive TTL disables caching entirely — every fetch performs an HTTP round-trip and the cache table stays empty, which matches what a caller asking for "zero or less freshness" plausibly means.
func (*ConfigCache) FetchTransmitterConfig ¶
func (c *ConfigCache) FetchTransmitterConfig(ctx context.Context, baseURL string, opts ...DiscoveryOption) (*ssf.TransmitterConfig, error)
FetchTransmitterConfig returns a cached *ssf.TransmitterConfig for baseURL when one is still fresh, otherwise performs an HTTP round-trip via the package-level FetchTransmitterConfig logic and stores the result for future calls. The returned config is the same pointer stored in the cache; consumers that intend to mutate the returned value should copy it first.
Options are the same set FetchTransmitterConfig accepts. The cache is keyed by baseURL only; passing a different HTTPDoer across calls for the same baseURL still returns the cached entry while it is fresh. Callers that need per-doer caching should hold one *ConfigCache per doer.
type DiscoveryOption ¶
type DiscoveryOption func(*discoveryConfig)
DiscoveryOption configures a FetchTransmitterConfig call or a ConfigCache.FetchTransmitterConfig call. Options are applied in the order they are passed; later options override earlier ones for the same setting.
HTTPDoer and the package-level WithHTTPDoer option are defined in client.go; the option type is shared with the per-endpoint Client surface. Discovery accepts the same option for the same reason: the consumer's transport choice is a deployment concern that should be uniform across discovery and per-endpoint calls.
func WithDiscoveryHTTPDoer ¶
func WithDiscoveryHTTPDoer(doer HTTPDoer) DiscoveryOption
WithDiscoveryHTTPDoer overrides the HTTPDoer used to fetch the metadata document for a FetchTransmitterConfig call. The default is http.DefaultClient; pass a configured *http.Client when the deployment requires a custom transport (instrumented round-tripper, proxy, mTLS, request-scoped timeouts beyond what context.Context supplies).
A nil doer is ignored and the default is retained, so passing WithDiscoveryHTTPDoer(nil) is a no-op rather than a runtime panic.
Distinct from WithHTTPDoer (which configures a Client) so the two option surfaces can coexist in this package.
type EndpointPaths ¶
type EndpointPaths struct {
// Config is the path for the stream-configuration endpoint
// (spec §7.1.1). Default [DefaultConfigPath].
Config string
// Status is the path for the stream-status endpoint
// (spec §7.1.2). Default [DefaultStatusPath].
Status string
// AddSubject is the path for the add-subject endpoint
// (spec §7.1.3). Default [DefaultAddSubjectPath].
AddSubject string
// RemoveSubject is the path for the remove-subject endpoint
// (spec §7.1.3). Default [DefaultRemoveSubjectPath].
RemoveSubject string
// Verify is the path for the verification endpoint
// (spec §7.1.4). Default [DefaultVerifyPath].
Verify string
// Poll is the path for the poll-delivery endpoint
// (RFC 8936 §2.4). Default [DefaultPollPath].
Poll string
}
EndpointPaths overrides the per-endpoint URL paths the client appends to its base URL. The zero value of each field means "keep the default" — the client falls back to the default path constants documented on Client when the corresponding field is empty.
EndpointPaths is shaped to match the transmitter.MuxHandler path option group on the Transmitter side, so a deployment that mounts the Transmitter on a custom path layout can pass the symmetrical values to its clients without translating between two different option vocabularies.
Paths are URL paths, not full URLs — they are appended to the configured base URL with net/url.URL.ResolveReference. Each path SHOULD begin with a leading slash for clarity; the client tolerates the leading slash being absent but the behaviour with relative paths follows net/url's reference-resolution rules.
type HTTPDoer ¶
HTTPDoer is the minimal interface this package needs from an HTTP transport: one method that takes an *http.Request and returns an *http.Response. The shape matches net/http.Client.Do verbatim, and the [oauth2] package uses the same shape, so *http.Client already satisfies HTTPDoer out of the box.
Consumers plug their own implementation to layer retries, authentication header injection, distributed-tracing propagation, mutual-TLS dialers, or any policy that wraps an outbound HTTP request. The client invokes Do exactly once per method call; back- off, retry, and circuit-breaking belong inside HTTPDoer, not in the client itself.
type Option ¶
type Option func(*httpClient)
Option configures the HTTP-backed Client at construction time. Options compose; later options override earlier ones for the same setting.
func WithAuthorizationHeader ¶
WithAuthorizationHeader sets a verbatim Authorization header value the client adds to every request. Typical values are "Bearer <access-token>" for OAuth 2.0 bearer credentials or "Basic <base64>" for HTTP Basic authentication; the client does not interpret the value — it is copied onto each outbound request header as-is.
Pass the empty string to clear a previously configured header. Callers needing per-request credentials (e.g. a token refreshed shortly before expiry) typically wrap HTTPDoer instead so the credential is minted at request time rather than fixed at client construction.
func WithEndpoints ¶
func WithEndpoints(paths EndpointPaths) Option
WithEndpoints overrides the per-endpoint URL paths the client appends to its base URL. Empty fields in paths fall through to the default value (see DefaultConfigPath and friends), so a caller who only needs to retarget one endpoint can leave the others zero.
Use this option when the Transmitter is deployed with non-default path mounts (for example behind a reverse proxy that prefixes all SSF paths with `/api/v1`). The path values are joined to the base URL with net/url reference-resolution rules; supplying a fully-qualified URL in a path field is not supported.
func WithHTTPDoer ¶
WithHTTPDoer overrides the HTTP transport the client uses. The default is http.DefaultClient. Pass any value satisfying HTTPDoer — typically an *http.Client preconfigured with a custom http.Transport, or a wrapper that layers retry, auth, metrics, or tracing.
A nil doer is rejected at construction time so callers do not learn about the misconfiguration the first time a method panics on a nil-pointer dereference.