diff --git a/internal/hooks/client_header.go b/internal/hooks/client_header.go new file mode 100644 index 0000000..7823f28 --- /dev/null +++ b/internal/hooks/client_header.go @@ -0,0 +1,45 @@ +package hooks + +import ( + "net/http" + "strings" + + "github.com/sfcompute/sfc-go/internal/config" +) + +// sfcClientHeader is the canonical header used by SFC services to identify +// SDK clients. The server-side parser relies on this header rather than the +// generated User-Agent so that our parsing does not break if Speakeasy +// changes the User-Agent format. +const sfcClientHeader = "x-sfc-client" + +// ClientHeaderHook sets the x-sfc-client header on every outgoing request. +// The version is captured from the generated User-Agent at SDK init time. +type ClientHeaderHook struct { + headerValue string +} + +func (h *ClientHeaderHook) SDKInit(c config.SDKConfiguration) config.SDKConfiguration { + h.headerValue = "speakeasy/go-" + parseSDKVersion(c.UserAgent) + return c +} + +func (h *ClientHeaderHook) BeforeRequest(_ BeforeRequestContext, req *http.Request) (*http.Request, error) { + req.Header.Set(sfcClientHeader, h.headerValue) + return req, nil +} + +// parseSDKVersion extracts the SDK version (second token) from the +// Speakeasy-generated User-Agent string of the form: +// +// "speakeasy-sdk/go " +// +// Returns "unknown" if the User-Agent is empty or does not have at least two +// space-separated tokens. +func parseSDKVersion(userAgent string) string { + parts := strings.Fields(userAgent) + if len(parts) < 2 { + return "unknown" + } + return parts[1] +} diff --git a/internal/hooks/client_header_test.go b/internal/hooks/client_header_test.go new file mode 100644 index 0000000..7f6557f --- /dev/null +++ b/internal/hooks/client_header_test.go @@ -0,0 +1,52 @@ +package hooks + +import ( + "net/http" + "testing" + + "github.com/sfcompute/sfc-go/internal/config" +) + +func TestClientHeaderHook(t *testing.T) { + tests := []struct { + name string + userAgent string + want string + }{ + { + name: "speakeasy generated user agent", + userAgent: "speakeasy-sdk/go 0.0.1 2.881.17 0.1.0 github.com/sfcompute/sfc-go", + want: "speakeasy/go-0.0.1", + }, + { + name: "empty user agent", + userAgent: "", + want: "speakeasy/go-unknown", + }, + { + name: "single-token user agent", + userAgent: "speakeasy-sdk/go", + want: "speakeasy/go-unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := &ClientHeaderHook{} + h.SDKInit(config.SDKConfiguration{UserAgent: tc.userAgent}) + + req, err := http.NewRequest(http.MethodGet, "https://api.sfcompute.com/v1/foo", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + if _, err := h.BeforeRequest(BeforeRequestContext{}, req); err != nil { + t.Fatalf("BeforeRequest: %v", err) + } + + if got := req.Header.Get(sfcClientHeader); got != tc.want { + t.Errorf("x-sfc-client = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 8651ce5..945b883 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -87,6 +87,8 @@ func New() *Hooks { afterErrorHook: []afterErrorHook{}, } + initHooks(h) + return h } diff --git a/internal/hooks/registration.go b/internal/hooks/registration.go new file mode 100644 index 0000000..c4b84f0 --- /dev/null +++ b/internal/hooks/registration.go @@ -0,0 +1,10 @@ +package hooks + +// initHooks is invoked by Hooks.New (in hooks.go) and registers all custom +// hooks for this SDK. This file is user-owned and preserved across Speakeasy +// regenerations. +func initHooks(h *Hooks) { + clientHeader := &ClientHeaderHook{} + h.registerSDKInitHook(clientHeader) + h.registerBeforeRequestHook(clientHeader) +}