diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml index 304ec070d..836ab86f2 100644 --- a/.github/workflows/emulate.yml +++ b/.github/workflows/emulate.yml @@ -50,7 +50,7 @@ jobs: arch: ${{ steps.get-avd-arch.outputs.arch }} target: default # Print emulator logs if tests fail - script: ./gradlew :core-android:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) + script: ./gradlew :core-android:connectedAndroidTest :device:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() diff --git a/device/build.gradle.kts b/device/build.gradle.kts new file mode 100644 index 000000000..f9f33e285 --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.pubsub.device" + defaultConfig { + minSdk = 19 + compileSdk = 34 + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + buildFeatures { + buildConfig = true + } + + lint { + abortOnError = false + } + + testOptions.targetSdk = 34 + + sourceSets { + getByName("main") { + // `../shared` holds the side-agent helper shared with the `server` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + java.srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +dependencies { + api(project(":core-android")) + androidTestImplementation(libs.bundles.instrumental.android) +} + +configurations { + all { + exclude(group = "org.hamcrest", module = "hamcrest-core") + resolutionStrategy { + force(libs.jetbrains) + } + } +} diff --git a/device/gradle.properties b/device/gradle.properties new file mode 100644 index 000000000..1be8fb312 --- /dev/null +++ b/device/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=device +POM_NAME=Ably Pub/Sub device SDK +POM_DESCRIPTION=Ably Pub/Sub client for devices: Android apps and other end-user runtimes. The recommended entry point is PubSubDevice.clientBuilder(...). +POM_PACKAGING=aar diff --git a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java new file mode 100644 index 000000000..b4ff3ea91 --- /dev/null +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -0,0 +1,61 @@ +package io.ably.pubsub.device; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic on + * MAU-priced accounts, so these tests are deliberately strict: if one fails, billing + * classification is broken, not just a header. + */ +public class PubSubDeviceTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + + private static ClientOptions offlineOptions(String key) throws Exception { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + @Test + public void client_stampsDeviceAgent() throws Exception { + AblyRealtime client = PubSubDevice.clientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception { + ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options; + assertEquals(FAKE_KEY, builtOptions.key); + assertNull(builtOptions.token); + assertEquals(BuildConfig.VERSION, builtOptions.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() throws Exception { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + callerAgents.put(Side.DEVICE_AGENT_IDENTIFIER, "not-the-real-version"); + options.agents = callerAgents; + + AblyRealtime client = PubSubDevice.clientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + + // the caller's own map is untouched + assertTrue(options.agents == callerAgents); + assertEquals("not-the-real-version", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertFalse(callerAgents.containsValue(BuildConfig.VERSION)); + } +} diff --git a/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java new file mode 100644 index 000000000..9b44823ff --- /dev/null +++ b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java @@ -0,0 +1,74 @@ +package io.ably.pubsub.device; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for devices: Android apps and other end-user runtimes. + *

+ * Clients built here declare themselves device-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-device} agent entry, which is how the platform + * classifies the traffic (on MAU-priced accounts, device traffic is what is counted). The + * side is the package's to declare — a caller-supplied agent entry cannot override it. + *

+ * There is one door: a device holds one live client. Connectionless operations (history, + * presence reads, token requests) are all available on it. + *

+ * This builder is the only recommended entry point of this artifact; the classes it + * constructs come from {@code io.ably.pubsub:core-android}, which is an internal + * implementation artifact not intended for direct use. + */ +public final class PubSubDevice { + private PubSubDevice() {} + + /** + * Returns a builder for the device's client. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static ClientBuilder clientBuilder(ClientOptions options) { + return new ClientBuilder(options, null); + } + + /** + * Returns a builder for the device's client. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static ClientBuilder clientBuilder(String keyOrToken) { + return new ClientBuilder(null, keyOrToken); + } + + /** + * Builds the device client. Accepts everything the core constructor accepts. + */ + public static final class ClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private ClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the device side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + final ClientOptions stamped; + if (keyOrToken != null) { + stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + } else { + stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + return new AblyRealtime(stamped); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/types/ClientOptions.java b/lib/src/main/java/io/ably/lib/types/ClientOptions.java index 3d63be81a..f1f88ded1 100644 --- a/lib/src/main/java/io/ably/lib/types/ClientOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ClientOptions.java @@ -372,6 +372,10 @@ public ClientOptions copy() { copied.authParams = authParams; copied.queryTime = queryTime; copied.useTokenAuth = useTokenAuth; + copied.headers = headers; + copied.fallbackHosts = fallbackHosts; + copied.transportParams = transportParams; + copied.agents = agents; return copied; } diff --git a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java index 3d482d95d..34f873e48 100644 --- a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java +++ b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java @@ -1,7 +1,11 @@ package io.ably.lib.types; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import java.util.HashMap; + import org.junit.Test; public class ClientOptionsTest { @@ -13,4 +17,24 @@ public void should_support_idempotent_rest_publishing() { // Then assertTrue(clientOptions.idempotentRestPublishing); } + + @Test + public void copy_carries_headers_fallbackHosts_transportParams_and_agents() { + // Given + clientOptions.headers = new HashMap<>(); + clientOptions.headers.put("X-Custom", "value"); + clientOptions.fallbackHosts = new String[]{"a.example.com", "b.example.com"}; + clientOptions.transportParams = new Param[]{new Param("remainPresentFor", "1000")}; + clientOptions.agents = new HashMap<>(); + clientOptions.agents.put("some-sdk", "1.2.3"); + + // When + ClientOptions copied = clientOptions.copy(); + + // Then + assertSame(clientOptions.headers, copied.headers); + assertArrayEquals(clientOptions.fallbackHosts, copied.fallbackHosts); + assertSame(clientOptions.transportParams, copied.transportParams); + assertSame(clientOptions.agents, copied.agents); + } } diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 000000000..18aa5ec16 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.build.config) + alias(libs.plugins.maven.publish) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +dependencies { + api(project(":core")) + testImplementation(libs.bundles.tests) +} + +buildConfig { + useJavaOutput() + packageName = "io.ably.pubsub.server" + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") +} + +sourceSets { + named("main") { + java { + // `../shared` holds the side-agent helper shared with the `device` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +tasks.checkstyleMain.configure { + exclude("io/ably/pubsub/server/BuildConfig.java") +} + +tasks.register("runUnitTests") { + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} diff --git a/server/gradle.properties b/server/gradle.properties new file mode 100644 index 000000000..8aa9715ce --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=server +POM_NAME=Ably Pub/Sub server SDK +POM_DESCRIPTION=Ably Pub/Sub client for servers and other trusted backend environments. The recommended entry points are PubSubServer.httpClientBuilder(...) and PubSubServer.realtimeClientBuilder(...). +POM_PACKAGING=jar diff --git a/server/src/main/java/io/ably/pubsub/server/PubSubServer.java b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java new file mode 100644 index 000000000..ef2ad67da --- /dev/null +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -0,0 +1,122 @@ +package io.ably.pubsub.server; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for servers and other trusted backend environments. + *

+ * Clients built here declare themselves server-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-server} agent entry, which is how the platform + * classifies the traffic (and, on MAU-priced accounts using API-key auth, how it earns the + * server exemption). The side is the package's to declare — a caller-supplied agent entry + * cannot override it. + *

+ * These builders are the only recommended entry points of this artifact; the classes they + * construct come from {@code io.ably.pubsub:core}, which is an internal implementation + * artifact not intended for direct use. + */ +public final class PubSubServer { + private PubSubServer() {} + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(ClientOptions options) { + return new HttpClientBuilder(options, null); + } + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(String keyOrToken) { + return new HttpClientBuilder(null, keyOrToken); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(ClientOptions options) { + return new RealtimeClientBuilder(options, null); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(String keyOrToken) { + return new RealtimeClientBuilder(null, keyOrToken); + } + + /** + * Resolves the caller's input exactly as the core constructors would, then stamps the + * server-side agent entry. Resolution happens at {@code build()} time so the caller's + * input is read once, when the client is constructed. + */ + private static ClientOptions stampedOptions(ClientOptions options, String keyOrToken) throws AblyException { + if (keyOrToken != null) { + return Side.optionsWithSideAgent(keyOrToken, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + + /** + * Builds the HTTP (REST) client. Accepts everything the core constructor accepts. + */ + public static final class HttpClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private HttpClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRest build() throws AblyException { + return new AblyRest(stampedOptions(options, keyOrToken)); + } + } + + /** + * Builds the realtime client. Accepts everything the core constructor accepts. + */ + public static final class RealtimeClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private RealtimeClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + return new AblyRealtime(stampedOptions(options, keyOrToken)); + } + } +} diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java new file mode 100644 index 000000000..af02ffa31 --- /dev/null +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -0,0 +1,144 @@ +package io.ably.pubsub.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.sun.net.httpserver.HttpServer; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic (and, on + * MAU-priced accounts, what earns the server exemption), so these tests are deliberately + * strict: if one fails, billing classification is broken, not just a header. + */ +public class PubSubServerTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + private static final String FAKE_TOKEN = "fakeTokenString"; + + private static ClientOptions offlineOptions(String key) throws AblyException { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + @Test + public void httpClient_stampsServerAgent() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void realtimeClient_stampsServerAgent() throws AblyException { + AblyRealtime client = PubSubServer.realtimeClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_KEY).build(); + assertEquals(FAKE_KEY, client.options.key); + assertNull(client.options.token); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void tokenString_isAcceptedAndDisambiguatedAsToken() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_TOKEN).build(); + assertEquals(FAKE_TOKEN, client.options.token); + assertNull(client.options.key); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callerAgentEntries_arePreserved() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put("some-sdk", "1.2.3"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callerCannotOverrideTheSideEntry() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-version"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callersOptionsObject_isNotMutated() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + options.agents = callerAgents; + PubSubServer.httpClientBuilder(options).build(); + assertTrue(options.agents == callerAgents); + assertEquals(1, callerAgents.size()); + assertFalse(callerAgents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void nullOptions_getTheCoreConstructorsOwnError() { + try { + PubSubServer.httpClientBuilder((ClientOptions) null).build(); + fail("expected the core's initialization error"); + } catch (AblyException e) { + assertEquals(40000, e.errorInfo.code); + } + } + + /** + * Wire-level assertion: the Ably-Agent header actually sent over HTTP carries the + * side-declaring entry alongside the core's base identifier. This is the value billing + * classification reads. + */ + @Test + public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { + AtomicReference observedAgentHeader = new AtomicReference<>(); + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/time", exchange -> { + observedAgentHeader.set(exchange.getRequestHeaders().getFirst("Ably-Agent")); + byte[] body = "[1234567890000]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + httpServer.start(); + try { + ClientOptions options = offlineOptions(FAKE_KEY); + options.tls = false; + options.restHost = "127.0.0.1"; + options.port = httpServer.getAddress().getPort(); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + client.time(); + + String agentHeader = observedAgentHeader.get(); + assertNotNull("no Ably-Agent header observed", agentHeader); + assertTrue("missing side-declaring entry in: " + agentHeader, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/" + BuildConfig.VERSION)); + assertTrue("missing core base identifier in: " + agentHeader, + agentHeader.contains("ably-java/")); + } finally { + httpServer.stop(0); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ed2fc200d..c11795fed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,8 @@ rootProject.name = "ably-java" include("core") include("core-android") +include("device") +include("server") include("gradle-lint") include("network-client-core") include("network-client-default") diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java new file mode 100644 index 000000000..fb5205ea0 --- /dev/null +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -0,0 +1,98 @@ +package io.ably.pubsub.internal; + +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Internal helper shared by the {@code io.ably.pubsub:device} and {@code io.ably.pubsub:server} + * door artifacts. It is compiled into each artifact's output from a shared source directory + * rather than published, so that the two artifacts can share this code without a third + * artifact existing for it to live in. + *

+ * PDR-091 keeps {@code io.ably.pubsub:core} itself as the shared core, so nothing here may + * grow into a general abstraction over the core: it exists only to stamp the side a package + * declares. + */ +public final class Side { + private Side() {} + + /* + * The `-device` / `-server` suffix on both identifiers below is load-bearing, not + * cosmetic. On API-key auth the realtime system grants the server exemption by matching + * an agent entry ending in `-server`, and an identifier that is not yet in the + * ably-common registry is classified by that suffix alone. Renaming either without + * preserving its suffix silently reclassifies every client the package constructs. + * + * Both live here rather than in the package that uses each, so the naming scheme can be + * changed in one place. + */ + + /** The agent identifier declaring the device side, sent by {@code io.ably.pubsub:device}. */ + public static final String DEVICE_AGENT_IDENTIFIER = "ably-pubsub-device"; + + /** + * The agent identifier declaring the server side, sent by {@code io.ably.pubsub:server}. + *

+ * This is the entry that earns the MAU exemption on API-key auth, so its {@code -server} + * suffix is the one with billing consequences. + */ + public static final String SERVER_AGENT_IDENTIFIER = "ably-pubsub-server"; + + /** + * Returns a copy of the caller's options carrying the agent entry that declares this + * package's side. + *

+ * The copy is made with {@link ClientOptions#copy()} and a fresh agents map, so the + * caller's options and their own {@code agents} map are both left untouched. The + * caller's {@code agents} entries are preserved alongside the side stamp, so an SDK + * layered on top of this package keeps its attribution. The side stamp is applied last + * and so wins a collision on its own identifier: which side the package declares is the + * package's to state, not the caller's to redefine. + *

+ * {@code null} passes through unchanged rather than being defaulted, so a caller who + * passes nothing gets the core constructor's own initialization error ("no options + * provided") instead of constructing with only an {@code agents} entry and failing + * later with a vaguer authentication error. + * + * @param options the options the caller passed to the door's builder, or {@code null}. + * @param identifier the side-declaring agent identifier to stamp. + * @param version the version of the package doing the stamping. + * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}. + */ + public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier, String version) { + if (options == null) { + return null; + } + ClientOptions stamped = options.copy(); + Map agents = new LinkedHashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, version); + stamped.agents = agents; + return stamped; + } + + /** + * As {@link #optionsWithSideAgent(ClientOptions, String, String)}, for the API key or + * token string form the core constructors also accept. Reuses the core's own + * key-versus-token disambiguation ({@link ClientOptions#ClientOptions(String)}: an Ably + * API key always contains a colon, an Ably token never does). + * + * @param keyOrToken the Ably API key or token string the caller passed to the door's builder. + * @param identifier the side-declaring agent identifier to stamp. + * @param version the version of the package doing the stamping. + * @return stamped options constructed from the key or token. + * @throws AblyException if the key or token string is rejected by the core. + */ + public static ClientOptions optionsWithSideAgent(String keyOrToken, String identifier, String version) + throws AblyException { + ClientOptions options = new ClientOptions(keyOrToken); + options.agents = new LinkedHashMap<>(); + options.agents.put(identifier, version); + return options; + } +}