diff --git a/ably/options.go b/ably/options.go index 70af0547..33d2eec7 100644 --- a/ably/options.go +++ b/ably/options.go @@ -1194,9 +1194,18 @@ func WithLogLevel(level LogLevel) ClientOption { // WithAgents is used to add product/version key-value pairs to include in the // agent library identifiers. This must only be used by Ably-authored SDKs. +// Successive calls merge their entries into any agents already configured +// rather than replacing them. func WithAgents(agents map[string]string) ClientOption { return func(os *clientOptions) { - os.Agents = agents + merged := make(map[string]string, len(os.Agents)+len(agents)) + for product, version := range os.Agents { + merged[product] = version + } + for product, version := range agents { + merged[product] = version + } + os.Agents = merged } } diff --git a/pubsub/device/device.go b/pubsub/device/device.go new file mode 100644 index 00000000..4e3c0228 --- /dev/null +++ b/pubsub/device/device.go @@ -0,0 +1,21 @@ +// Package device provides the Ably Pub/Sub client for devices: applications +// running on end-user devices, whose connections are identified by a +// clientId and counted on accounts with monthly-active-user billing. +package device + +import "github.com/ably/ably-go/ably" + +// agentName declares the side in the Ably-Agent header (RSC7d) so that +// traffic from clients constructed by this package is classified as +// device-side. +const agentName = "ably-go-pubsub-device" + +// Client is a device Pub/Sub client. +type Client = ably.Realtime + +// NewClient constructs a device Pub/Sub client: a realtime connection to +// Ably with channels, presence and history. +func NewClient(opts ...ably.ClientOption) (*Client, error) { + opts = append(opts[:len(opts):len(opts)], ably.WithAgents(map[string]string{agentName: ""})) + return ably.NewRealtime(opts...) +} diff --git a/pubsub/device/device_test.go b/pubsub/device/device_test.go new file mode 100644 index 00000000..c93ee3ef --- /dev/null +++ b/pubsub/device/device_test.go @@ -0,0 +1,38 @@ +package device_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ably/ably-go/ably" + "github.com/ably/ably-go/pubsub/device" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClient_DeclaresDeviceAgent(t *testing.T) { + var agent string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agent = r.Header.Get(ably.AblyAgentHeaderName) + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + u, err := url.Parse(ts.URL) + require.NoError(t, err) + + // The realtime client's HTTP requests carry the same agents as its + // connection, so assert the header via Time without connecting. + client, err := device.NewClient( + ably.WithEndpoint(u.Host), + ably.WithTLS(false), + ably.WithUseTokenAuth(true), + ably.WithAutoConnect(false), + ) + require.NoError(t, err) + + client.Time(context.Background()) + assert.Equal(t, ably.AgentIdentifier(map[string]string{"ably-go-pubsub-device": ""}), agent) +} diff --git a/pubsub/server/server.go b/pubsub/server/server.go new file mode 100644 index 00000000..cc223bad --- /dev/null +++ b/pubsub/server/server.go @@ -0,0 +1,32 @@ +// Package server provides the Ably Pub/Sub clients for servers: trusted +// environments which typically authenticate with an API key and whose +// connections are exempt from monthly-active-user counting. +package server + +import "github.com/ably/ably-go/ably" + +// agentName declares the side in the Ably-Agent header (RSC7d) so that +// traffic from clients constructed by this package is classified as +// server-side. +const agentName = "ably-go-pubsub-server" + +// HTTPClient is a server Pub/Sub client that operates entirely over HTTP. +type HTTPClient = ably.REST + +// RealtimeClient is a server Pub/Sub client with a realtime connection. +type RealtimeClient = ably.Realtime + +// NewHTTPClient constructs a server Pub/Sub client that operates entirely +// over HTTP: publish, history, presence reads, stats, token issuing. +func NewHTTPClient(opts ...ably.ClientOption) (*HTTPClient, error) { + opts = append(opts[:len(opts):len(opts)], ably.WithAgents(map[string]string{agentName: ""})) + return ably.NewREST(opts...) +} + +// NewRealtimeClient constructs a server Pub/Sub client with a persistent +// realtime connection: everything the HTTP client does, plus subscribing +// to channels and entering presence. +func NewRealtimeClient(opts ...ably.ClientOption) (*RealtimeClient, error) { + opts = append(opts[:len(opts):len(opts)], ably.WithAgents(map[string]string{agentName: ""})) + return ably.NewRealtime(opts...) +} diff --git a/pubsub/server/server_test.go b/pubsub/server/server_test.go new file mode 100644 index 00000000..aad70aa9 --- /dev/null +++ b/pubsub/server/server_test.go @@ -0,0 +1,56 @@ +package server_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/ably/ably-go/ably" + "github.com/ably/ably-go/pubsub/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newAgentRecorder returns a test server which records the Ably-Agent header +// of each request it receives, along with options pointing a client at it. +func newAgentRecorder(t *testing.T) (agent *string, opts []ably.ClientOption) { + t.Helper() + agent = new(string) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *agent = r.Header.Get(ably.AblyAgentHeaderName) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(ts.Close) + u, err := url.Parse(ts.URL) + require.NoError(t, err) + return agent, []ably.ClientOption{ + ably.WithEndpoint(u.Host), + ably.WithTLS(false), + ably.WithUseTokenAuth(true), + } +} + +func TestNewHTTPClient_DeclaresServerAgent(t *testing.T) { + agent, opts := newAgentRecorder(t) + + client, err := server.NewHTTPClient(opts...) + require.NoError(t, err) + + client.Time(context.Background()) + assert.Equal(t, ably.AgentIdentifier(map[string]string{"ably-go-pubsub-server": ""}), *agent) +} + +func TestNewHTTPClient_MergesUserAgents(t *testing.T) { + agent, opts := newAgentRecorder(t) + + client, err := server.NewHTTPClient(append(opts, ably.WithAgents(map[string]string{"foo": "1.2.3"}))...) + require.NoError(t, err) + + client.Time(context.Background()) + // Agent map iteration order is unspecified, so assert each entry. + assert.True(t, strings.Contains(*agent, " ably-go-pubsub-server"), "missing side agent: %q", *agent) + assert.True(t, strings.Contains(*agent, " foo/1.2.3"), "missing user agent: %q", *agent) +}