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..5418c02d1 --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.pubsub.device" + defaultConfig { + minSdk = 19 + compileSdk = 34 + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + 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..9b280f19b --- /dev/null +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -0,0 +1,72 @@ +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. + *

+ * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. + */ +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; + } + + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertDeviceFlag(Map agents) { + assertTrue("expected the device side flag", agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertFalse("a device client must not carry the server entry", + agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void client_stampsDeviceAgent() throws Exception { + AblyRealtime client = PubSubDevice.clientBuilder(offlineOptions(FAKE_KEY)).build(); + assertDeviceFlag(client.options.agents); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception { + ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options; + assertEquals(FAKE_KEY, builtOptions.key); + assertNull(builtOptions.token); + assertDeviceFlag(builtOptions.agents); + } + + @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-form"); + options.agents = callerAgents; + + AblyRealtime client = PubSubDevice.clientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertDeviceFlag(client.options.agents); + + // the caller's own map is untouched + assertTrue(options.agents == callerAgents); + assertEquals("not-the-real-form", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } +} 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..c838ee19e --- /dev/null +++ b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java @@ -0,0 +1,75 @@ +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 { + // The side entry is a versionless flag — see Side. + final ClientOptions stamped; + if (keyOrToken != null) { + stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER); + } else { + stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER); + } + return new AblyRealtime(stamped); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/transport/Defaults.java b/lib/src/main/java/io/ably/lib/transport/Defaults.java index 66e3e897c..8844bff11 100644 --- a/lib/src/main/java/io/ably/lib/transport/Defaults.java +++ b/lib/src/main/java/io/ably/lib/transport/Defaults.java @@ -14,7 +14,14 @@ public class Defaults { */ public static final String ABLY_PROTOCOL_VERSION = "6"; - public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-java", BuildConfig.VERSION); + /** + * The SDK family identifier. It renamed from {@code ably-java} with the per-side package + * split, so the identifier alone partitions the fleet: {@code ably-java/*} is legacy-package + * traffic, {@code ably-pubsub-java/*} is new-package traffic. It names the family rather than + * any one published artifact; the side a client declares travels as a separate versionless + * agent entry (see io.ably.pubsub.internal.Side and the agents registry in ably-common). + */ + public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-pubsub-java", BuildConfig.VERSION); /* realtime params */ public static final String ABLY_PROTOCOL_VERSION_PARAM = "v"; 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/test/realtime/RealtimeHttpHeaderTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java index 258c21368..88d280dc9 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java @@ -88,7 +88,7 @@ public void realtime_websocket_param_test() { * Defaults.ABLY_AGENT_PARAM, as ultimately the request param has been derived from those values. */ assertEquals("Verify correct lib version", requestParameters.get("agent"), - Collections.singletonList("ably-java/2.0.0 jre/" + System.getProperty("java.version"))); + Collections.singletonList("ably-pubsub-java/2.0.0 jre/" + System.getProperty("java.version"))); /* Spec RTN2a */ assertEquals("Verify correct format", requestParameters.get("format"), 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/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt index d91359b8c..a9f44104b 100644 --- a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt +++ b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt @@ -27,7 +27,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -35,7 +35,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -43,7 +43,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -59,7 +59,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -67,7 +67,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -75,7 +75,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -91,7 +91,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -99,7 +99,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -107,7 +107,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -123,7 +123,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -131,7 +131,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -139,7 +139,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 000000000..00e35300b --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + 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) +} + +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.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..ec310161b --- /dev/null +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -0,0 +1,123 @@ +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 (a versionless flag — see {@link Side}). 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); + } + return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER); + } + + /** + * 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..ee4a8d4eb --- /dev/null +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -0,0 +1,163 @@ +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.Arrays; +import java.util.HashMap; +import java.util.List; +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. + *

+ * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. + */ +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; + } + + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertServerFlag(Map agents) { + assertTrue("expected the server side flag", agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("a server client must not carry the device entry", + agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void httpClient_stampsServerAgent() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertServerFlag(client.options.agents); + } + + @Test + public void realtimeClient_stampsServerAgent() throws AblyException { + AblyRealtime client = PubSubServer.realtimeClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertServerFlag(client.options.agents); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_KEY).build(); + assertEquals(FAKE_KEY, client.options.key); + assertNull(client.options.token); + assertServerFlag(client.options.agents); + } + + @Test + public void tokenString_isAcceptedAndDisambiguatedAsToken() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_TOKEN).build(); + assertEquals(FAKE_TOKEN, client.options.token); + assertNull(client.options.key); + assertServerFlag(client.options.agents); + } + + @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")); + assertServerFlag(client.options.agents); + } + + @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-form"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertServerFlag(client.options.agents); + } + + @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 flag as a bare token 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); + List tokens = Arrays.asList(agentHeader.split(" ")); + // The flag must be present as a bare token: `name/anything` means the + // versionless stamp regressed (the registry entry is versionless). + assertTrue("missing bare side flag in: " + agentHeader, + tokens.contains(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("side flag must be versionless in: " + agentHeader, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/")); + assertTrue("missing core base identifier in: " + agentHeader, + agentHeader.contains("ably-pubsub-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..147f74c27 --- /dev/null +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -0,0 +1,103 @@ +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. + *

+ * The package split 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 side entry is a versionless flag — a bare token on the wire, like the + * platform's own {@code browser} entry — registered as such in the ably-common agents + * registry. Identity, version and support status keep + * travelling on the SDK's own {@code ably-pubsub-java/} entry alongside it; + * {@link io.ably.lib.util.AgentHeaderCreator} emits a map entry with a {@code null} + * value as a bare token. + *

+ * 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. + * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}. + */ + public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier) { + if (options == null) { + return null; + } + ClientOptions stamped = options.copy(); + Map agents = new LinkedHashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, null); + stamped.agents = agents; + return stamped; + } + + /** + * As {@link #optionsWithSideAgent(ClientOptions, 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. + * @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) + throws AblyException { + ClientOptions options = new ClientOptions(keyOrToken); + options.agents = new LinkedHashMap<>(); + options.agents.put(identifier, null); + return options; + } +}