Repository: ably/ably-go
Pull request #590 (2023-04-03 15:29:54)

add agents option
Add an optional agents option that includes product/version key-value pairs to include in the library agents identifier, as described by RSC7d6.

Note if the OS cannot be identified, instead of an empty string this uses `unknown` to ensure the agents are always 4th in the header.

lmars: > Note if the OS cannot be identified, instead of an empty string this uses unknown to ensure the agents are always 4th in the header.

Why is it desirable for it to always be 4th?
lmars: I think it's fine to be any position, since I believe there is an explicit parser based on [agents.json](https://github.com/ably/ably-common/blob/main/protocol/agents.json) (which you should add the new agent you're adding to, by the way).

cc @ruimffl who can confirm ^^
andydunstall: > I think it's fine to be any position, since I believe there is an explicit parser based on [agents.json](https://github.com/ably/ably-common/blob/main/protocol/agents.json) (which you should add the new agent you're adding to, by the way).

Ok great thanks - will remove the 'unknown' and update agents.json
andydunstall: Some tests failing, though different tests fail between runs so looks like CI is just flaky - so will mark ready to review

Patch:
diff --git a/ably/options.go b/ably/options.go
index 2109cb2..c5bc29d 100644
--- a/ably/options.go
+++ b/ably/options.go
@@ -390,6 +390,10 @@ type clientOptions struct {
 	Now   func() time.Time
 	After func(context.Context, time.Duration) <-chan time.Time
 
+	// Product/version key-value pairs to include in the agent library
+	// identifiers. This must only be used by Ably-authored SDKs (RSC7d6).
+	Agents map[string]string
+
 	// LogLevel controls the verbosity of the logs output from the library.
 	// Levels include verbose, debug, info, warn and error.
 	// platform specific (TO3b)
@@ -1070,6 +1074,14 @@ 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.
+func WithAgents(agents map[string]string) ClientOption {
+	return func(os *clientOptions) {
+		os.Agents = agents
+	}
+}
+
 // WithPort is used for setting custom Port using [ably.ClientOption].
 // Port is used for non-TLS connections and requests
 func WithPort(port int) ClientOption {
diff --git a/ably/proto_http.go b/ably/proto_http.go
index fc2777f..d38b024 100644
--- a/ably/proto_http.go
+++ b/ably/proto_http.go
@@ -3,6 +3,7 @@ package ably
 import (
 	"fmt"
 	"runtime"
+	"strings"
 )
 
 // constants for rsc7
@@ -23,11 +24,24 @@ var goRuntimeIdentifier = func() string {
 	return fmt.Sprintf("%s/%s", libraryName, runtime.Version()[2:])
 }()
 
-var ablyAgentIdentifier = func() string {
+func ablyAgentIdentifier(agents map[string]string) string {
+	identifiers := []string{
+		ablySDKIdentifier,
+		goRuntimeIdentifier,
+	}
+
 	osIdentifier := goOSIdentifier()
-	if empty(osIdentifier) {
-		return fmt.Sprintf("%s %s", ablySDKIdentifier, goRuntimeIdentifier)
-	} else {
-		return fmt.Sprintf("%s %s %s", ablySDKIdentifier, goRuntimeIdentifier, osIdentifier)
+	if !empty(osIdentifier) {
+		identifiers = append(identifiers, osIdentifier)
 	}
-}()
+
+	for product, version := range agents {
+		if empty(version) {
+			identifiers = append(identifiers, product)
+		} else {
+			identifiers = append(identifiers, product+"/"+version)
+		}
+	}
+
+	return strings.Join(identifiers, " ")
+}
diff --git a/ably/realtime_conn.go b/ably/realtime_conn.go
index 39be842..8665472 100644
--- a/ably/realtime_conn.go
+++ b/ably/realtime_conn.go
@@ -133,7 +133,7 @@ func (c *Connection) dial(proto string, u *url.URL) (conn conn, err error) {
 	if c.opts.Dial != nil {
 		conn, err = c.opts.Dial(proto, u, timeout)
 	} else {
-		conn, err = dialWebsocket(proto, u, timeout)
+		conn, err = dialWebsocket(proto, u, timeout, c.opts.Agents)
 	}
 	if err != nil {
 		c.log().Debugf("Dial Failed in %v with %v", time.Since(start), err)
diff --git a/ably/rest_client.go b/ably/rest_client.go
index 5ff935b..e9f5e89 100644
--- a/ably/rest_client.go
+++ b/ably/rest_client.go
@@ -826,7 +826,7 @@ func (c *REST) newHTTPRequest(ctx context.Context, r *request) (*http.Request, e
 	}
 	req.Header.Set("Accept", protocol) //spec RSC19c
 	req.Header.Set(ablyVersionHeader, ablyVersion)
-	req.Header.Set(ablyAgentHeader, ablyAgentIdentifier)
+	req.Header.Set(ablyAgentHeader, ablyAgentIdentifier(c.opts.Agents))
 	if c.opts.ClientID != "" && c.Auth.method == authBasic {
 		// References RSA7e2
 		h := base64.StdEncoding.EncodeToString([]byte(c.opts.ClientID))
diff --git a/ably/websocket.go b/ably/websocket.go
index bef3a87..02483cf 100644
--- a/ably/websocket.go
+++ b/ably/websocket.go
@@ -77,7 +77,7 @@ func (ws *websocketConn) Close() error {
 	return ws.conn.Close(websocket.StatusNormalClosure, "")
 }
 
-func dialWebsocket(proto string, u *url.URL, timeout time.Duration) (*websocketConn, error) {
+func dialWebsocket(proto string, u *url.URL, timeout time.Duration, agents map[string]string) (*websocketConn, error) {
 	ws := &websocketConn{}
 	switch proto {
 	case "application/json":
@@ -88,7 +88,7 @@ func dialWebsocket(proto string, u *url.URL, timeout time.Duration) (*websocketC
 		return nil, errors.New(`invalid protocol "` + proto + `"`)
 	}
 	// Starts a raw websocket connection with server
-	conn, err := dialWebsocketTimeout(u.String(), "https://"+u.Host, timeout)
+	conn, err := dialWebsocketTimeout(u.String(), "https://"+u.Host, timeout, agents)
 	if err != nil {
 		return nil, err
 	}
@@ -97,13 +97,13 @@ func dialWebsocket(proto string, u *url.URL, timeout time.Duration) (*websocketC
 }
 
 // dialWebsocketTimeout dials the websocket with a timeout.
-func dialWebsocketTimeout(uri, origin string, timeout time.Duration) (*websocket.Conn, error) {
+func dialWebsocketTimeout(uri, origin string, timeout time.Duration, agents map[string]string) (*websocket.Conn, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
 
 	var ops websocket.DialOptions
 	ops.HTTPHeader = make(http.Header)
-	ops.HTTPHeader.Add(ablyAgentHeader, ablyAgentIdentifier)
+	ops.HTTPHeader.Add(ablyAgentHeader, ablyAgentIdentifier(agents))
 
 	c, _, err := websocket.Dial(ctx, uri, &ops)
 


Test patch:
diff --git a/ably/export_test.go b/ably/export_test.go
index 6b72a02..4bdd5e2 100644
--- a/ably/export_test.go
+++ b/ably/export_test.go
@@ -269,7 +269,7 @@ func ChannelModeToFlag(mode ChannelMode) ProtoFlag {
 }
 
 func DialWebsocket(proto string, u *url.URL, timeout time.Duration) (Conn, error) {
-	return dialWebsocket(proto, u, timeout)
+	return dialWebsocket(proto, u, timeout, nil)
 }
 
 func NewCBCCipher(opts CipherParams) (*cbcCipher, error) {
diff --git a/ably/realtime_client_integration_test.go b/ably/realtime_client_integration_test.go
index 51ccf29..925918f 100644
--- a/ably/realtime_client_integration_test.go
+++ b/ably/realtime_client_integration_test.go
@@ -72,6 +72,64 @@ func TestRealtime_RSC7_AblyAgent(t *testing.T) {
 
 		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
 	})
+
+	t.Run("RSC7d6 : Should set ablyAgent header with custom agents", func(t *testing.T) {
+		var agentHeaderValue string
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			agentHeaderValue = r.Header.Get(ably.AblyAgentHeader)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer server.Close()
+		serverURL, err := url.Parse(server.URL)
+		assert.NoError(t, err)
+
+		client, err := ably.NewRealtime(
+			ably.WithEnvironment(ablytest.Environment),
+			ably.WithTLS(false),
+			ably.WithToken("fake:token"),
+			ably.WithUseTokenAuth(true),
+			ably.WithRealtimeHost(serverURL.Host),
+			ably.WithAgents(map[string]string{
+				"foo": "1.2.3",
+			}),
+		)
+		assert.NoError(t, err)
+		defer client.Close()
+
+		expectedAgentHeaderValue := ably.AblySDKIdentifier + " " + ably.GoRuntimeIdentifier + " " + ably.GoOSIdentifier() + " foo/1.2.3"
+		ablytest.Wait(ablytest.ConnWaiter(client, nil, ably.ConnectionEventDisconnected), nil)
+
+		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
+	})
+
+	t.Run("RSC7d6 : Should set ablyAgent header with custom agents missing version", func(t *testing.T) {
+		var agentHeaderValue string
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			agentHeaderValue = r.Header.Get(ably.AblyAgentHeader)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer server.Close()
+		serverURL, err := url.Parse(server.URL)
+		assert.NoError(t, err)
+
+		client, err := ably.NewRealtime(
+			ably.WithEnvironment(ablytest.Environment),
+			ably.WithTLS(false),
+			ably.WithToken("fake:token"),
+			ably.WithUseTokenAuth(true),
+			ably.WithRealtimeHost(serverURL.Host),
+			ably.WithAgents(map[string]string{
+				"bar": "",
+			}),
+		)
+		assert.NoError(t, err)
+		defer client.Close()
+
+		expectedAgentHeaderValue := ably.AblySDKIdentifier + " " + ably.GoRuntimeIdentifier + " " + ably.GoOSIdentifier() + " bar"
+		ablytest.Wait(ablytest.ConnWaiter(client, nil, ably.ConnectionEventDisconnected), nil)
+
+		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
+	})
 }
 
 func checkUnique(ch chan string, typ string, n int) error {
diff --git a/ably/rest_client_integration_test.go b/ably/rest_client_integration_test.go
index e9ed1e3..d1eb11e 100644
--- a/ably/rest_client_integration_test.go
+++ b/ably/rest_client_integration_test.go
@@ -262,6 +262,62 @@ func TestRest_RSC7_AblyAgent(t *testing.T) {
 		client.Time(context.Background())
 		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
 	})
+
+	t.Run("RSC7d6 : Should set ablyAgent header with custom agents", func(t *testing.T) {
+		var agentHeaderValue string
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			agentHeaderValue = r.Header.Get(ably.AblyAgentHeader)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer server.Close()
+		serverURL, err := url.Parse(server.URL)
+		assert.NoError(t, err)
+
+		opts := []ably.ClientOption{
+			ably.WithEnvironment(ablytest.Environment),
+			ably.WithTLS(false),
+			ably.WithUseTokenAuth(true),
+			ably.WithRESTHost(serverURL.Host),
+			ably.WithAgents(map[string]string{
+				"foo": "1.2.3",
+			}),
+		}
+
+		client, err := ably.NewREST(opts...)
+		assert.NoError(t, err)
+		expectedAgentHeaderValue := ably.AblySDKIdentifier + " " + ably.GoRuntimeIdentifier + " " + ably.GoOSIdentifier() + " foo/1.2.3"
+
+		client.Time(context.Background())
+		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
+	})
+
+	t.Run("RSC7d6 : Should set ablyAgent header with custom agents missing version", func(t *testing.T) {
+		var agentHeaderValue string
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			agentHeaderValue = r.Header.Get(ably.AblyAgentHeader)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer server.Close()
+		serverURL, err := url.Parse(server.URL)
+		assert.NoError(t, err)
+
+		opts := []ably.ClientOption{
+			ably.WithEnvironment(ablytest.Environment),
+			ably.WithTLS(false),
+			ably.WithUseTokenAuth(true),
+			ably.WithRESTHost(serverURL.Host),
+			ably.WithAgents(map[string]string{
+				"bar": "",
+			}),
+		}
+
+		client, err := ably.NewREST(opts...)
+		assert.NoError(t, err)
+		expectedAgentHeaderValue := ably.AblySDKIdentifier + " " + ably.GoRuntimeIdentifier + " " + ably.GoOSIdentifier() + " bar"
+
+		client.Time(context.Background())
+		assert.Equal(t, expectedAgentHeaderValue, agentHeaderValue)
+	})
 }
 
 func TestRest_hostfallback(t *testing.T) {
diff --git a/ably/websocket_internal_test.go b/ably/websocket_internal_test.go
index d482e95..51daaf4 100644
--- a/ably/websocket_internal_test.go
+++ b/ably/websocket_internal_test.go
@@ -119,7 +119,7 @@ func TestWebsocketDial(t *testing.T) {
 	for testName, test := range tests {
 		t.Run(testName, func(t *testing.T) {
 
-			result, err := dialWebsocket(test.dialProtocol, testServerURL, timeout)
+			result, err := dialWebsocket(test.dialProtocol, testServerURL, timeout, nil)
 			assert.Equal(t, test.expectedErr, err)
 
 			if result != nil {
@@ -159,7 +159,7 @@ func TestWebsocketSendAndReceive(t *testing.T) {
 	for testName, test := range tests {
 		t.Run(testName, func(t *testing.T) {
 
-			ws, err := dialWebsocket(test.dialProtocol, testServerURL, timeout)
+			ws, err := dialWebsocket(test.dialProtocol, testServerURL, timeout, nil)
 			assert.NoError(t, err)
 			msg := protocolMessage{
 				Messages: []*Message{{Name: "temperature", Data: "22.7"}},