diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java b/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java
new file mode 100644
index 000000000..8f7b575dc
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java
@@ -0,0 +1,253 @@
+package io.ably.pubsub.liveobjects;
+
+import com.google.gson.JsonElement;
+import io.ably.pubsub.liveobjects.http.HttpObjectGenerateIdBody;
+import io.ably.pubsub.liveobjects.http.HttpObjectGenerateIdResult;
+import io.ably.pubsub.liveobjects.http.HttpObjectGetFullResult;
+import io.ably.pubsub.liveobjects.http.HttpObjectOperation;
+import io.ably.pubsub.liveobjects.http.HttpObjectPublishResult;
+import io.ably.pubsub.types.AblyException;
+import io.ably.pubsub.types.Callback;
+import io.ably.pubsub.types.Param;
+import org.jetbrains.annotations.Blocking;
+import org.jetbrains.annotations.NonBlocking;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * The entry point to LiveObjects for an HTTP (REST) channel, accessed via
+ * {@code io.ably.pubsub.http.Channel#object}.
+ *
+ * Unlike {@link RealtimeObject}, this API is stateless: every call is an independent request to
+ * Ably's objects REST endpoint, and nothing is synchronized or cached locally. It is built into the
+ * HTTP client and needs no plugin.
+ *
+ *
Like the rest of the HTTP channel API, each operation comes in two forms: a blocking method
+ * that returns the result or throws an {@link AblyException}, and an {@code ...Async} method that
+ * reports the result or the error to a {@link Callback}, invoked on a background thread. Errors
+ * raised locally (for example invalid input) and errors returned by the server are reported the
+ * same way.
+ *
+ *
Implementations are thread-safe.
+ *
+ *
Spec: RSO1, RSL16
+ */
+public interface HttpObject {
+
+ /**
+ * Fetches the channel object in the compact format. Equivalent to {@code getCompact(null)}.
+ *
+ *
Spec: RSO2
+ *
+ * @return the compact representation; see {@link #getCompact(Param[])}.
+ * @throws AblyException if the request fails.
+ */
+ @Blocking
+ @Nullable
+ JsonElement getCompact() throws AblyException;
+
+ /**
+ * Fetches an object, or a value within it, from the channel in the compact format: the
+ * server's compacted representation as JSON, like {@code PathObject#compactJson()} on a
+ * realtime channel. A map is a {@link com.google.gson.JsonObject}, and a value is a JSON
+ * primitive; a map that references itself appears as {@code {"objectId": ...}} (RSO8).
+ *
+ *
The value is returned as the server sent it, with no further decoding, so a string that
+ * looks like Base64 or JSON stays that string (RSO2f). The result is the same whichever
+ * protocol is used, except that binary values, which the binary protocol transmits as raw
+ * bytes, are returned as Base64 strings, as the JSON protocol transmits them.
+ *
+ *
The {@code objectId} param, if any, selects the object and is sent as a path segment; every
+ * other param, such as {@code path}, goes in the query string. The format is set by this method,
+ * so a {@code compact} param is replaced.
+ *
+ *
{@code objectId} and {@code path} are sent to the server as given, without validation;
+ * an invalid value surfaces as the server's error (RSO2h, RSO6).
+ *
+ *
Spec: RSO2
+ *
+ * @param params the request params, built with {@link io.ably.pubsub.liveobjects.http.HttpObjectGetParams},
+ * or {@code null} to fetch the channel object.
+ * @return the compact representation, or {@code null} if the response has no body.
+ * @throws AblyException if the request fails or the response can't be decoded.
+ */
+ @Blocking
+ @Nullable
+ JsonElement getCompact(@Nullable Param[] params) throws AblyException;
+
+ /**
+ * Asynchronously fetches the channel object in the compact format; see {@link #getCompact()}.
+ *
+ *
Spec: RSO2
+ *
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void getCompactAsync(@NotNull Callback callback);
+
+ /**
+ * Asynchronously fetches an object, or a value within it, in the compact format; see
+ * {@link #getCompact(Param[])}.
+ *
+ * Spec: RSO2
+ *
+ * @param params the request params, built with {@link io.ably.pubsub.liveobjects.http.HttpObjectGetParams},
+ * or {@code null} to fetch the channel object.
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void getCompactAsync(@Nullable Param[] params, @NotNull Callback callback);
+
+ /**
+ * Fetches the channel object in the full format. Equivalent to {@code get(null)}.
+ *
+ * Spec: RSO2
+ *
+ * @return the full representation; see {@link #get(Param[])}.
+ * @throws AblyException if the request fails.
+ */
+ @Blocking
+ @Nullable
+ HttpObjectGetFullResult get() throws AblyException;
+
+ /**
+ * Fetches an object, or a value within it, from the channel in the full format (the spec's
+ * {@code compact: false}), decoded into typed results: a
+ * {@link io.ably.pubsub.liveobjects.http.HttpLiveMap},
+ * {@link io.ably.pubsub.liveobjects.http.HttpLiveCounter}, a decoded
+ * {@link io.ably.pubsub.liveobjects.http.HttpObjectData} leaf, or, for an object type this
+ * library doesn't recognise, a plain {@link io.ably.pubsub.liveobjects.http.HttpLiveObject}
+ * (RSO2g).
+ *
+ *
The {@code objectId} param, if any, selects the object and is sent as a path segment; every
+ * other param, such as {@code path}, goes in the query string. The format is set by this method,
+ * so a {@code compact} param is replaced.
+ *
+ *
{@code objectId} and {@code path} are sent to the server as given, without validation;
+ * an invalid value surfaces as the server's error (RSO2h, RSO6).
+ *
+ *
Spec: RSO2
+ *
+ * @param params the request params, built with {@link io.ably.pubsub.liveobjects.http.HttpObjectGetParams},
+ * or {@code null} to fetch the channel object.
+ * @return the decoded result, or {@code null} if the response has no body.
+ * @throws AblyException if the request fails or the response can't be decoded.
+ */
+ @Blocking
+ @Nullable
+ HttpObjectGetFullResult get(@Nullable Param[] params) throws AblyException;
+
+ /**
+ * Asynchronously fetches the channel object in the full format; see {@link #get()}.
+ *
+ *
Spec: RSO2
+ *
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void getAsync(@NotNull Callback callback);
+
+ /**
+ * Asynchronously fetches an object, or a value within it, in the full format; see
+ * {@link #get(Param[])}.
+ *
+ * Spec: RSO2
+ *
+ * @param params the request params, built with {@link io.ably.pubsub.liveobjects.http.HttpObjectGetParams},
+ * or {@code null} to fetch the channel object.
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void getAsync(@Nullable Param[] params, @NotNull Callback callback);
+
+ /**
+ * Publishes a single operation. Equivalent to publishing a one-element list.
+ *
+ * Spec: RSO3
+ *
+ * @param operation the operation to publish.
+ * @return the {@link HttpObjectPublishResult}.
+ * @throws AblyException if the operation is invalid or the request fails.
+ */
+ @Blocking
+ @NotNull
+ HttpObjectPublishResult publish(@NotNull HttpObjectOperation operation) throws AblyException;
+
+ /**
+ * Publishes operations in a single request, in the given order.
+ *
+ *
The library does not generate operation ids, even when
+ * {@code ClientOptions.idempotentRestPublishing} is enabled (RSO3f); set
+ * {@link HttpObjectOperation#withId(String)} for idempotent publishing. The library doesn't
+ * validate targeting or the number of operations; the server rejects invalid batches (RSO3g, RSO3h).
+ * Invalid input (for example a non-finite number or a null value) fails before any request is sent.
+ *
+ *
A request that fails with a server or connection error is retried on fallback hosts, like
+ * other REST requests. The server de-duplicates operations by id, so set ids if a batch must
+ * not be applied twice: without them, a batch the first host applied before failing is applied
+ * again by the retry.
+ *
+ *
Spec: RSO3
+ *
+ * @param operations the operations to publish.
+ * @return the {@link HttpObjectPublishResult}.
+ * @throws AblyException if an operation is invalid or the request fails.
+ */
+ @Blocking
+ @NotNull
+ HttpObjectPublishResult publish(@NotNull List operations) throws AblyException;
+
+ /**
+ * Asynchronously publishes a single operation; see {@link #publish(HttpObjectOperation)}.
+ *
+ * Spec: RSO3
+ *
+ * @param operation the operation to publish.
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void publishAsync(@NotNull HttpObjectOperation operation, @NotNull Callback callback);
+
+ /**
+ * Asynchronously publishes operations in a single request; see {@link #publish(List)}.
+ *
+ * Spec: RSO3
+ *
+ * @param operations the operations to publish.
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void publishAsync(@NotNull List operations, @NotNull Callback callback);
+
+ /**
+ * Generates an object ID for a map or counter create body, so that a batch can reference a new
+ * object before the server has created it. Pass the result to
+ * {@link HttpObjectOperation#mapCreateWithObjectId(HttpObjectGenerateIdResult)} or
+ * {@link HttpObjectOperation#counterCreateWithObjectId(HttpObjectGenerateIdResult)}.
+ *
+ * Nothing is published. The ID embeds the current server time, which is fetched from Ably the
+ * first time this client needs it (RTO16); later calls don't go to the network.
+ *
+ *
Spec: RSO4
+ *
+ * @param createBody the map or counter create body the ID is derived from.
+ * @return the generated {@link HttpObjectGenerateIdResult}.
+ * @throws AblyException if the body is invalid or the server time can't be retrieved.
+ */
+ @Blocking
+ @NotNull
+ HttpObjectGenerateIdResult generateObjectId(@NotNull HttpObjectGenerateIdBody createBody) throws AblyException;
+
+ /**
+ * Asynchronously generates an object ID; see {@link #generateObjectId(HttpObjectGenerateIdBody)}.
+ *
+ *
Spec: RSO4
+ *
+ * @param createBody the map or counter create body the ID is derived from.
+ * @param callback receives the result or the error, on a background thread.
+ */
+ @NonBlocking
+ void generateObjectIdAsync(@NotNull HttpObjectGenerateIdBody createBody, @NotNull Callback callback);
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java
new file mode 100644
index 000000000..5d5fc29f0
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java
@@ -0,0 +1,326 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonElement;
+import io.ably.pubsub.http.HttpCore;
+import io.ably.pubsub.http.HttpRequester;
+import io.ably.pubsub.http.HttpUtils;
+import io.ably.pubsub.http.PubSubBase;
+import io.ably.pubsub.liveobjects.HttpObject;
+import io.ably.pubsub.types.AblyException;
+import io.ably.pubsub.types.Callback;
+import io.ably.pubsub.types.ErrorInfo;
+import io.ably.pubsub.types.Param;
+import io.ably.pubsub.util.Base64Coder;
+import io.ably.pubsub.util.Crypto;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * [Internal] The {@link HttpObject} of one HTTP channel. Stateless apart from the client-wide
+ * {@link HttpObjectServerTime}; obtain it from {@code channel.object}.
+ *
+ * Each operation is built once as an {@link HttpRequester.Request} and run with
+ * {@link HttpRequester.Request#sync()} or {@link HttpRequester.Request#async(Callback)}, like the
+ * rest of the HTTP channel API. Async callbacks, including those for invalid input, are invoked on
+ * the scheduler's background thread.
+ *
+ *
Spec: RSO1-RSO4
+ */
+public final class DefaultHttpObject implements HttpObject {
+
+ private static final String NONCE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ private static final int NONCE_LENGTH = 16; // RTO11f6 - at least 16 characters
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final String COMPACT = "compact";
+
+ private final PubSubBase client;
+ private final String basePath;
+ private final HttpObjectServerTime serverTime;
+
+ /**
+ * @param client the HTTP client requests are sent through.
+ * @param channelBasePath the channel's REST path, {@code /channels/}.
+ * @param serverTime the client's shared server time.
+ */
+ public DefaultHttpObject(@NotNull PubSubBase client, @NotNull String channelBasePath, @NotNull HttpObjectServerTime serverTime) {
+ this.client = client;
+ this.basePath = channelBasePath + "/object";
+ this.serverTime = serverTime;
+ }
+
+ //region get (RSO2)
+
+ @Override
+ public @Nullable JsonElement getCompact() throws AblyException {
+ return compactImpl(null).sync();
+ }
+
+ @Override
+ public @Nullable JsonElement getCompact(@Nullable Param[] params) throws AblyException {
+ return compactImpl(params).sync();
+ }
+
+ @Override
+ public void getCompactAsync(@NotNull Callback callback) {
+ compactImpl(null).async(callback);
+ }
+
+ @Override
+ public void getCompactAsync(@Nullable Param[] params, @NotNull Callback callback) {
+ compactImpl(params).async(callback);
+ }
+
+ @Override
+ public @Nullable HttpObjectGetFullResult get() throws AblyException {
+ return fullImpl(null).sync();
+ }
+
+ @Override
+ public @Nullable HttpObjectGetFullResult get(@Nullable Param[] params) throws AblyException {
+ return fullImpl(params).sync();
+ }
+
+ @Override
+ public void getAsync(@NotNull Callback callback) {
+ fullImpl(null).async(callback);
+ }
+
+ @Override
+ public void getAsync(@Nullable Param[] params, @NotNull Callback callback) {
+ fullImpl(params).async(callback);
+ }
+
+ /** RSO2f - the compact body as JSON, otherwise unchanged; binary values become Base64 strings. */
+ private HttpRequester.Request compactImpl(@Nullable Param[] params) {
+ // compact is the server default, so it isn't sent (RSO2d)
+ return getImpl(params, null, body -> body == null ? null : HttpObjectCodec.toJson(body));
+ }
+
+ /** RSO2g - the full body decoded into typed results. */
+ private HttpRequester.Request fullImpl(@Nullable Param[] params) {
+ return getImpl(params, new Param(COMPACT, "false"),
+ body -> body == null ? null : HttpObjectCodec.decodeFullNode(body));
+ }
+
+ private HttpRequester.Request getImpl(@Nullable Param[] params, @Nullable Param format,
+ BodyDecoder decoder) {
+ // RSO2c - the objectId, if any, is a percent-encoded path segment
+ String objectId = Param.getFirst(params, HttpObjectGetParams.OBJECT_ID);
+ final String path = objectId != null ? basePath + "/" + HttpUtils.encodeURIComponent(objectId) : basePath;
+
+ // RSO2d, RSO6 - every other param, such as path, goes in the query string unmodified; the
+ // format is this method's, so a caller's compact param is dropped
+ List query = new ArrayList<>();
+ if (params != null) {
+ for (Param param : params) {
+ if (param.key.equals(HttpObjectGetParams.OBJECT_ID) || param.key.equals(COMPACT)) continue;
+ query.add(param);
+ }
+ }
+ if (format != null) query.add(format);
+ if (client.options.addRequestIds) query.add(Crypto.generateRandomRequestId()); // RSC7c
+ final Param[] queryParams = query.toArray(new Param[0]);
+
+ // RSO2e - the body is decoded per RSC8 before the decoder runs; no body is null
+ return client.http.request((http, callback) ->
+ http.get(path, HttpUtils.defaultAcceptHeaders(binary()), queryParams, responseHandler(decoder), true, callback));
+ }
+
+ //endregion
+
+ //region publish (RSO3)
+
+ @Override
+ public @NotNull HttpObjectPublishResult publish(@NotNull HttpObjectOperation operation) throws AblyException {
+ return publishImpl(Collections.singletonList(operation)).sync();
+ }
+
+ @Override
+ public @NotNull HttpObjectPublishResult publish(@NotNull List operations) throws AblyException {
+ return publishImpl(operations).sync();
+ }
+
+ @Override
+ public void publishAsync(@NotNull HttpObjectOperation operation, @NotNull Callback callback) {
+ publishImpl(Collections.singletonList(operation)).async(callback);
+ }
+
+ @Override
+ public void publishAsync(@NotNull List operations, @NotNull Callback callback) {
+ publishImpl(operations).async(callback);
+ }
+
+ private HttpRequester.Request publishImpl(final List operations) {
+ final Param[] query = client.options.addRequestIds ? new Param[]{Crypto.generateRandomRequestId()} : new Param[0];
+ return client.http.request((http, callback) -> {
+ // Encoded before anything is sent, so invalid input fails without a request (RSO3d1).
+ // No ids are generated, whatever idempotentRestPublishing says (RSO3f).
+ final HttpCore.RequestBody body;
+ try {
+ body = encodeOperations(operations);
+ } catch (AblyException e) {
+ http.execute(() -> callback.onError(e.errorInfo));
+ return;
+ }
+ // RSO3c, RSO3e - one POST carrying all operations as an array, retried on fallback hosts
+ // like other REST requests and as ably-js does. No ids are generated (RSO3f), so a batch
+ // without ids that the first host applied before failing can be applied twice.
+ http.post(basePath, HttpUtils.defaultAcceptHeaders(binary()), query, body,
+ responseHandler(HttpObjectCodec::decodePublishResult), true, callback);
+ });
+ }
+
+ private HttpCore.RequestBody encodeOperations(List operations) throws AblyException {
+ if (operations == null) throw HttpObjectCodec.invalidInput("operations must not be null");
+ try {
+ List wireOperations = new ArrayList<>();
+ for (HttpObjectOperation operation : operations) {
+ if (operation == null) throw HttpObjectCodec.invalidInput("operations must not contain null");
+ wireOperations.add(HttpObjectCodec.encodeOperation(operation, binary()));
+ }
+ return HttpObjectCodec.requestBody(wireOperations, binary());
+ } catch (RuntimeException e) {
+ // e.g. a null where the typed API expects a value; report it as invalid input
+ throw HttpObjectCodec.invalidInput("Invalid operation: " + e);
+ }
+ }
+
+ //endregion
+
+ //region generateObjectId (RSO4)
+
+ @Override
+ public @NotNull HttpObjectGenerateIdResult generateObjectId(@NotNull HttpObjectGenerateIdBody createBody) throws AblyException {
+ CreateBody body = CreateBody.of(createBody);
+ return body.withTimestamp(serverTime.currentTimeMillis());
+ }
+
+ @Override
+ public void generateObjectIdAsync(@NotNull final HttpObjectGenerateIdBody createBody,
+ @NotNull Callback callback) {
+ // Runs on the scheduler, so the callback is invoked on a background thread even when the
+ // input is invalid or the server time is already known.
+ client.http.request((http, cb) -> http.execute(() -> {
+ final CreateBody body;
+ try {
+ body = CreateBody.of(createBody);
+ } catch (AblyException e) {
+ cb.onError(e.errorInfo);
+ return;
+ }
+ serverTime.currentTimeMillisAsync(new Callback() {
+ @Override
+ public void onSuccess(Long timestamp) {
+ cb.onSuccess(body.withTimestamp(timestamp));
+ }
+
+ @Override
+ public void onError(ErrorInfo reason) {
+ cb.onError(reason);
+ }
+ });
+ })).async(callback);
+ }
+
+ /** The type, initialValue and nonce of a generateObjectId call, before the server time is known. */
+ private static final class CreateBody {
+ private final String type;
+ private final String initialValue;
+ private final String nonce;
+
+ private CreateBody(String type, String initialValue) {
+ this.type = type;
+ this.initialValue = initialValue;
+ this.nonce = generateNonce(); // RSO4f
+ }
+
+ /** RSO4c, RSO4d, RSO4e - the type and the initialValue, always JSON-encoded whatever the protocol. */
+ static CreateBody of(HttpObjectGenerateIdBody createBody) throws AblyException {
+ try {
+ return encode(createBody);
+ } catch (RuntimeException e) {
+ // e.g. a null entry value; report it as invalid input
+ throw HttpObjectCodec.invalidInput("Invalid create body: " + e);
+ }
+ }
+
+ private static CreateBody encode(HttpObjectGenerateIdBody createBody) throws AblyException {
+ if (createBody != null && createBody.getMapCreate() != null) {
+ return new CreateBody("map",
+ HttpObjectCodec.toJson(HttpObjectCodec.encodeMapCreate(createBody.getMapCreate(), false)).toString());
+ }
+ if (createBody != null && createBody.getCounterCreate() != null) {
+ return new CreateBody("counter",
+ HttpObjectCodec.toJson(HttpObjectCodec.encodeCounterCreate(createBody.getCounterCreate())).toString());
+ }
+ throw HttpObjectCodec.invalidInput("generateObjectId requires a mapCreate or counterCreate body");
+ }
+
+ /** RSO4h, RSO4i */
+ HttpObjectGenerateIdResult withTimestamp(long timestamp) {
+ return new HttpObjectResults.GenerateIdResult(objectId(type, initialValue, nonce, timestamp), nonce, initialValue);
+ }
+ }
+
+ //endregion
+
+ private boolean binary() {
+ return client.options.useBinaryProtocol;
+ }
+
+ /** Decodes a response body that has already been converted to plain values. */
+ private interface BodyDecoder {
+ T decode(Object body) throws AblyException;
+ }
+
+ /**
+ * Surfaces server errors as the request's failure (RSO2h, RSO3g) and decodes the body: first
+ * its format (RSC8), then its structure with {@code decoder}. A body that can't be decoded fails
+ * as a plain {@link AblyException}, never a {@code HostFailedException}, so the request is not
+ * retried on a fallback host.
+ */
+ private static HttpCore.ResponseHandler responseHandler(final BodyDecoder decoder) {
+ return (response, error) -> {
+ if (error != null) throw AblyException.fromErrorInfo(error);
+ Object body;
+ try {
+ body = HttpObjectCodec.decodeResponseBody(response);
+ } catch (Exception e) {
+ throw HttpObjectCodec.decodeError("Failed to decode response body: " + e.getMessage(), e);
+ }
+ return decoder.decode(body);
+ };
+ }
+
+ /**
+ * {@code [type]:[base64url(SHA-256(utf8("[initialValue]:[nonce]")))]@[timestamp]}, without padding.
+ * Spec: RTO14
+ */
+ private static String objectId(String type, String initialValue, String nonce, long timestamp) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256")
+ .digest((initialValue + ":" + nonce).getBytes(Charset.forName("UTF-8")));
+ String base64Url = Base64Coder.encodeToString(hash)
+ .replace('+', '-').replace('/', '_').replace("=", "");
+ return type + ":" + base64Url + "@" + timestamp;
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e); // every Java platform provides it
+ }
+ }
+
+ private static String generateNonce() {
+ StringBuilder nonce = new StringBuilder(NONCE_LENGTH);
+ for (int i = 0; i < NONCE_LENGTH; i++) {
+ nonce.append(NONCE_CHARS.charAt(RANDOM.nextInt(NONCE_CHARS.length())));
+ }
+ return nonce.toString();
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveCounter.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveCounter.java
new file mode 100644
index 000000000..f342085ac
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveCounter.java
@@ -0,0 +1,18 @@
+package io.ably.pubsub.liveobjects.http;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A counter object in a {@link io.ably.pubsub.liveobjects.HttpObject#get(io.ably.pubsub.types.Param[])}
+ * response.
+ *
+ *
Spec: RSO15
+ */
+public interface HttpLiveCounter extends HttpLiveObject {
+
+ /**
+ * @return the counter's current value (the wire field {@code counter.data.number}, RSO15b), or
+ * {@code null} if the response didn't carry a numeric value there.
+ */
+ @Nullable Double getCount();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMap.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMap.java
new file mode 100644
index 000000000..69e6ed961
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMap.java
@@ -0,0 +1,26 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Map;
+
+/**
+ * A map object in a {@link io.ably.pubsub.liveobjects.HttpObject#get(io.ably.pubsub.types.Param[])}
+ * response. Nested objects and values in its entries are decoded recursively.
+ *
+ *
Spec: RSO14
+ */
+public interface HttpLiveMap extends HttpLiveObject {
+
+ /**
+ * @return the map's conflict-resolution semantics, or {@link ObjectsMapSemantics#UNKNOWN} for a
+ * value this library doesn't recognise (RSO14b).
+ */
+ @NotNull ObjectsMapSemantics getSemantics();
+
+ /**
+ * @return an unmodifiable map of the map's entries, by key (RSO14c).
+ */
+ @NotNull Map getEntries();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMapEntry.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMapEntry.java
new file mode 100644
index 000000000..7b07bab7e
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMapEntry.java
@@ -0,0 +1,16 @@
+package io.ably.pubsub.liveobjects.http;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An entry of an {@link HttpLiveMap}.
+ *
+ * Spec: RSO16
+ */
+public interface HttpLiveMapEntry {
+
+ /**
+ * @return the entry's value: a nested object or a decoded leaf (RSO16a).
+ */
+ @NotNull HttpObjectGetFullResult getData();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveObject.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveObject.java
new file mode 100644
index 000000000..761cd0c1c
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveObject.java
@@ -0,0 +1,28 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonObject;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An object in a {@link io.ably.pubsub.liveobjects.HttpObject#get(io.ably.pubsub.types.Param[])}
+ * response. Known types are the subtypes {@link HttpLiveMap} and {@link HttpLiveCounter}; an object
+ * whose type this library doesn't recognise is returned as a plain {@code HttpLiveObject}.
+ *
+ *
Spec: RSO9d
+ */
+public interface HttpLiveObject extends HttpObjectGetFullResult {
+
+ /**
+ * @return the object's ID.
+ */
+ @NotNull String getObjectId();
+
+ /**
+ * Fields of the object the library doesn't model, passed through unchanged (RSF1, RSO2g4).
+ * For an unrecognised object type this holds its type-specific data. Values are JSON, as in a
+ * {@code getCompact} result: binary values are Base64 strings for both protocols.
+ *
+ * @return an unmodifiable map of field name to value; empty if there are none.
+ */
+ @NotNull JsonObject getUnrecognizedFields();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectCodec.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectCodec.java
new file mode 100644
index 000000000..e35d77369
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectCodec.java
@@ -0,0 +1,482 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+import io.ably.pubsub.http.HttpCore;
+import io.ably.pubsub.http.HttpUtils;
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics;
+import io.ably.pubsub.types.AblyException;
+import io.ably.pubsub.types.ErrorInfo;
+import io.ably.pubsub.util.Base64Coder;
+import org.msgpack.core.MessageBufferPacker;
+import org.msgpack.core.MessagePack;
+import org.msgpack.core.MessagePacker;
+import org.msgpack.core.MessageUnpacker;
+import org.msgpack.value.IntegerValue;
+import org.msgpack.value.Value;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Wire encoding and decoding for the objects REST endpoint.
+ *
+ *
Request and response bodies are handled as a format-neutral tree of plain Java values
+ * ({@code Map}, {@code List}, {@code String}, {@code Long} (or
+ * {@code BigInteger} beyond the long range), {@code Double}, {@code Boolean}, {@code byte[]},
+ * {@code null}), the same for both protocols, which is what {@code getCompact} returns to the
+ * caller verbatim (RSO2f). The tree is converted to and from JSON or MessagePack at the edges (RSC8).
+ */
+final class HttpObjectCodec {
+
+ private static final String MSGPACK_CONTENT_TYPE = "application/x-msgpack";
+ private static final Charset UTF_8 = Charset.forName("UTF-8");
+ private static final List LEAF_VALUE_FIELDS = Arrays.asList("string", "number", "boolean", "bytes", "json");
+
+ private HttpObjectCodec() {}
+
+ //region Operations -> wire tree (RSO3d, RSO4e)
+
+ /**
+ * Encodes an operation for the wire (RSO3d). {@code id}, {@code objectId} and {@code path} are
+ * copied as given (RSO3d3); there is no {@code action} field, the server infers it from the body
+ * field (RSO3d4).
+ */
+ static Map encodeOperation(HttpObjectOperation op, boolean binary) throws AblyException {
+ Map wire = new LinkedHashMap<>();
+ putIfNotNull(wire, "id", op.getId());
+ putIfNotNull(wire, "objectId", op.getObjectId());
+ putIfNotNull(wire, "path", op.getPath());
+ if (op.getMapCreate() != null) {
+ wire.put("mapCreate", encodeMapCreate(op.getMapCreate(), binary));
+ }
+ if (op.getMapCreateWithObjectId() != null) {
+ wire.put("mapCreateWithObjectId", encodeCreateWithObjectId(op.getMapCreateWithObjectId()));
+ }
+ if (op.getMapSet() != null) {
+ Map mapSet = new LinkedHashMap<>();
+ mapSet.put("key", op.getMapSet().getKey());
+ mapSet.put("value", encodeData(op.getMapSet().getValue(), binary));
+ wire.put("mapSet", mapSet);
+ }
+ if (op.getMapRemove() != null) {
+ wire.put("mapRemove", singleton("key", op.getMapRemove().getKey()));
+ }
+ if (op.getCounterCreate() != null) {
+ wire.put("counterCreate", encodeCounterCreate(op.getCounterCreate()));
+ }
+ if (op.getCounterCreateWithObjectId() != null) {
+ wire.put("counterCreateWithObjectId", encodeCreateWithObjectId(op.getCounterCreateWithObjectId()));
+ }
+ if (op.getCounterInc() != null) {
+ wire.put("counterInc", singleton("number", finite(op.getCounterInc().getNumber(), "counterInc.number")));
+ }
+ return wire;
+ }
+
+ /** RSO3d1/RSO4e1a semantics per OMP2, RSO3d2/RSO4e1b entry data per OD4. */
+ static Map encodeMapCreate(HttpObjectOperation.MapCreate body, boolean binary) throws AblyException {
+ Map entries = new LinkedHashMap<>();
+ for (Map.Entry entry : body.getEntries().entrySet()) {
+ entries.put(entry.getKey(), singleton("data", encodeData(entry.getValue(), binary)));
+ }
+ Map mapCreate = new LinkedHashMap<>();
+ mapCreate.put("semantics", encodeSemantics(body.getSemantics()));
+ mapCreate.put("entries", entries);
+ return mapCreate;
+ }
+
+ static Map encodeCounterCreate(HttpObjectOperation.CounterCreate body) throws AblyException {
+ return singleton("count", finite(body.getCount(), "counterCreate.count"));
+ }
+
+ private static Map encodeCreateWithObjectId(HttpObjectOperation.CreateWithObjectId body) {
+ Map wire = new LinkedHashMap<>();
+ wire.put("initialValue", body.getInitialValue());
+ wire.put("nonce", body.getNonce());
+ return wire;
+ }
+
+ /** OMP2: semantics travel as their integer code. */
+ private static int encodeSemantics(ObjectsMapSemantics semantics) throws AblyException {
+ if (semantics == ObjectsMapSemantics.LWW) {
+ return 0;
+ }
+ throw invalidInput("Unrecognized map semantics: " + semantics);
+ }
+
+ /**
+ * Encodes a leaf value per OD4 (RSO3d2): with MessagePack, binary stays binary (OD4c); with JSON,
+ * it is Base64-encoded (OD4d2). A JSON object or array is sent as a JSON-encoded string in the
+ * {@code json} field with both protocols (OD4c5, OD4d5).
+ */
+ private static Map encodeData(PublishObjectData data, boolean binary) throws AblyException {
+ if (data == null) throw invalidInput("A map value must not be null");
+ if (data.getString() != null) return singleton("string", data.getString());
+ if (data.getNumber() != null) return singleton("number", finite(data.getNumber(), "number"));
+ if (data.getBoolean() != null) return singleton("boolean", data.getBoolean());
+ if (data.getBytes() != null) {
+ byte[] bytes = data.getBytes();
+ return singleton("bytes", binary ? bytes : Base64Coder.encodeToString(bytes));
+ }
+ if (data.getJson() != null) return singleton("json", data.getJson().toString());
+ if (data.getObjectId() != null) return singleton("objectId", data.getObjectId());
+ throw invalidInput("PublishObjectData has no value set"); // unreachable via the public factories
+ }
+
+ //endregion
+
+ //region Full-format response decoding (RSO2g)
+
+ /**
+ * Decodes a node of a {@code get} response (RSO2g). A node with {@code map} is a map
+ * whose entries are decoded recursively (RSO2g1), one with {@code counter} a counter (RSO2g2). A
+ * node that carries only an {@code objectId} and fields this library doesn't know is an object
+ * of an unrecognised type, passed through (RSO2g4, RSO9d). Anything else is a leaf (RSO2g3).
+ */
+ static HttpObjectGetFullResult decodeFullNode(Object node) throws AblyException {
+ try {
+ return decodeNode(node);
+ } catch (RuntimeException e) {
+ // e.g. a malformed JSON string or Base64 value in a leaf
+ throw decodeError("Failed to decode full-format response: " + e.getMessage(), e);
+ }
+ }
+
+ private static HttpObjectGetFullResult decodeNode(Object node) throws AblyException {
+ if (!(node instanceof Map)) {
+ throw decodeError("Unexpected full-format node: " + node, null);
+ }
+ Map, ?> fields = (Map, ?>) node;
+ String objectId = asString(fields.get("objectId"));
+ Object map = fields.get("map");
+ Object counter = fields.get("counter");
+ if (map instanceof Map) {
+ return decodeMap(objectId, (Map, ?>) map, fields);
+ }
+ if (counter instanceof Map) {
+ return decodeCounter(objectId, (Map, ?>) counter, fields);
+ }
+ if (objectId != null && !hasLeafValue(fields)) {
+ return new HttpObjectResults.LiveObject(objectId, unrecognized(fields, "objectId"));
+ }
+ return decodeLeaf(fields);
+ }
+
+ private static HttpLiveMap decodeMap(String objectId, Map, ?> map, Map, ?> fields) throws AblyException {
+ Map entries = new LinkedHashMap<>();
+ Object rawEntries = map.get("entries");
+ if (rawEntries instanceof Map) {
+ for (Map.Entry, ?> entry : ((Map, ?>) rawEntries).entrySet()) {
+ if (!(entry.getValue() instanceof Map)) continue;
+ Object data = ((Map, ?>) entry.getValue()).get("data");
+ if (data == null) continue;
+ entries.put(String.valueOf(entry.getKey()), new HttpObjectResults.LiveMapEntry(decodeNode(data)));
+ }
+ }
+ return new HttpObjectResults.LiveMap(
+ objectId != null ? objectId : "",
+ decodeSemantics(map.get("semantics")),
+ entries,
+ unrecognized(fields, "objectId", "map"));
+ }
+
+ /** A counter whose value isn't a number at {@code counter.data.number} has a null count, not 0. */
+ private static HttpLiveCounter decodeCounter(String objectId, Map, ?> counter, Map, ?> fields) {
+ Double count = null;
+ Object data = counter.get("data");
+ if (data instanceof Map && ((Map, ?>) data).get("number") instanceof Number) {
+ count = ((Number) ((Map, ?>) data).get("number")).doubleValue();
+ }
+ return new HttpObjectResults.LiveCounter(
+ objectId != null ? objectId : "", count, unrecognized(fields, "objectId", "counter"));
+ }
+
+ /**
+ * RSO14b: the REST API returns semantics as a name ({@code "LWW"}), the realtime protocol as an
+ * OMP2 code ({@code 0}); accept both. Anything else is preserved as UNKNOWN.
+ */
+ private static ObjectsMapSemantics decodeSemantics(Object value) {
+ if (value instanceof Number && ((Number) value).longValue() == 0L) return ObjectsMapSemantics.LWW;
+ if (value instanceof String && "lww".equalsIgnoreCase((String) value)) return ObjectsMapSemantics.LWW;
+ return ObjectsMapSemantics.UNKNOWN;
+ }
+
+ /**
+ * Decodes a leaf per OD5 (RSO2g3): Base64 {@code bytes} become binary (OD5b2), and the
+ * JSON-encoded string in {@code json} is parsed (OD5a2, OD5b3). The result carries no
+ * {@code objectId} or {@code encoding} (RSO9c1).
+ */
+ private static HttpObjectData decodeLeaf(Map, ?> fields) {
+ final String string = asString(fields.get("string"));
+ JsonElement json = null;
+ Object rawJson = fields.get("json");
+ if (rawJson instanceof String) {
+ json = JsonParser.parseString((String) rawJson);
+ } else if (rawJson != null) {
+ json = toJson(rawJson);
+ }
+ byte[] bytes = null;
+ Object rawBytes = fields.get("bytes");
+ if (rawBytes instanceof byte[]) {
+ bytes = (byte[]) rawBytes;
+ } else if (rawBytes instanceof String) {
+ bytes = Base64Coder.decode((String) rawBytes);
+ }
+ Object number = fields.get("number");
+ Object bool = fields.get("boolean");
+ return new HttpObjectResults.ObjectDataLeaf(
+ string,
+ number instanceof Number ? ((Number) number).doubleValue() : null,
+ bool instanceof Boolean ? (Boolean) bool : null,
+ bytes,
+ json);
+ }
+
+ private static boolean hasLeafValue(Map, ?> fields) {
+ for (String field : LEAF_VALUE_FIELDS) {
+ if (fields.get(field) != null) return true;
+ }
+ return false;
+ }
+
+ /** The fields not in {@code known}, as JSON; binary values become Base64 strings. */
+ private static JsonObject unrecognized(Map, ?> fields, String... known) {
+ List knownFields = Arrays.asList(known);
+ JsonObject result = new JsonObject();
+ for (Map.Entry, ?> entry : fields.entrySet()) {
+ String key = String.valueOf(entry.getKey());
+ if (!knownFields.contains(key)) result.add(key, toJson(entry.getValue()));
+ }
+ return result;
+ }
+
+ //endregion
+
+ //region Publish result (RSO10)
+
+ static HttpObjectPublishResult decodePublishResult(Object body) throws AblyException {
+ if (!(body instanceof Map)) {
+ throw decodeError("Unexpected publish response: " + body, null);
+ }
+ Map, ?> fields = (Map, ?>) body;
+ List objectIds = new ArrayList<>();
+ Object rawIds = fields.get("objectIds");
+ if (rawIds instanceof List) {
+ for (Object id : (List>) rawIds) {
+ if (id instanceof String) objectIds.add((String) id);
+ }
+ }
+ String messageId = asString(fields.get("messageId"));
+ String channel = asString(fields.get("channel"));
+ return new HttpObjectResults.PublishResult(
+ messageId != null ? messageId : "", channel != null ? channel : "", objectIds);
+ }
+
+ //endregion
+
+ //region Format conversion (RSC8)
+
+ static HttpCore.RequestBody requestBody(Object tree, boolean binary) throws AblyException {
+ if (binary) {
+ return new HttpUtils.ByteArrayRequestBody(toMsgpack(tree), MSGPACK_CONTENT_TYPE);
+ }
+ return new HttpUtils.JsonRequestBody(toJson(tree).toString());
+ }
+
+ /** Decodes a response body per its content type into plain values. */
+ static Object decodeResponseBody(HttpCore.Response response) throws IOException {
+ byte[] body = response.body;
+ if (body == null || body.length == 0) return null;
+ if (response.contentType != null && response.contentType.toLowerCase().contains("msgpack")) {
+ try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(body)) {
+ return fromMsgpack(unpacker.unpackValue());
+ }
+ }
+ return fromJson(JsonParser.parseString(new String(body, UTF_8)));
+ }
+
+ /** Converts a plain-value tree to JSON. Binary values become Base64 strings. */
+ static JsonElement toJson(Object value) {
+ if (value == null) return JsonNull.INSTANCE;
+ if (value instanceof JsonElement) return (JsonElement) value;
+ if (value instanceof String) return new JsonPrimitive((String) value);
+ if (value instanceof Number) return new JsonPrimitive((Number) value);
+ if (value instanceof Boolean) return new JsonPrimitive((Boolean) value);
+ if (value instanceof byte[]) return new JsonPrimitive(Base64Coder.encodeToString((byte[]) value));
+ if (value instanceof Map) {
+ JsonObject object = new JsonObject();
+ for (Map.Entry, ?> entry : ((Map, ?>) value).entrySet()) {
+ object.add(String.valueOf(entry.getKey()), toJson(entry.getValue()));
+ }
+ return object;
+ }
+ if (value instanceof List) {
+ JsonArray array = new JsonArray();
+ for (Object item : (List>) value) array.add(toJson(item));
+ return array;
+ }
+ return new JsonPrimitive(value.toString());
+ }
+
+ private static Object fromJson(JsonElement element) {
+ if (element == null || element.isJsonNull()) return null;
+ if (element.isJsonObject()) {
+ Map map = new LinkedHashMap<>();
+ for (Map.Entry entry : element.getAsJsonObject().entrySet()) {
+ map.put(entry.getKey(), fromJson(entry.getValue()));
+ }
+ return map;
+ }
+ if (element.isJsonArray()) {
+ List list = new ArrayList<>();
+ for (JsonElement item : element.getAsJsonArray()) list.add(fromJson(item));
+ return list;
+ }
+ JsonPrimitive primitive = element.getAsJsonPrimitive();
+ if (primitive.isBoolean()) return primitive.getAsBoolean();
+ if (primitive.isNumber()) {
+ String text = primitive.getAsString();
+ if (text.indexOf('.') >= 0 || text.indexOf('e') >= 0 || text.indexOf('E') >= 0) {
+ return Double.parseDouble(text);
+ }
+ BigInteger integer = new BigInteger(text);
+ return integer.bitLength() < 64 ? (Object) integer.longValue() : integer;
+ }
+ return primitive.getAsString();
+ }
+
+ private static byte[] toMsgpack(Object tree) throws AblyException {
+ try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) {
+ pack(packer, tree);
+ return packer.toByteArray();
+ } catch (IOException e) {
+ throw AblyException.fromThrowable(e);
+ }
+ }
+
+ private static void pack(MessagePacker packer, Object value) throws IOException {
+ if (value == null) {
+ packer.packNil();
+ } else if (value instanceof String) {
+ packer.packString((String) value);
+ } else if (value instanceof Boolean) {
+ packer.packBoolean((Boolean) value);
+ } else if (value instanceof Double || value instanceof Float || value instanceof BigDecimal) {
+ packer.packDouble(((Number) value).doubleValue());
+ } else if (value instanceof BigInteger) {
+ packer.packBigInteger((BigInteger) value);
+ } else if (value instanceof Number) {
+ packer.packLong(((Number) value).longValue());
+ } else if (value instanceof byte[]) {
+ byte[] bytes = (byte[]) value;
+ packer.packBinaryHeader(bytes.length);
+ packer.writePayload(bytes);
+ } else if (value instanceof Map) {
+ Map, ?> map = (Map, ?>) value;
+ packer.packMapHeader(map.size());
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ packer.packString(String.valueOf(entry.getKey()));
+ pack(packer, entry.getValue());
+ }
+ } else if (value instanceof List) {
+ List> list = (List>) value;
+ packer.packArrayHeader(list.size());
+ for (Object item : list) pack(packer, item);
+ } else if (value instanceof JsonElement) {
+ pack(packer, fromJson((JsonElement) value));
+ } else {
+ packer.packString(value.toString());
+ }
+ }
+
+ private static Object fromMsgpack(Value value) {
+ switch (value.getValueType()) {
+ case BOOLEAN:
+ return value.asBooleanValue().getBoolean();
+ case INTEGER:
+ IntegerValue integer = value.asIntegerValue();
+ return integer.isInLongRange() ? (Object) integer.toLong() : integer.toBigInteger();
+ case FLOAT:
+ return value.asFloatValue().toDouble();
+ case STRING:
+ return value.asStringValue().asString();
+ case BINARY:
+ return value.asBinaryValue().asByteArray();
+ case ARRAY: {
+ List list = new ArrayList<>();
+ for (Value item : value.asArrayValue()) list.add(fromMsgpack(item));
+ return list;
+ }
+ case MAP: {
+ Map map = new LinkedHashMap<>();
+ for (Map.Entry entry : value.asMapValue().map().entrySet()) {
+ Value key = entry.getKey();
+ map.put(key.isStringValue() ? key.asStringValue().asString() : key.toString(), fromMsgpack(entry.getValue()));
+ }
+ return map;
+ }
+ case EXTENSION:
+ return value.asExtensionValue().getData();
+ case NIL:
+ default:
+ return null;
+ }
+ }
+
+ //endregion
+
+ //region Helpers
+
+ static AblyException invalidInput(String message) {
+ return AblyException.fromErrorInfo(new ErrorInfo(message, 400, 40003));
+ }
+
+ /**
+ * A response that could not be decoded: 500 / 50000, built so that it is a plain
+ * {@link AblyException} rather than the {@code HostFailedException} that
+ * {@link AblyException#fromErrorInfo} makes of a 5xx status, which would mean "retry on a
+ * fallback host".
+ */
+ static AblyException decodeError(String message, Throwable cause) {
+ AblyException exception = AblyException.fromThrowable(cause != null ? cause : new IllegalStateException(message));
+ exception.errorInfo.message = message;
+ return exception;
+ }
+
+ private static Number finite(Number number, String field) throws AblyException {
+ if (number == null) throw invalidInput("Invalid " + field + ": must not be null");
+ double value = number.doubleValue();
+ if (Double.isNaN(value) || Double.isInfinite(value)) {
+ throw invalidInput("Invalid " + field + ": " + number + " is not a finite number");
+ }
+ return number;
+ }
+
+ private static String asString(Object value) {
+ return value instanceof String ? (String) value : null;
+ }
+
+ private static Map singleton(String key, Object value) {
+ Map map = new LinkedHashMap<>();
+ map.put(key, value);
+ return map;
+ }
+
+ private static void putIfNotNull(Map map, String key, Object value) {
+ if (value != null) map.put(key, value);
+ }
+
+ //endregion
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectData.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectData.java
new file mode 100644
index 000000000..1b126ada7
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectData.java
@@ -0,0 +1,15 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.liveobjects.message.ObjectData;
+
+/**
+ * A decoded leaf value in a full-format
+ * {@link io.ably.pubsub.liveobjects.HttpObject#get(io.ably.pubsub.types.Param[])} response. Exactly one value
+ * getter returns non-null: binary values are decoded to {@code byte[]} and JSON values are parsed
+ * (RSO9c2). {@link #getObjectId()} is always {@code null}; an object reference is returned as an
+ * {@link HttpLiveObject} instead (RSO9c1).
+ *
+ * Spec: RSO9c
+ */
+public interface HttpObjectData extends ObjectData, HttpObjectGetFullResult {
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdBody.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdBody.java
new file mode 100644
index 000000000..d3a27d806
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdBody.java
@@ -0,0 +1,59 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+
+/**
+ * The create body passed to {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId}: either
+ * a map create or a counter create. Create instances with the static factories, which set exactly one.
+ *
+ *
Spec: RSO12
+ */
+public final class HttpObjectGenerateIdBody {
+
+ private final @Nullable HttpObjectOperation.MapCreate mapCreate;
+ private final @Nullable HttpObjectOperation.CounterCreate counterCreate;
+
+ private HttpObjectGenerateIdBody(@Nullable HttpObjectOperation.MapCreate mapCreate,
+ @Nullable HttpObjectOperation.CounterCreate counterCreate) {
+ this.mapCreate = mapCreate;
+ this.counterCreate = counterCreate;
+ }
+
+ /** Spec: RSO12a */
+ public static @NotNull HttpObjectGenerateIdBody mapCreate(@NotNull HttpObjectOperation.MapCreate body) {
+ return new HttpObjectGenerateIdBody(body, null);
+ }
+
+ /** Spec: RSO12a */
+ public static @NotNull HttpObjectGenerateIdBody mapCreate(@NotNull ObjectsMapSemantics semantics,
+ @NotNull Map entries) {
+ return mapCreate(new HttpObjectOperation.MapCreate(semantics, entries));
+ }
+
+ /** Spec: RSO12a, with {@link ObjectsMapSemantics#LWW} semantics. */
+ public static @NotNull HttpObjectGenerateIdBody mapCreate(@NotNull Map entries) {
+ return mapCreate(ObjectsMapSemantics.LWW, entries);
+ }
+
+ /** Spec: RSO12b */
+ public static @NotNull HttpObjectGenerateIdBody counterCreate(@NotNull HttpObjectOperation.CounterCreate body) {
+ return new HttpObjectGenerateIdBody(null, body);
+ }
+
+ /** Spec: RSO12b */
+ public static @NotNull HttpObjectGenerateIdBody counterCreate(@NotNull Number count) {
+ return counterCreate(new HttpObjectOperation.CounterCreate(count));
+ }
+
+ public @Nullable HttpObjectOperation.MapCreate getMapCreate() {
+ return mapCreate;
+ }
+
+ public @Nullable HttpObjectOperation.CounterCreate getCounterCreate() {
+ return counterCreate;
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdResult.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdResult.java
new file mode 100644
index 000000000..ad1f3b994
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdResult.java
@@ -0,0 +1,23 @@
+package io.ably.pubsub.liveobjects.http;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * The result of {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId}. Pass it to
+ * {@link HttpObjectOperation#mapCreateWithObjectId(HttpObjectGenerateIdResult)} or
+ * {@link HttpObjectOperation#counterCreateWithObjectId(HttpObjectGenerateIdResult)} to create the
+ * object; all three values must be sent unchanged.
+ *
+ * Spec: RSO11
+ */
+public interface HttpObjectGenerateIdResult {
+
+ /** @return the generated object ID (RSO11a). */
+ @NotNull String getObjectId();
+
+ /** @return the nonce the object ID was derived from (RSO11b). */
+ @NotNull String getNonce();
+
+ /** @return the JSON-encoded initial value the object ID was derived from (RSO11c). */
+ @NotNull String getInitialValue();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetFullResult.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetFullResult.java
new file mode 100644
index 000000000..207182eeb
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetFullResult.java
@@ -0,0 +1,99 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.ably.pubsub.liveobjects.ValueType;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A value returned by {@link io.ably.pubsub.liveobjects.HttpObject#get(io.ably.pubsub.types.Param[])}
+ * (the full format), and the type of each
+ * {@link HttpLiveMapEntry#getData() map entry's data}. It is one of:
+ *
+ * {@link HttpLiveMap} - a map object (RSO9a)
+ * {@link HttpLiveCounter} - a counter object (RSO9b)
+ * {@link HttpObjectData} - a decoded leaf value (RSO9c)
+ * {@link HttpLiveObject} itself - an object of a type this library doesn't recognise (RSO9d)
+ *
+ *
+ * As with a realtime {@code Instance}, discriminate with {@link #getType()} and narrow with the
+ * {@code as*} helpers, which fail fast with {@link IllegalStateException} on a type mismatch. The
+ * result is an immutable snapshot, so the helpers for leaf values return the value itself, e.g.
+ * {@code result.asLiveMap().getEntries().get("name").getData().asString()}.
+ *
+ *
Spec: RSO9
+ */
+public interface HttpObjectGetFullResult {
+
+ /**
+ * Returns the type of this value: {@link ValueType#LIVE_MAP}, {@link ValueType#LIVE_COUNTER},
+ * a primitive type for a leaf, or {@link ValueType#UNKNOWN} for an object type this library
+ * doesn't recognise or a JSON leaf that is neither an object nor an array.
+ *
+ * @return the value type.
+ */
+ @NotNull ValueType getType();
+
+ /**
+ * @return this value as an {@link HttpLiveMap}.
+ * @throws IllegalStateException if this is not a map.
+ */
+ @NotNull HttpLiveMap asLiveMap();
+
+ /**
+ * @return this value as an {@link HttpLiveCounter}.
+ * @throws IllegalStateException if this is not a counter.
+ */
+ @NotNull HttpLiveCounter asLiveCounter();
+
+ /**
+ * Returns this value as an {@link HttpLiveObject}: a map, a counter, or an object of a type this
+ * library doesn't recognise.
+ *
+ * @return this value as an {@link HttpLiveObject}.
+ * @throws IllegalStateException if this is a leaf value, not an object.
+ */
+ @NotNull HttpLiveObject asLiveObject();
+
+ /**
+ * @return this leaf as an {@link HttpObjectData}, with the getter for its one value.
+ * @throws IllegalStateException if this is an object, not a leaf value.
+ */
+ @NotNull HttpObjectData asObjectData();
+
+ /**
+ * @return the string value.
+ * @throws IllegalStateException if this is not a string leaf.
+ */
+ @NotNull String asString();
+
+ /**
+ * @return the number value.
+ * @throws IllegalStateException if this is not a number leaf.
+ */
+ double asNumber();
+
+ /**
+ * @return the boolean value.
+ * @throws IllegalStateException if this is not a boolean leaf.
+ */
+ boolean asBoolean();
+
+ /**
+ * @return the binary value.
+ * @throws IllegalStateException if this is not a binary leaf.
+ */
+ byte @NotNull [] asBinary();
+
+ /**
+ * @return the JSON object value.
+ * @throws IllegalStateException if this is not a JSON object leaf.
+ */
+ @NotNull JsonObject asJsonObject();
+
+ /**
+ * @return the JSON array value.
+ * @throws IllegalStateException if this is not a JSON array leaf.
+ */
+ @NotNull JsonArray asJsonArray();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetParams.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetParams.java
new file mode 100644
index 000000000..940cf20be
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetParams.java
@@ -0,0 +1,70 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.types.Param;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Builds the {@code Param[]} request parameters for
+ * {@link io.ably.pubsub.liveobjects.HttpObject#getCompact(Param[])} and
+ * {@link io.ably.pubsub.liveobjects.HttpObject#get(Param[])}.
+ *
+ *
The params are ordinary {@link Param}s, so they can also be built by hand with the
+ * {@link #OBJECT_ID} and {@link #PATH} keys. {@code compact} (RSO2a1c) is not a parameter: the
+ * format is chosen by calling {@code getCompact} (compact) or {@code get} (full), so each returns its
+ * own type.
+ *
+ *
{@code
+ * channel.object.get(HttpObjectGetParams.ofPath("players.alice"));
+ * }
+ *
+ * Spec: RSO2a1
+ */
+public final class HttpObjectGetParams {
+
+ /**
+ * The key of the ID of the object to fetch. It's sent as a path segment, not in the query
+ * string. If it's absent, the channel object is the target.
+ *
Spec: RSO2a1a
+ */
+ public static final String OBJECT_ID = "objectId";
+
+ /**
+ * The key of a path within the target, evaluated by the server relative to the channel object
+ * or the {@link #OBJECT_ID object}. The path is sent as given.
+ *
Spec: RSO2a1b, RSO6
+ */
+ public static final String PATH = "path";
+
+ private HttpObjectGetParams() {}
+
+ /**
+ * Targets an object by ID.
+ *
+ * @param objectId the ID of the object to fetch.
+ * @return the params.
+ */
+ public static @NotNull Param @NotNull [] ofObjectId(@NotNull String objectId) {
+ return new Param[] { new Param(OBJECT_ID, objectId) };
+ }
+
+ /**
+ * Targets a path within the channel object.
+ *
+ * @param path the path, relative to the channel object.
+ * @return the params.
+ */
+ public static @NotNull Param @NotNull [] ofPath(@NotNull String path) {
+ return new Param[] { new Param(PATH, path) };
+ }
+
+ /**
+ * Targets a path within an object.
+ *
+ * @param objectId the ID of the object the path is evaluated from.
+ * @param path the path, relative to that object.
+ * @return the params.
+ */
+ public static @NotNull Param @NotNull [] of(@NotNull String objectId, @NotNull String path) {
+ return new Param[] { new Param(OBJECT_ID, objectId), new Param(PATH, path) };
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectOperation.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectOperation.java
new file mode 100644
index 000000000..82d06ed28
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectOperation.java
@@ -0,0 +1,363 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * An operation published with {@link io.ably.pubsub.liveobjects.HttpObject#publish}.
+ *
+ *
Each operation carries exactly one operation body. Create an operation with one of the static
+ * factories, which fixes the body, then set the optional common fields with {@link #withObjectId},
+ * {@link #withPath} and {@link #withId}. Instances are immutable; the {@code with*} methods return
+ * a copy.
+ *
+ *
{@code
+ * HttpObjectOperation.mapSet("name", PublishObjectData.string("Alice")).withPath("profile");
+ * HttpObjectOperation.counterInc(1).withObjectId("counter:abc@1700000000000");
+ * }
+ *
+ * Which target fields an operation needs depends on its type (see the factories). The library
+ * does not check targeting; the server rejects an invalid combination (RSO3g).
+ *
+ *
Spec: RSO5
+ */
+public final class HttpObjectOperation {
+
+ private final @Nullable String id;
+ private final @Nullable String objectId;
+ private final @Nullable String path;
+ private final @Nullable MapCreate mapCreate;
+ private final @Nullable CreateWithObjectId mapCreateWithObjectId;
+ private final @Nullable MapSet mapSet;
+ private final @Nullable MapRemove mapRemove;
+ private final @Nullable CounterCreate counterCreate;
+ private final @Nullable CreateWithObjectId counterCreateWithObjectId;
+ private final @Nullable CounterInc counterInc;
+
+ private HttpObjectOperation(@Nullable String id, @Nullable String objectId, @Nullable String path,
+ @Nullable MapCreate mapCreate, @Nullable CreateWithObjectId mapCreateWithObjectId,
+ @Nullable MapSet mapSet, @Nullable MapRemove mapRemove,
+ @Nullable CounterCreate counterCreate, @Nullable CreateWithObjectId counterCreateWithObjectId,
+ @Nullable CounterInc counterInc) {
+ this.id = id;
+ this.objectId = objectId;
+ this.path = path;
+ this.mapCreate = mapCreate;
+ this.mapCreateWithObjectId = mapCreateWithObjectId;
+ this.mapSet = mapSet;
+ this.mapRemove = mapRemove;
+ this.counterCreate = counterCreate;
+ this.counterCreateWithObjectId = counterCreateWithObjectId;
+ this.counterInc = counterInc;
+ }
+
+ //region Factories
+
+ /**
+ * Creates a new map. Without a target, the map is standalone and its ID is returned in
+ * {@link HttpObjectPublishResult#getObjectIds()}; with {@link #withPath}, it's assigned at that path.
+ *
Spec: RSO5c
+ *
+ * @param semantics the conflict-resolution semantics.
+ * @param entries the initial entries, by key.
+ */
+ public static @NotNull HttpObjectOperation mapCreate(@NotNull ObjectsMapSemantics semantics,
+ @NotNull Map entries) {
+ return mapCreate(new MapCreate(semantics, entries));
+ }
+
+ /**
+ * Creates a new map with {@link ObjectsMapSemantics#LWW} semantics; see
+ * {@link #mapCreate(ObjectsMapSemantics, Map)}.
+ */
+ public static @NotNull HttpObjectOperation mapCreate(@NotNull Map entries) {
+ return mapCreate(ObjectsMapSemantics.LWW, entries);
+ }
+
+ /** Spec: RSO5c */
+ public static @NotNull HttpObjectOperation mapCreate(@NotNull MapCreate body) {
+ return new HttpObjectOperation(null, null, null, body, null, null, null, null, null, null);
+ }
+
+ /**
+ * Creates a new map with an ID generated by
+ * {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId}, setting the object ID, initial
+ * value and nonce from the result.
+ * Spec: RSO5d
+ */
+ public static @NotNull HttpObjectOperation mapCreateWithObjectId(@NotNull HttpObjectGenerateIdResult generated) {
+ return mapCreateWithObjectId(generated.getObjectId(), generated.getInitialValue(), generated.getNonce());
+ }
+
+ /**
+ * Creates a new map with a client-generated ID. All three values must come unchanged from the
+ * same {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId} result.
+ *
Spec: RSO5d
+ */
+ public static @NotNull HttpObjectOperation mapCreateWithObjectId(@NotNull String objectId,
+ @NotNull String initialValue,
+ @NotNull String nonce) {
+ return new HttpObjectOperation(null, objectId, null, null,
+ new CreateWithObjectId(initialValue, nonce), null, null, null, null, null);
+ }
+
+ /**
+ * Sets a key in an existing map, targeted by {@link #withObjectId} or {@link #withPath}.
+ *
Spec: RSO5e
+ */
+ public static @NotNull HttpObjectOperation mapSet(@NotNull String key, @NotNull PublishObjectData value) {
+ return new HttpObjectOperation(null, null, null, null, null, new MapSet(key, value), null, null, null, null);
+ }
+
+ /**
+ * Removes a key from an existing map, targeted by {@link #withObjectId} or {@link #withPath}.
+ *
Spec: RSO5f
+ */
+ public static @NotNull HttpObjectOperation mapRemove(@NotNull String key) {
+ return new HttpObjectOperation(null, null, null, null, null, null, new MapRemove(key), null, null, null);
+ }
+
+ /**
+ * Creates a new counter. Targeting works as for {@link #mapCreate(ObjectsMapSemantics, Map)}.
+ *
Spec: RSO5g
+ */
+ public static @NotNull HttpObjectOperation counterCreate(@NotNull Number count) {
+ return counterCreate(new CounterCreate(count));
+ }
+
+ /** Spec: RSO5g */
+ public static @NotNull HttpObjectOperation counterCreate(@NotNull CounterCreate body) {
+ return new HttpObjectOperation(null, null, null, null, null, null, null, body, null, null);
+ }
+
+ /**
+ * Creates a new counter with an ID generated by
+ * {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId}.
+ *
Spec: RSO5h
+ */
+ public static @NotNull HttpObjectOperation counterCreateWithObjectId(@NotNull HttpObjectGenerateIdResult generated) {
+ return counterCreateWithObjectId(generated.getObjectId(), generated.getInitialValue(), generated.getNonce());
+ }
+
+ /**
+ * Creates a new counter with a client-generated ID; see
+ * {@link #mapCreateWithObjectId(String, String, String)}.
+ *
Spec: RSO5h
+ */
+ public static @NotNull HttpObjectOperation counterCreateWithObjectId(@NotNull String objectId,
+ @NotNull String initialValue,
+ @NotNull String nonce) {
+ return new HttpObjectOperation(null, objectId, null, null, null, null, null, null,
+ new CreateWithObjectId(initialValue, nonce), null);
+ }
+
+ /**
+ * Adds {@code number} to an existing counter (negative to decrement), targeted by
+ * {@link #withObjectId} or {@link #withPath}.
+ *
Spec: RSO5i
+ */
+ public static @NotNull HttpObjectOperation counterInc(@NotNull Number number) {
+ return new HttpObjectOperation(null, null, null, null, null, null, null, null, null, new CounterInc(number));
+ }
+
+ //endregion
+
+ //region Common fields
+
+ /**
+ * Returns a copy with the operation ID set, used for idempotent publishing. The library never
+ * generates one.
+ *
Spec: RSO5a1, RSO3f
+ */
+ public @NotNull HttpObjectOperation withId(@Nullable String id) {
+ return new HttpObjectOperation(id, objectId, path, mapCreate, mapCreateWithObjectId, mapSet, mapRemove,
+ counterCreate, counterCreateWithObjectId, counterInc);
+ }
+
+ /**
+ * Returns a copy that targets the object with this ID.
+ *
Spec: RSO5a2
+ */
+ public @NotNull HttpObjectOperation withObjectId(@Nullable String objectId) {
+ return new HttpObjectOperation(id, objectId, path, mapCreate, mapCreateWithObjectId, mapSet, mapRemove,
+ counterCreate, counterCreateWithObjectId, counterInc);
+ }
+
+ /**
+ * Returns a copy that targets the object(s) at this path in the channel object. The path is sent
+ * as given.
+ *
Spec: RSO5a3, RSO6
+ */
+ public @NotNull HttpObjectOperation withPath(@Nullable String path) {
+ return new HttpObjectOperation(id, objectId, path, mapCreate, mapCreateWithObjectId, mapSet, mapRemove,
+ counterCreate, counterCreateWithObjectId, counterInc);
+ }
+
+ //endregion
+
+ //region Getters
+
+ public @Nullable String getId() {
+ return id;
+ }
+
+ public @Nullable String getObjectId() {
+ return objectId;
+ }
+
+ public @Nullable String getPath() {
+ return path;
+ }
+
+ public @Nullable MapCreate getMapCreate() {
+ return mapCreate;
+ }
+
+ public @Nullable CreateWithObjectId getMapCreateWithObjectId() {
+ return mapCreateWithObjectId;
+ }
+
+ public @Nullable MapSet getMapSet() {
+ return mapSet;
+ }
+
+ public @Nullable MapRemove getMapRemove() {
+ return mapRemove;
+ }
+
+ public @Nullable CounterCreate getCounterCreate() {
+ return counterCreate;
+ }
+
+ public @Nullable CreateWithObjectId getCounterCreateWithObjectId() {
+ return counterCreateWithObjectId;
+ }
+
+ public @Nullable CounterInc getCounterInc() {
+ return counterInc;
+ }
+
+ //endregion
+
+ //region Operation bodies
+
+ /**
+ * The body of a {@code mapCreate} operation, also accepted by
+ * {@link HttpObjectGenerateIdBody#mapCreate(MapCreate)}.
+ *
Spec: RSO5c
+ */
+ public static final class MapCreate {
+ private final @NotNull ObjectsMapSemantics semantics;
+ private final @NotNull Map entries;
+
+ /**
+ * @param semantics the conflict-resolution semantics (RSO5c1).
+ * @param entries the initial entries, by key (RSO5c2). Iteration order is kept on the wire.
+ */
+ public MapCreate(@NotNull ObjectsMapSemantics semantics, @NotNull Map entries) {
+ this.semantics = semantics;
+ this.entries = Collections.unmodifiableMap(new LinkedHashMap<>(entries));
+ }
+
+ public @NotNull ObjectsMapSemantics getSemantics() {
+ return semantics;
+ }
+
+ public @NotNull Map getEntries() {
+ return entries;
+ }
+ }
+
+ /**
+ * The body of a {@code mapCreateWithObjectId} or {@code counterCreateWithObjectId} operation.
+ * Spec: RSO5d, RSO5h
+ */
+ public static final class CreateWithObjectId {
+ private final @NotNull String initialValue;
+ private final @NotNull String nonce;
+
+ public CreateWithObjectId(@NotNull String initialValue, @NotNull String nonce) {
+ this.initialValue = initialValue;
+ this.nonce = nonce;
+ }
+
+ /** Spec: RSO5d1 */
+ public @NotNull String getInitialValue() {
+ return initialValue;
+ }
+
+ /** Spec: RSO5d2 */
+ public @NotNull String getNonce() {
+ return nonce;
+ }
+ }
+
+ /** Spec: RSO5e */
+ public static final class MapSet {
+ private final @NotNull String key;
+ private final @NotNull PublishObjectData value;
+
+ public MapSet(@NotNull String key, @NotNull PublishObjectData value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public @NotNull String getKey() {
+ return key;
+ }
+
+ public @NotNull PublishObjectData getValue() {
+ return value;
+ }
+ }
+
+ /** Spec: RSO5f */
+ public static final class MapRemove {
+ private final @NotNull String key;
+
+ public MapRemove(@NotNull String key) {
+ this.key = key;
+ }
+
+ public @NotNull String getKey() {
+ return key;
+ }
+ }
+
+ /**
+ * The body of a {@code counterCreate} operation, also accepted by
+ * {@link HttpObjectGenerateIdBody#counterCreate(CounterCreate)}.
+ *
Spec: RSO5g
+ */
+ public static final class CounterCreate {
+ private final @NotNull Number count;
+
+ public CounterCreate(@NotNull Number count) {
+ this.count = count;
+ }
+
+ public @NotNull Number getCount() {
+ return count;
+ }
+ }
+
+ /** Spec: RSO5i */
+ public static final class CounterInc {
+ private final @NotNull Number number;
+
+ public CounterInc(@NotNull Number number) {
+ this.number = number;
+ }
+
+ public @NotNull Number getNumber() {
+ return number;
+ }
+ }
+
+ //endregion
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectPublishResult.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectPublishResult.java
new file mode 100644
index 000000000..005291a49
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectPublishResult.java
@@ -0,0 +1,25 @@
+package io.ably.pubsub.liveobjects.http;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+/**
+ * The result of {@link io.ably.pubsub.liveobjects.HttpObject#publish}.
+ *
+ *
Spec: RSO10
+ */
+public interface HttpObjectPublishResult {
+
+ /** @return the ID of the message the operations were published in (RSO10a). */
+ @NotNull String getMessageId();
+
+ /** @return the channel the operations were published on (RSO10b). */
+ @NotNull String getChannel();
+
+ /**
+ * @return the IDs of the objects the operations affected, including objects the server created
+ * for {@code mapCreate} and {@code counterCreate} operations (RSO10c).
+ */
+ @NotNull List getObjectIds();
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectResults.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectResults.java
new file mode 100644
index 000000000..e1ab7d1ea
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectResults.java
@@ -0,0 +1,314 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.ably.pubsub.liveobjects.ValueType;
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Implementations of the {@link io.ably.pubsub.liveobjects.HttpObject} result types.
+ */
+final class HttpObjectResults {
+
+ private HttpObjectResults() {}
+
+ /** The {@code as*} casts of {@link HttpObjectGetFullResult}, driven by {@link #getType()}. */
+ abstract static class Result implements HttpObjectGetFullResult {
+
+ @Override
+ public @NotNull HttpLiveMap asLiveMap() {
+ return (HttpLiveMap) expect(this instanceof HttpLiveMap, "LIVE_MAP");
+ }
+
+ @Override
+ public @NotNull HttpLiveCounter asLiveCounter() {
+ return (HttpLiveCounter) expect(this instanceof HttpLiveCounter, "LIVE_COUNTER");
+ }
+
+ @Override
+ public @NotNull HttpLiveObject asLiveObject() {
+ return (HttpLiveObject) expect(this instanceof HttpLiveObject, "an object");
+ }
+
+ @Override
+ public @NotNull HttpObjectData asObjectData() {
+ return (HttpObjectData) expect(this instanceof HttpObjectData, "a leaf value");
+ }
+
+ @Override
+ public @NotNull String asString() {
+ return asObjectData(ValueType.STRING).getString();
+ }
+
+ @Override
+ public double asNumber() {
+ return asObjectData(ValueType.NUMBER).getNumber();
+ }
+
+ @Override
+ public boolean asBoolean() {
+ return asObjectData(ValueType.BOOLEAN).getBoolean();
+ }
+
+ @Override
+ public byte @NotNull [] asBinary() {
+ return asObjectData(ValueType.BINARY).getBytes();
+ }
+
+ @Override
+ public @NotNull JsonObject asJsonObject() {
+ return asObjectData(ValueType.JSON_OBJECT).getJson().getAsJsonObject();
+ }
+
+ @Override
+ public @NotNull JsonArray asJsonArray() {
+ return asObjectData(ValueType.JSON_ARRAY).getJson().getAsJsonArray();
+ }
+
+ private HttpObjectData asObjectData(ValueType type) {
+ return (HttpObjectData) expect(getType() == type, type.name());
+ }
+
+ private Object expect(boolean matches, String expected) {
+ if (!matches) {
+ throw new IllegalStateException("Expected " + expected + " but the value is " + getType());
+ }
+ return this;
+ }
+ }
+
+ /** Spec: RSO9d */
+ static class LiveObject extends Result implements HttpLiveObject {
+ private final String objectId;
+ private final JsonObject unrecognizedFields;
+
+ LiveObject(String objectId, JsonObject unrecognizedFields) {
+ this.objectId = objectId;
+ this.unrecognizedFields = unrecognizedFields;
+ }
+
+ @Override
+ public @NotNull String getObjectId() {
+ return objectId;
+ }
+
+ @Override
+ public @NotNull JsonObject getUnrecognizedFields() {
+ return unrecognizedFields.deepCopy(); // JsonObject is mutable; keep the result immutable
+ }
+
+ @Override
+ public @NotNull ValueType getType() {
+ return ValueType.UNKNOWN;
+ }
+
+ @Override
+ public String toString() {
+ return "HttpLiveObject{objectId=" + objectId + ", unrecognizedFields=" + unrecognizedFields + "}";
+ }
+ }
+
+ /** Spec: RSO14 */
+ static final class LiveMap extends LiveObject implements HttpLiveMap {
+ private final ObjectsMapSemantics semantics;
+ private final Map entries;
+
+ LiveMap(String objectId, ObjectsMapSemantics semantics, Map entries,
+ JsonObject unrecognizedFields) {
+ super(objectId, unrecognizedFields);
+ this.semantics = semantics;
+ this.entries = Collections.unmodifiableMap(entries);
+ }
+
+ @Override
+ public @NotNull ObjectsMapSemantics getSemantics() {
+ return semantics;
+ }
+
+ @Override
+ public @NotNull Map getEntries() {
+ return entries;
+ }
+
+ @Override
+ public @NotNull ValueType getType() {
+ return ValueType.LIVE_MAP;
+ }
+
+ @Override
+ public String toString() {
+ return "HttpLiveMap{objectId=" + getObjectId() + ", semantics=" + semantics + ", entries=" + entries + "}";
+ }
+ }
+
+ /** Spec: RSO16 */
+ static final class LiveMapEntry implements HttpLiveMapEntry {
+ private final HttpObjectGetFullResult data;
+
+ LiveMapEntry(HttpObjectGetFullResult data) {
+ this.data = data;
+ }
+
+ @Override
+ public @NotNull HttpObjectGetFullResult getData() {
+ return data;
+ }
+
+ @Override
+ public String toString() {
+ return "HttpLiveMapEntry{data=" + data + "}";
+ }
+ }
+
+ /** Spec: RSO15 */
+ static final class LiveCounter extends LiveObject implements HttpLiveCounter {
+ private final Double count;
+
+ LiveCounter(String objectId, Double count, JsonObject unrecognizedFields) {
+ super(objectId, unrecognizedFields);
+ this.count = count;
+ }
+
+ @Override
+ public @Nullable Double getCount() {
+ return count;
+ }
+
+ @Override
+ public @NotNull ValueType getType() {
+ return ValueType.LIVE_COUNTER;
+ }
+
+ @Override
+ public String toString() {
+ return "HttpLiveCounter{objectId=" + getObjectId() + ", count=" + count + "}";
+ }
+ }
+
+ /** Spec: RSO9c - {@code objectId} is always unset (RSO9c1). */
+ static final class ObjectDataLeaf extends Result implements HttpObjectData {
+ private final String string;
+ private final Double number;
+ private final Boolean bool;
+ private final byte[] bytes;
+ private final JsonElement json;
+
+ ObjectDataLeaf(String string, Double number, Boolean bool, byte[] bytes, JsonElement json) {
+ this.string = string;
+ this.number = number;
+ this.bool = bool;
+ this.bytes = bytes;
+ this.json = json;
+ }
+
+ @Override
+ public @Nullable String getObjectId() {
+ return null;
+ }
+
+ @Override
+ public @Nullable String getString() {
+ return string;
+ }
+
+ @Override
+ public @Nullable Double getNumber() {
+ return number;
+ }
+
+ @Override
+ public @Nullable Boolean getBoolean() {
+ return bool;
+ }
+
+ @Override
+ public byte @Nullable [] getBytes() {
+ return bytes;
+ }
+
+ @Override
+ public @Nullable JsonElement getJson() {
+ return json;
+ }
+
+ @Override
+ public @NotNull ValueType getType() {
+ if (string != null) return ValueType.STRING;
+ if (number != null) return ValueType.NUMBER;
+ if (bool != null) return ValueType.BOOLEAN;
+ if (bytes != null) return ValueType.BINARY;
+ if (json != null && json.isJsonObject()) return ValueType.JSON_OBJECT;
+ if (json != null && json.isJsonArray()) return ValueType.JSON_ARRAY;
+ return ValueType.UNKNOWN;
+ }
+
+ @Override
+ public String toString() {
+ return "HttpObjectData{string=" + string + ", number=" + number + ", boolean=" + bool
+ + ", bytes=" + (bytes == null ? null : "<" + bytes.length + " bytes>") + ", json=" + json + "}";
+ }
+ }
+
+ /** Spec: RSO10 */
+ static final class PublishResult implements HttpObjectPublishResult {
+ private final String messageId;
+ private final String channel;
+ private final List objectIds;
+
+ PublishResult(String messageId, String channel, List objectIds) {
+ this.messageId = messageId;
+ this.channel = channel;
+ this.objectIds = Collections.unmodifiableList(objectIds);
+ }
+
+ @Override
+ public @NotNull String getMessageId() {
+ return messageId;
+ }
+
+ @Override
+ public @NotNull String getChannel() {
+ return channel;
+ }
+
+ @Override
+ public @NotNull List getObjectIds() {
+ return objectIds;
+ }
+ }
+
+ /** Spec: RSO11 */
+ static final class GenerateIdResult implements HttpObjectGenerateIdResult {
+ private final String objectId;
+ private final String nonce;
+ private final String initialValue;
+
+ GenerateIdResult(String objectId, String nonce, String initialValue) {
+ this.objectId = objectId;
+ this.nonce = nonce;
+ this.initialValue = initialValue;
+ }
+
+ @Override
+ public @NotNull String getObjectId() {
+ return objectId;
+ }
+
+ @Override
+ public @NotNull String getNonce() {
+ return nonce;
+ }
+
+ @Override
+ public @NotNull String getInitialValue() {
+ return initialValue;
+ }
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java
new file mode 100644
index 000000000..ba238857f
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java
@@ -0,0 +1,191 @@
+package io.ably.pubsub.liveobjects.http;
+
+import io.ably.pubsub.http.PubSubBase;
+import io.ably.pubsub.types.AblyException;
+import io.ably.pubsub.types.Callback;
+import io.ably.pubsub.types.ErrorInfo;
+import io.ably.pubsub.util.Log;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * [Internal] Server time for one HTTP client, used by
+ * {@link io.ably.pubsub.liveobjects.HttpObject#generateObjectId}: fetched from {@code /time} on
+ * first use, then derived from the offset to the local clock.
+ *
+ * The offset is the one {@link io.ably.pubsub.http.Auth} keeps for {@code queryTime} token
+ * requests, so the client fetches {@code /time} once for both, and the offset is discarded when
+ * the device clock jumps. This class only coordinates the fetch: concurrent first
+ * callers share one {@code /time} request. A blocking caller fetches on its own thread, and other
+ * callers wait for it. An async caller fetches on the HTTP scheduler; later async callers wait for
+ * it, but a blocking caller then fetches separately instead of blocking on the scheduler (which
+ * could deadlock if called from a scheduler thread). After a failure the next call tries again.
+ *
+ *
Spec: RTO16, RTO16a
+ */
+public final class HttpObjectServerTime {
+
+ private static final String TAG = HttpObjectServerTime.class.getName();
+
+ private final PubSubBase client;
+
+ private enum Fetch { NONE, BLOCKING, ASYNC }
+
+ /** Guards {@link #inFlight} and {@link #waiters}. */
+ private final Object lock = new Object();
+ /** The kind of {@code /time} request in flight, if any. */
+ private Fetch inFlight = Fetch.NONE;
+ /** Callers waiting for the in-flight request. */
+ private final List> waiters = new ArrayList<>();
+
+ public HttpObjectServerTime(@NotNull PubSubBase client) {
+ this.client = client;
+ }
+
+ /** Blocking: the current server time, fetching it first if needed. */
+ long currentTimeMillis() throws AblyException {
+ Long known = knownTime();
+ if (known != null) return known;
+
+ final Waiter waiter = new Waiter();
+ Fetch role;
+ synchronized (lock) {
+ known = knownTime();
+ if (known != null) return known;
+ role = inFlight;
+ if (role == Fetch.NONE) {
+ inFlight = Fetch.BLOCKING; // this thread fetches
+ } else if (role == Fetch.BLOCKING) {
+ waiters.add(waiter); // the fetching thread does blocking I/O; safe to wait for
+ }
+ }
+ if (role == Fetch.BLOCKING) return waiter.await();
+ if (role == Fetch.ASYNC) {
+ // Don't wait on the scheduler (could deadlock on a scheduler thread); fetch independently.
+ long serverTime = client.time();
+ storeOffset(serverTime);
+ return serverTime;
+ }
+
+ long serverTime;
+ try {
+ serverTime = client.time();
+ } catch (AblyException | RuntimeException e) {
+ notifyWaiters(null, e instanceof AblyException ? ((AblyException) e).errorInfo : AblyException.fromThrowable(e).errorInfo);
+ throw e;
+ }
+ storeOffset(serverTime);
+ notifyWaiters(serverTime, null);
+ return serverTime;
+ }
+
+ /**
+ * Asynchronous: reports the current server time to {@code callback}, fetching it first if
+ * needed. When the time is already known the callback is invoked on the calling thread.
+ */
+ void currentTimeMillisAsync(@NotNull Callback callback) {
+ Long known = knownTime();
+ if (known != null) {
+ callback.onSuccess(known);
+ return;
+ }
+ synchronized (lock) {
+ known = knownTime();
+ if (known == null) {
+ waiters.add(callback);
+ if (inFlight != Fetch.NONE) return; // the in-flight request notifies this callback
+ inFlight = Fetch.ASYNC;
+ }
+ }
+ if (known != null) {
+ callback.onSuccess(known);
+ return;
+ }
+ try {
+ // A blocking fetch on the scheduler rather than timeAsync, whose callback is never invoked
+ // if decoding the /time response throws (e.g. a 200 of [] or HTML), leaving the waiters hung.
+ client.http.request((http, ignored) -> http.execute(this::fetchForWaiters)).async(null);
+ } catch (RuntimeException e) {
+ // the request never started; release the waiters so later calls can try again
+ notifyWaiters(null, AblyException.fromThrowable(e).errorInfo);
+ }
+ }
+
+ /** The scheduled async fetch: always ends the in-flight request and notifies the waiters. */
+ private void fetchForWaiters() {
+ long serverTime;
+ try {
+ serverTime = client.time();
+ } catch (AblyException | RuntimeException e) {
+ notifyWaiters(null, e instanceof AblyException ? ((AblyException) e).errorInfo : AblyException.fromThrowable(e).errorInfo);
+ return;
+ }
+ storeOffset(serverTime);
+ notifyWaiters(serverTime, null);
+ }
+
+ /** A blocking caller waiting for another caller's request. */
+ private static final class Waiter implements Callback {
+ private final CountDownLatch done = new CountDownLatch(1);
+ private volatile Long time;
+ private volatile ErrorInfo error;
+
+ @Override
+ public void onSuccess(Long serverTime) {
+ time = serverTime;
+ done.countDown();
+ }
+
+ @Override
+ public void onError(ErrorInfo reason) {
+ error = reason;
+ done.countDown();
+ }
+
+ long await() throws AblyException {
+ try {
+ done.await(); // the HTTP client's own timeouts bound the request
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw AblyException.fromThrowable(e);
+ }
+ if (error != null) throw AblyException.fromErrorInfo(error);
+ return time;
+ }
+ }
+
+ private void storeOffset(long serverTime) {
+ client.auth.setServerTime(serverTime);
+ }
+
+ private Long knownTime() {
+ return client.auth.serverTimestampIfKnown();
+ }
+
+ /**
+ * Ends the in-flight request, then notifies and removes every waiter; one waiter's exception
+ * doesn't stop the others.
+ */
+ private void notifyWaiters(Long serverTime, ErrorInfo error) {
+ List> toNotify;
+ synchronized (lock) {
+ toNotify = new ArrayList<>(waiters);
+ waiters.clear();
+ inFlight = Fetch.NONE;
+ }
+ for (Callback waiter : toNotify) {
+ try {
+ if (error != null) {
+ waiter.onError(error);
+ } else {
+ waiter.onSuccess(serverTime);
+ }
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Server time callback threw", e);
+ }
+ }
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/PublishObjectData.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/PublishObjectData.java
new file mode 100644
index 000000000..e0c913143
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/PublishObjectData.java
@@ -0,0 +1,98 @@
+package io.ably.pubsub.liveobjects.http;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A leaf value in an {@link HttpObjectOperation}: the value of a {@code mapSet}, or the data of a
+ * {@code mapCreate} entry. Exactly one field is set; create instances with the static factories.
+ *
+ * Spec: RSO7
+ */
+public final class PublishObjectData {
+
+ private final @Nullable String string;
+ private final @Nullable Number number;
+ private final @Nullable Boolean bool;
+ private final byte @Nullable [] bytes;
+ private final @Nullable JsonElement json;
+ private final @Nullable String objectId;
+
+ private PublishObjectData(@Nullable String string, @Nullable Number number, @Nullable Boolean bool,
+ byte @Nullable [] bytes, @Nullable JsonElement json, @Nullable String objectId) {
+ this.string = string;
+ this.number = number;
+ this.bool = bool;
+ this.bytes = bytes;
+ this.json = json;
+ this.objectId = objectId;
+ }
+
+ /** Spec: RSO7a */
+ public static @NotNull PublishObjectData string(@NotNull String value) {
+ return new PublishObjectData(value, null, null, null, null, null);
+ }
+
+ /** Spec: RSO7b */
+ public static @NotNull PublishObjectData number(@NotNull Number value) {
+ return new PublishObjectData(null, value, null, null, null, null);
+ }
+
+ /** Spec: RSO7c */
+ public static @NotNull PublishObjectData bool(boolean value) {
+ return new PublishObjectData(null, null, value, null, null, null);
+ }
+
+ /** Spec: RSO7d */
+ public static @NotNull PublishObjectData bytes(byte @NotNull [] value) {
+ return new PublishObjectData(null, null, null, value, null, null);
+ }
+
+ /** Spec: RSO7e */
+ public static @NotNull PublishObjectData json(@NotNull JsonObject value) {
+ return new PublishObjectData(null, null, null, null, value, null);
+ }
+
+ /** Spec: RSO7e */
+ public static @NotNull PublishObjectData json(@NotNull JsonArray value) {
+ return new PublishObjectData(null, null, null, null, value, null);
+ }
+
+ /**
+ * A reference to an existing object by its ID.
+ *
Spec: RSO7f
+ */
+ public static @NotNull PublishObjectData objectId(@NotNull String objectId) {
+ return new PublishObjectData(null, null, null, null, null, objectId);
+ }
+
+ public @Nullable String getString() {
+ return string;
+ }
+
+ public @Nullable Number getNumber() {
+ return number;
+ }
+
+ public @Nullable Boolean getBoolean() {
+ return bool;
+ }
+
+ public byte @Nullable [] getBytes() {
+ return bytes;
+ }
+
+ /**
+ * @return the JSON value, a {@link JsonObject} or {@link JsonArray}, or {@code null}.
+ */
+ public @Nullable JsonElement getJson() {
+ return json;
+ }
+
+ public @Nullable String getObjectId() {
+ return objectId;
+ }
+}
diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/http/package-info.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/package-info.java
new file mode 100644
index 000000000..1ac7db0a8
--- /dev/null
+++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Request and result types for {@link io.ably.pubsub.liveobjects.HttpObject}, the LiveObjects API
+ * of an HTTP (REST) channel.
+ *
+ *
Spec: RSO1-RSO16
+ */
+package io.ably.pubsub.liveobjects.http;
diff --git a/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt
new file mode 100644
index 000000000..3110bf5b3
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt
@@ -0,0 +1,545 @@
+package io.ably.pubsub.liveobjects.http
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import io.ably.pubsub.http.HttpUtils
+import io.ably.pubsub.liveobjects.ValueType
+import io.ably.pubsub.types.AblyException
+import io.ably.pubsub.types.Callback
+import io.ably.pubsub.types.ErrorInfo
+import io.ably.pubsub.types.Param
+import io.ably.pubsub.uts.unit.objects.path
+import io.ably.pubsub.uts.unit.objects.queryParams
+import io.ably.pubsub.uts.unit.objects.respondWithBody
+import io.ably.pubsub.uts.unit.objects.setupRestObject
+import org.junit.Test
+import java.math.BigInteger
+import java.util.Collections
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * SDK-specific behaviour of `HttpObject` that the UTS specs don't cover: the `...Async` callback
+ * variants, error types, empty and malformed responses, input the encoder must reject, number
+ * types, and the shared `/time` request. The spec-derived tests (blocking API) are in
+ * `io.ably.pubsub.uts.unit.objects`.
+ */
+class HttpObjectEdgeCasesTest {
+
+ /** The outcome of an `...Async` call. */
+ private class Outcome(val result: T?, val error: ErrorInfo?)
+
+ /** Runs an `...Async` call and waits for its callback. */
+ private fun awaitCallback(call: (Callback) -> Unit): Outcome {
+ val latch = CountDownLatch(1)
+ var outcome: Outcome? = null
+ call(object : Callback {
+ override fun onSuccess(result: T) {
+ outcome = Outcome(result, null)
+ latch.countDown()
+ }
+
+ override fun onError(reason: ErrorInfo?) {
+ outcome = Outcome(null, reason)
+ latch.countDown()
+ }
+ })
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "callback not invoked")
+ return outcome!!
+ }
+
+ private fun Outcome.errorInfo(): ErrorInfo = error ?: fail("expected an error, got $result")
+
+ //region Async variants
+
+ @Test
+ fun `async variants deliver results through the callback`() {
+ val setup = setupRestObject("edge-async") { req ->
+ when {
+ req.path == "/time" -> req.respondWith(200, listOf(1700000000000L))
+ req.method == "POST" -> req.respondWithBody(201, mapOf("messageId" to "m", "channel" to "c", "objectIds" to listOf("root")), false)
+ else -> req.respondWithBody(200, mapOf("objectId" to "root", "counter" to mapOf("data" to mapOf("number" to 3L))), false)
+ }
+ }
+ val obj = setup.channel.`object`
+
+ assertIs(awaitCallback { obj.getAsync(it) }.result)
+ assertIs(awaitCallback { obj.getCompactAsync(it) }.result)
+ assertEquals("m", awaitCallback { obj.publishAsync(HttpObjectOperation.counterInc(1).withPath("c"), it) }.result?.messageId)
+ assertEquals(listOf("root"), awaitCallback { obj.publishAsync(listOf(HttpObjectOperation.counterInc(1).withPath("c")), it) }.result?.objectIds)
+ val generated = awaitCallback { obj.generateObjectIdAsync(HttpObjectGenerateIdBody.counterCreate(1), it) }.result
+ assertTrue(generated!!.objectId.endsWith("@1700000000000"))
+ }
+
+ @Test
+ fun `async variants deliver local validation and server errors through the callback`() {
+ val setup = setupRestObject("edge-async-errors") { req ->
+ req.respondWith(404, mapOf("error" to mapOf("code" to 40400, "statusCode" to 404, "message" to "nope")))
+ }
+ val obj = setup.channel.`object`
+
+ assertEquals(40400, awaitCallback { obj.getCompactAsync(it) }.errorInfo().code)
+ assertEquals(1, setup.capturedRequests.size)
+
+ val invalid = HttpObjectOperation.counterInc(Double.NaN).withPath("c")
+ assertEquals(40003, awaitCallback { obj.publishAsync(invalid, it) }.errorInfo().code)
+ assertEquals(40003, awaitCallback { obj.generateObjectIdAsync(HttpObjectGenerateIdBody.counterCreate(Double.NaN), it) }.errorInfo().code)
+ assertEquals(1, setup.capturedRequests.size) // nothing sent for invalid input
+ }
+
+ @Test
+ fun `async callbacks run on a background thread, including for invalid input and a known server time`() {
+ val setup = setupRestObject("edge-async-thread") { req ->
+ if (req.path == "/time") req.respondWith(200, listOf(1700000000000L))
+ }
+ val obj = setup.channel.`object`
+ obj.generateObjectId(HttpObjectGenerateIdBody.counterCreate(1)) // server time is now known
+ val testThread = Thread.currentThread()
+
+ fun callbackThread(call: (Callback) -> Unit): Thread {
+ val latch = CountDownLatch(1)
+ var thread: Thread? = null
+ call(object : Callback {
+ override fun onSuccess(result: T) { thread = Thread.currentThread(); latch.countDown() }
+ override fun onError(reason: ErrorInfo?) { thread = Thread.currentThread(); latch.countDown() }
+ })
+ assertTrue(latch.await(5, TimeUnit.SECONDS))
+ return thread!!
+ }
+
+ assertTrue(callbackThread { obj.generateObjectIdAsync(HttpObjectGenerateIdBody.counterCreate(1), it) } != testThread)
+ assertTrue(callbackThread { obj.generateObjectIdAsync(HttpObjectGenerateIdBody.counterCreate(Double.NaN), it) } != testThread)
+ assertTrue(callbackThread { obj.publishAsync(HttpObjectOperation.counterInc(Double.NaN), it) } != testThread)
+ }
+
+ //endregion
+
+ //region Get params
+
+ @Test
+ fun `get params - objectId becomes the path segment, other params go in the query, compact is the method's`() {
+ val setup = setupRestObject("edge-params") { it.respondWithBody(200, mapOf("objectId" to "root"), false) }
+ val obj = setup.channel.`object`
+ val params = arrayOf(
+ Param(HttpObjectGetParams.OBJECT_ID, "map:abc@1"),
+ Param(HttpObjectGetParams.PATH, "a.b"),
+ Param("compact", "true"),
+ Param("extra", "1"),
+ )
+
+ obj.get(params)
+ obj.getCompact(params)
+
+ val (full, compact) = setup.capturedRequests
+ assertEquals("/channels/edge-params/object/" + HttpUtils.encodeURIComponent("map:abc@1"), full.path)
+ assertEquals(mapOf("path" to "a.b", "extra" to "1", "compact" to "false"), full.queryParams)
+ assertEquals("/channels/edge-params/object/" + HttpUtils.encodeURIComponent("map:abc@1"), compact.path)
+ assertEquals(mapOf("path" to "a.b", "extra" to "1"), compact.queryParams)
+ }
+
+ @Test
+ fun `get params - factories build the objectId and path params`() {
+ assertEquals(listOf(Param("objectId", "map:abc@1")), HttpObjectGetParams.ofObjectId("map:abc@1").toList())
+ assertEquals(listOf(Param("path", "a.b")), HttpObjectGetParams.ofPath("a.b").toList())
+ assertEquals(
+ listOf(Param("objectId", "map:abc@1"), Param("path", "a.b")),
+ HttpObjectGetParams.of("map:abc@1", "a.b").toList(),
+ )
+ }
+
+ //endregion
+
+ //region Fallback hosts
+
+ @Test
+ fun `publish is retried on a fallback host after a server error, with or without ids`() {
+ val serverError = mapOf("error" to mapOf("code" to 50000, "statusCode" to 500, "message" to "boom"))
+ var posts = 0
+ val setup = setupRestObject("edge-fallback") { req ->
+ // the first attempt of each publish fails; the retry on a fallback host succeeds
+ if (++posts % 2 == 1) req.respondWith(500, serverError)
+ else req.respondWithBody(201, mapOf("messageId" to "m", "channel" to "c", "objectIds" to listOf("root")), false)
+ }
+ val batches = listOf(
+ listOf(HttpObjectOperation.counterInc(5).withPath("c")),
+ listOf(HttpObjectOperation.counterInc(5).withPath("c").withId("op-1"), HttpObjectOperation.counterInc(1).withPath("d")),
+ listOf(HttpObjectOperation.counterInc(5).withPath("c").withId("op-1"), HttpObjectOperation.counterInc(1).withPath("d").withId("op-2")),
+ )
+
+ for (operations in batches) {
+ setup.capturedRequests.clear()
+ assertEquals("m", setup.channel.`object`.publish(operations).messageId)
+ assertEquals(2, setup.capturedRequests.size)
+ assertTrue(setup.capturedRequests[0].url.host != setup.capturedRequests[1].url.host, "expected the retry to go to a fallback host")
+ // the retry sends the same body, so any ids are the same and the server can de-duplicate
+ assertEquals(String(setup.capturedRequests[0].body), String(setup.capturedRequests[1].body))
+
+ setup.capturedRequests.clear()
+ assertEquals("m", awaitCallback { setup.channel.`object`.publishAsync(operations, it) }.result!!.messageId)
+ assertEquals(2, setup.capturedRequests.size)
+ }
+ }
+
+ //endregion
+
+ //region Errors and decoding
+
+ @Test
+ fun `null values are rejected with 40003, also through the async callback`() {
+ val setup = setupRestObject("edge-null-values") { req ->
+ if (req.path == "/time") req.respondWith(200, listOf(1700000000000L)) else req.respondWithBody(201, emptyMap(), false)
+ }
+ @Suppress("UNCHECKED_CAST")
+ val entries = mapOf("a" to null) as Map
+ val op = HttpObjectOperation.mapCreate(entries)
+ val body = HttpObjectGenerateIdBody.mapCreate(entries)
+
+ assertEquals(40003, assertFailsWith { setup.channel.`object`.publish(op) }.errorInfo.code)
+ assertEquals(40003, awaitCallback { setup.channel.`object`.publishAsync(op, it) }.errorInfo().code)
+ assertEquals(40003, assertFailsWith { setup.channel.`object`.generateObjectId(body) }.errorInfo.code)
+ assertEquals(40003, awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }.errorInfo().code)
+ assertEquals(0, setup.capturedRequests.count { it.method == "POST" })
+ }
+
+ @Test
+ fun `counter without a numeric value has a null count`() {
+ val setup = setupRestObject("edge-counter-no-value") {
+ it.respondWithBody(200, mapOf("objectId" to "counter:x@1", "counter" to mapOf("count" to 3L)), false)
+ }
+
+ val counter = assertIs(setup.channel.`object`.get())
+
+ assertNull(counter.count)
+ }
+
+
+ @Test
+ fun `malformed leaf in a full response fails without a fallback retry`() {
+ val cases = listOf(
+ mapOf("json" to "{not json"),
+ mapOf("bytes" to "not*base64!"),
+ )
+ for (leaf in cases) {
+ val setup = setupRestObject("edge-malformed") { it.respondWithBody(200, leaf, false) }
+ val params = HttpObjectGetParams.ofPath("leaf")
+
+ val error = assertFailsWith { setup.channel.`object`.get(params) }
+ assertEquals(50000, error.errorInfo.code)
+ assertEquals(500, error.errorInfo.statusCode)
+ assertEquals(50000, awaitCallback { setup.channel.`object`.getAsync(params, it) }.errorInfo().code)
+ // one request per call: a decode error is not taken for a host failure and retried
+ assertEquals(2, setup.capturedRequests.size)
+ }
+ }
+
+ @Test
+ fun `unexpected publish response fails without a fallback retry`() {
+ val setup = setupRestObject("edge-publish-response") { it.respondWithBody(201, listOf("not", "a", "map"), false) }
+
+ val error = assertFailsWith { setup.channel.`object`.publish(HttpObjectOperation.counterInc(1).withPath("c")) }
+
+ assertEquals(50000, error.errorInfo.code)
+ assertEquals(1, setup.capturedRequests.size)
+ }
+
+ @Test
+ fun `full get with no response body is null, like compact`() {
+ val setup = setupRestObject("edge-empty-body") { it.respondWith(200, ByteArray(0)) }
+
+ assertNull(setup.channel.`object`.get())
+ assertNull(setup.channel.`object`.getCompact())
+ }
+
+ @Test
+ fun `publish with a null operation fails with 40003 and sends nothing`() {
+ val setup = setupRestObject("edge-null-op") { it.respondWithBody(201, emptyMap(), false) }
+
+ val error = assertFailsWith { setup.channel.`object`.publish(listOf(HttpObjectOperation.counterInc(1), null)) }
+
+ assertEquals(40003, error.errorInfo.code)
+ assertEquals(0, setup.capturedRequests.size)
+ }
+
+ @Test
+ fun `non-finite numbers are rejected with 40003 before any request`() {
+ val setup = setupRestObject("edge-non-finite") { it.respondWithBody(201, emptyMap(), false) }
+ val operations = listOf(
+ HttpObjectOperation.mapSet("k", PublishObjectData.number(Double.NaN)).withObjectId("root"),
+ HttpObjectOperation.counterInc(Double.POSITIVE_INFINITY).withPath("c"),
+ HttpObjectOperation.counterCreate(Double.NEGATIVE_INFINITY),
+ )
+ for (operation in operations) {
+ assertEquals(40003, assertFailsWith { setup.channel.`object`.publish(operation) }.errorInfo.code)
+ }
+ val nanBody = HttpObjectGenerateIdBody.counterCreate(Double.NaN)
+ assertEquals(40003, assertFailsWith { setup.channel.`object`.generateObjectId(nanBody) }.errorInfo.code)
+ assertEquals(0, setup.capturedRequests.size)
+ }
+
+ @Test
+ fun `integers beyond the long range keep their exact value for both protocols`() {
+ val big = BigInteger.ONE.shiftLeft(63) // just beyond Long.MAX_VALUE; still fits msgpack uint64
+ for (binary in listOf(false, true)) {
+ val setup = setupRestObject("edge-big-int", useBinaryProtocol = binary) {
+ it.respondWithBody(200, mapOf("big" to big, "small" to 7L), binary)
+ }
+
+ val result = assertIs(setup.channel.`object`.getCompact())
+
+ assertEquals(big, result["big"].asBigInteger, "useBinaryProtocol=$binary")
+ assertEquals(7L, result["small"].asLong, "useBinaryProtocol=$binary")
+ }
+ }
+
+ //endregion
+
+ //region Type casts
+
+ @Test
+ fun `getType and as casts narrow full results`() {
+ val body = mapOf(
+ "objectId" to "root",
+ "map" to mapOf(
+ "semantics" to 0L,
+ "entries" to mapOf(
+ "str" to mapOf("data" to mapOf("string" to "hello")),
+ "num" to mapOf("data" to mapOf("number" to 2.5)),
+ "bool" to mapOf("data" to mapOf("boolean" to true)),
+ "bin" to mapOf("data" to mapOf("bytes" to "AQID")),
+ "jobj" to mapOf("data" to mapOf("json" to "{\"a\":1}")),
+ "jarr" to mapOf("data" to mapOf("json" to "[1,2]")),
+ "jnum" to mapOf("data" to mapOf("json" to "5")),
+ "visits" to mapOf("data" to mapOf("objectId" to "counter:v@1", "counter" to mapOf("data" to mapOf("number" to 7L)))),
+ "future" to mapOf("data" to mapOf("objectId" to "future:f@1", "futureType" to emptyMap())),
+ ),
+ ),
+ )
+ val setup = setupRestObject("edge-casts") { it.respondWithBody(200, body, false) }
+
+ val root = assertNotNull(setup.channel.`object`.get())
+ assertEquals(ValueType.LIVE_MAP, root.type)
+ val map = root.asLiveMap()
+ assertEquals("root", root.asLiveObject().objectId)
+ fun entry(key: String) = map.entries.getValue(key).data
+
+ assertEquals(ValueType.STRING, entry("str").type)
+ assertEquals("hello", entry("str").asString())
+ assertEquals("hello", entry("str").asObjectData().string)
+ assertEquals(ValueType.NUMBER, entry("num").type)
+ assertEquals(2.5, entry("num").asNumber())
+ assertEquals(ValueType.BOOLEAN, entry("bool").type)
+ assertTrue(entry("bool").asBoolean())
+ assertEquals(ValueType.BINARY, entry("bin").type)
+ assertEquals(listOf(1, 2, 3), entry("bin").asBinary().toList())
+ assertEquals(ValueType.JSON_OBJECT, entry("jobj").type)
+ assertEquals(1, entry("jobj").asJsonObject()["a"].asInt)
+ assertEquals(ValueType.JSON_ARRAY, entry("jarr").type)
+ assertEquals(2, entry("jarr").asJsonArray().size())
+ assertEquals(ValueType.UNKNOWN, entry("jnum").type)
+ assertEquals(5, entry("jnum").asObjectData().json!!.asInt)
+ assertEquals(ValueType.LIVE_COUNTER, entry("visits").type)
+ assertEquals(7.0, entry("visits").asLiveCounter().count)
+ assertEquals(ValueType.UNKNOWN, entry("future").type)
+ assertEquals("future:f@1", entry("future").asLiveObject().objectId)
+ }
+
+ @Test
+ fun `as casts fail fast on a type mismatch`() {
+ val body = mapOf("objectId" to "counter:c@1", "counter" to mapOf("data" to mapOf("number" to 1L)))
+ val setup = setupRestObject("edge-cast-mismatch") { it.respondWithBody(200, body, false) }
+ val counter = assertNotNull(setup.channel.`object`.get())
+
+ val error = assertFailsWith { counter.asLiveMap() }
+ assertEquals("Expected LIVE_MAP but the value is LIVE_COUNTER", error.message)
+ assertFailsWith { counter.asString() }
+ assertFailsWith { counter.asObjectData() }
+
+ val leafSetup = setupRestObject("edge-cast-leaf") { it.respondWithBody(200, mapOf("string" to "x"), false) }
+ val leaf = assertNotNull(leafSetup.channel.`object`.get())
+ assertFailsWith { leaf.asNumber() }
+ assertFailsWith { leaf.asLiveObject() }
+ assertFailsWith { leaf.asJsonObject() }
+ }
+
+ //endregion
+
+ //region Server time
+
+ @Test
+ fun `concurrent first generateObjectIdAsync calls share one time request`() {
+ val setup = setupRestObject("edge-time-shared") { req ->
+ if (req.path == "/time") req.respondWithDelay(300.milliseconds, 200, listOf(1700000000000L))
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+ val latch = CountDownLatch(5)
+ val ids = Collections.synchronizedList(mutableListOf())
+ repeat(5) {
+ setup.channel.`object`.generateObjectIdAsync(body, object : Callback {
+ override fun onSuccess(result: HttpObjectGenerateIdResult) {
+ ids.add(result.objectId)
+ latch.countDown()
+ }
+
+ override fun onError(reason: ErrorInfo?) = fail("unexpected error $reason")
+ })
+ }
+
+ assertTrue(latch.await(5, TimeUnit.SECONDS))
+ assertEquals(5, ids.toSet().size)
+ assertEquals(1, setup.capturedRequests.count { it.path == "/time" })
+ }
+
+ @Test
+ fun `concurrent first blocking generateObjectId calls share one time request`() {
+ val setup = setupRestObject("edge-time-shared-sync") { req ->
+ if (req.path == "/time") req.respondWithDelay(300.milliseconds, 200, listOf(1700000000000L))
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ val threads = (1..5).map { Thread { setup.channel.`object`.generateObjectId(body) }.apply { start() } }
+ threads.forEach { it.join(5000) }
+
+ assertEquals(1, setup.capturedRequests.count { it.path == "/time" })
+ }
+
+ @Test
+ fun `a throwing callback doesn't stop other waiters from being notified`() {
+ val setup = setupRestObject("edge-time-throwing-waiter") { req ->
+ if (req.path == "/time") req.respondWithDelay(300.milliseconds, 200, listOf(1700000000000L))
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+ val second = CountDownLatch(1)
+
+ setup.channel.`object`.generateObjectIdAsync(body, object : Callback {
+ override fun onSuccess(result: HttpObjectGenerateIdResult) = throw IllegalStateException("caller bug")
+ override fun onError(reason: ErrorInfo?) = Unit
+ })
+ setup.channel.`object`.generateObjectIdAsync(body, object : Callback {
+ override fun onSuccess(result: HttpObjectGenerateIdResult) = second.countDown()
+ override fun onError(reason: ErrorInfo?) = Unit
+ })
+
+ assertTrue(second.await(5, TimeUnit.SECONDS), "second waiter was not notified")
+ assertEquals(1, setup.capturedRequests.count { it.path == "/time" })
+ }
+
+ @Test
+ fun `a blocking and an async first call share one time request`() {
+ val setup = setupRestObject("edge-time-mixed") { req ->
+ if (req.path == "/time") req.respondWithDelay(300.milliseconds, 200, listOf(1700000000000L))
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ val blocking = Thread { setup.channel.`object`.generateObjectId(body) }.apply { start() }
+ Thread.sleep(100) // let the blocking call start its request
+ val async = awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }
+ blocking.join(5000)
+
+ assertNotNull(async.result)
+ assertEquals(1, setup.capturedRequests.count { it.path == "/time" })
+ }
+
+ @Test
+ fun `a failed time request fails generateObjectId and the next call retries`() {
+ var timeRequests = 0
+ val setup = setupRestObject("edge-time-retry") { req ->
+ if (req.path == "/time") {
+ timeRequests++
+ if (timeRequests % 2 == 1) {
+ req.respondWith(401, mapOf("error" to mapOf("code" to 40100, "statusCode" to 401, "message" to "nope")))
+ } else {
+ req.respondWith(200, listOf(1700000000000L))
+ }
+ }
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ // async: first call fails, second fetches again and succeeds
+ assertEquals(401, awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }.errorInfo().statusCode)
+ assertTrue(awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }.result!!.objectId.endsWith("@1700000000000"))
+ assertEquals(2, timeRequests)
+ }
+
+ @Test
+ fun `a failed blocking time request throws and the next call retries`() {
+ var timeRequests = 0
+ val setup = setupRestObject("edge-time-retry-sync") { req ->
+ if (req.path == "/time") {
+ timeRequests++
+ if (timeRequests == 1) {
+ req.respondWith(401, mapOf("error" to mapOf("code" to 40100, "statusCode" to 401, "message" to "nope")))
+ } else {
+ req.respondWith(200, listOf(1700000000000L))
+ }
+ }
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ assertEquals(401, assertFailsWith { setup.channel.`object`.generateObjectId(body) }.errorInfo.statusCode)
+ assertTrue(setup.channel.`object`.generateObjectId(body).objectId.endsWith("@1700000000000"))
+ assertEquals(2, timeRequests)
+ }
+
+ @Test
+ fun `generateObjectId and queryTime token requests share one server time offset`() {
+ val setup = setupRestObject("edge-time-shared", clientOptions = { queryTime = true }) { req ->
+ if (req.path == "/time") req.respondWith(200, listOf(1700000000000L))
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ // a token request fetches the time; generateObjectId (blocking and async) reuses its offset
+ val tokenRequest = setup.client.auth.createTokenRequest(null, null)
+ assertTrue(tokenRequest.timestamp >= 1700000000000L)
+ assertTrue(setup.channel.`object`.generateObjectId(body).objectId.contains("@17000000"))
+ assertNotNull(awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }.result)
+ assertEquals(1, setup.capturedRequests.count { it.path == "/time" })
+
+ // and the other way round: generateObjectId fetches, the token request reuses it
+ setup.client.auth.clearCachedServerTime()
+ setup.channel.`object`.generateObjectId(body)
+ setup.client.auth.createTokenRequest(null, null)
+ assertEquals(2, setup.capturedRequests.count { it.path == "/time" })
+ }
+
+ @Test
+ fun `a malformed time response fails waiting generateObjectIdAsync calls and the next call retries`() {
+ // 200 responses whose body can't be read as [time] make the /time response handler throw
+ val malformed = ArrayDeque(listOf("[]", "[null]", "captive portal", ""))
+ val setup = setupRestObject("edge-time-malformed") { req ->
+ if (req.path == "/time") {
+ val body = synchronized(malformed) { malformed.removeFirstOrNull() }
+ if (body != null) req.respondWithDelay(100.milliseconds, 200, body) else req.respondWith(200, listOf(1700000000000L))
+ }
+ }
+ val body = HttpObjectGenerateIdBody.counterCreate(1)
+
+ repeat(4) {
+ // two concurrent callers share each failing request; both must be called back
+ val first = CountDownLatch(1)
+ val errors = Collections.synchronizedList(mutableListOf())
+ val callback = object : Callback {
+ override fun onSuccess(result: HttpObjectGenerateIdResult) = fail("unexpected result $result")
+ override fun onError(reason: ErrorInfo?) { errors.add(reason); first.countDown() }
+ }
+ setup.channel.`object`.generateObjectIdAsync(body, callback)
+ setup.channel.`object`.generateObjectIdAsync(body, callback)
+ assertTrue(first.await(5, TimeUnit.SECONDS), "waiting callers were not released")
+ val deadline = System.currentTimeMillis() + 5000
+ while (errors.size < 2 && System.currentTimeMillis() < deadline) Thread.sleep(10)
+ assertEquals(2, errors.size)
+ assertTrue(errors.all { it != null })
+ }
+
+ assertTrue(awaitCallback { setup.channel.`object`.generateObjectIdAsync(body, it) }.result!!.objectId.endsWith("@1700000000000"))
+ }
+
+ //endregion
+}
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md b/lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md
index f0f285538..987a95ca7 100644
--- a/lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md
@@ -2,18 +2,21 @@
> **Scope:** this file lives alongside the realtime/rest UTS suites in the `:java` module
> (`lib/src/test/kotlin/io/ably/pubsub/uts/`) and holds deviations for the **realtime/rest tiers** it
-> hosts. All **objects** tiers (unit, integration and proxy) moved to `:liveobjects`'s own test source
-> set alongside the tests they document; their deviations (the typed-SDK / language adaptations, the
+> hosts, plus the REST `HttpObject` specs of the objects module (`objects/*/rest_object_*.md`, in
+> `uts/unit/objects` and `uts/integration/standard/objects`), whose entries are under
+> *Adapted Tests*. The other **objects** tiers (unit, integration and proxy) moved to `:liveobjects`'s
+> own test source set alongside the tests they document; their deviations (the typed-SDK / language adaptations, the
> intentional RTO18d entry, and any objects integration/proxy entries) are in
> `liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/deviations.md`. For the shared UTS infra these
> suites consume, the tier smoke examples, and the `RUN_DEVIATIONS` mechanism, see `uts/README.md`.
>
> Records every place a generated test deviates from its UTS spec, using the manual's **Recording
> deviations** entry format: each entry records the spec point, what the spec requires, what the SDK
-> actually does, and which test carries the deviation gate. Every deviation recorded here is a
-> **genuine SDK bug** (open; ably-js is compliant — fix the SDK), stated per entry under **Status**; the
-> other actionability groups of the shared taxonomy (shared cross-SDK gap, typed-SDK / language
-> adaptations, intentional design divergence) only have entries in the objects file.
+> actually does, and which test carries the deviation gate. Every realtime/rest deviation recorded here
+> is a **genuine SDK bug** (open; ably-js is compliant — fix the SDK), stated per entry under **Status**.
+> The REST `HttpObject` entries (RSL16b, RSO2a1c, RSO2e, T-4) are the exception: they are typed-SDK
+> adaptations or intentional design divergences, as their **Status** lines say. Other entries of those
+> kinds are in the objects file.
> **Recently fixed and removed from this file:** RTO23e (`get()` now re-attaches a DETACHED channel —
> mode-only check + ensure-active-channel) and RTO20e/RTO20e1 (event-driven `once(SYNCED)` waiters +
@@ -95,6 +98,42 @@
**Status:** Open SDK bug — ably-js is compliant; fix the SDK.
+### RSL16b — not applicable: `HttpObject` needs no plugin (`objects.RestObjectGetTest`)
+
+1. **Spec point:** RSL16b (`objects/unit/RSL16b/missing-plugin-error-0`).
+2. **What the spec says:** accessing `channel.object` on a client without the Objects plugin fails with `ErrorInfo` 400 / 40019. The test applies only to SDKs that handle this by throwing, and says SDKs that make the error impossible to express should note it as not applicable.
+3. **What the SDK does:** `HttpObject` is implemented directly in the core HTTP client (`DefaultHttpObject`), so every HTTP channel has a working `channel.object` and there is no plugin to omit.
+4. **Root cause:** design choice — the REST objects API is plain HTTP and has no dependency on the realtime LiveObjects engine.
+5. **Test impact:** the test asserts that a client built without any plugin has a working `channel.object` (it issues the GET); passes.
+6. **Status:** intentional.
+
+### RSO2a1c — compact and full formats are separate methods (`getCompact`, `get`); compact is JSON (`objects.RestObjectGetTest`)
+
+1. **Spec point:** RSO2a1c, RSO2f (`objects/unit/RSO2d/path-compact-query-params-0`, `objects/unit/RSO2f/compact-returned-verbatim-0`, `objects/unit/RSO2e/compact-msgpack-decoded-0`).
+2. **What the spec says:** `get` takes a `compact` param; `compact: true` is sent as `compact=true`, and the compact result is the decoded body as is, so msgpack binary values are raw bytes.
+3. **What the SDK does:** `getCompact` returns the compact result as a Gson `JsonElement`, like realtime `PathObject#compactJson()`, and `get` returns the full result as `HttpObjectGetFullResult`; the get params are a `Param[]` (built with `HttpObjectGetParams`), and a caller's `compact` param is dropped. `getCompact` never sends `compact` (the server default), `get` sends `compact=false`, and binary values in a compact result are Base64 strings for both protocols.
+4. **Root cause:** a typed API: one method can't return both a JSON value and a typed full result without returning `Object`.
+5. **Test impact:** the `compact: true` case asserts no `compact` param; the explicit-`compact: true` call is `getCompact()`; the msgpack avatar is asserted as the Base64 string `"AQID"`; compact results are compared through `fromJson`. All pass.
+6. **Status:** intentional API design.
+
+### RSO2e — binary `Accept` header also lists JSON (`objects.RestObjectGetTest`)
+
+1. **Spec point:** RSO2e (`objects/unit/RSO2e/compact-msgpack-decoded-0`).
+2. **What the spec says:** the msgpack request's `Accept` header is `application/x-msgpack`.
+3. **What the SDK does:** sends `application/x-msgpack,application/json`, as every binary-protocol request in ably-java does.
+4. **Root cause:** `HttpUtils.defaultAcceptHeaders(true)`, shared by all REST requests.
+5. **Test impact:** adapted assertion on the actual header; passes.
+6. **Status:** intentional, SDK-wide.
+
+### T-4 (objects) — operation and generate-ID bodies are unrepresentable without exactly one body (`objects.RestObjectPublishTest`, `objects.RestObjectGenerateIdTest`)
+
+1. **Spec point:** RSO5b1 (`objects/unit/RSO5b1/no-body-field-error-0`, `objects/unit/RSO5b1/multiple-body-fields-error-1`), RSO4c (`objects/unit/RSO4c/missing-create-body-error-0`).
+2. **What the spec says:** an operation with zero or several body fields, or a generate-ID body with neither `mapCreate` nor `counterCreate`, fails with 400 / 40003 before any request. Each test notes it is not applicable where the SDK makes these inputs unrepresentable.
+3. **What the SDK does:** `HttpObjectOperation` and `HttpObjectGenerateIdBody` can only be built through factories that set exactly one body, and the `with*` methods only change the common fields.
+4. **Root cause:** typed-SDK construction (the same T-4 pattern as the objects suite, `liveobjects/.../uts/deviations.md`).
+5. **Test impact:** the tests exist under their `@UTS` ids with a `// DEVIATION T-4` comment and assert only that no request was sent. The runtime-reachable 40003 case — an unrecognised semantics, `ObjectsMapSemantics.UNKNOWN` — is asserted for real in `RSO3d1/unrecognized-semantics-error-1` and `RSO4e1a/unrecognized-semantics-error-0`.
+6. **Status:** intentional — a typed-SDK adaptation, sanctioned by each spec test's own not-applicable note.
+
## Mock Infrastructure Limitations
*(none)*
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects/RestObjectTest.kt b/lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects/RestObjectTest.kt
new file mode 100644
index 000000000..dfdf79c43
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects/RestObjectTest.kt
@@ -0,0 +1,691 @@
+package io.ably.pubsub.uts.integration.standard.objects
+
+import kotlin.test.assertNull
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.ably.pubsub.http.Channel
+import io.ably.pubsub.liveobjects.http.HttpLiveCounter
+import io.ably.pubsub.liveobjects.http.HttpLiveMap
+import io.ably.pubsub.liveobjects.http.HttpLiveObject
+import io.ably.pubsub.liveobjects.http.HttpObjectData
+import io.ably.pubsub.liveobjects.http.HttpObjectGenerateIdBody
+import io.ably.pubsub.liveobjects.http.HttpObjectGetParams
+import io.ably.pubsub.liveobjects.http.HttpObjectOperation
+import io.ably.pubsub.liveobjects.http.PublishObjectData
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics
+import io.ably.pubsub.types.AblyException
+import io.ably.pubsub.types.Param
+import io.ably.pubsub.uts.infra.integration.SandboxApp
+import io.ably.pubsub.uts.infra.unit.TestHttpClient
+import io.ably.pubsub.uts.unit.objects.fromJson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import java.util.UUID
+import kotlin.test.assertContentEquals
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`,
+ * via [SandboxApp.sandboxHost]) — no proxy, no fault injection. Provisions one throwaway
+ * [SandboxApp] for the suite and sends real HTTP requests straight to the sandbox.
+ *
+ * End-to-end `HttpObject` (spec `RestObject`): `publish`, `get`/`getCompact` and `generateObjectId` against the
+ * real service.
+ *
+ * Spec points: RSO2, RSO2g, RSO3, RSO3c, RSO4, RSO5, RSO5a1, RSO5c3, RSO5d-RSO5j, RSO6,
+ * RSO9, RSO10, RSO14, RSO15. Source spec: `objects/integration/rest_object_test.md`.
+ *
+ * `objects/integration/RSL16/realtime-write-rest-read-0` writes through the realtime LiveObjects
+ * plugin, so it lives in `:liveobjects` as `RestObjectRealtimeInteropTest`.
+ *
+ * Each test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension —
+ * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class RestObjectTest {
+
+ private lateinit var app: SandboxApp
+
+ @BeforeAll
+ fun setUpAll() = runBlocking {
+ app = SandboxApp.create()
+ }
+
+ @AfterAll
+ fun tearDownAll() = runBlocking {
+ if (::app.isInitialized) app.delete()
+ }
+
+ /** The spec's `create_rest_channel(test_name)`. */
+ private fun createRestChannel(testName: String, useBinaryProtocol: Boolean): Channel {
+ val client = TestHttpClient {
+ key = app.defaultKey
+ endpoint = SandboxApp.sandboxEndpoint
+ this.useBinaryProtocol = useBinaryProtocol
+ }
+ return client.channels.get("objects-rest-$testName-" + UUID.randomUUID())
+ }
+
+ /**
+ * The spec's `poll_until_success(condition)`: re-runs [condition] until it returns non-null,
+ * treating a thrown error (e.g. not-found before a write is visible) as "not yet".
+ */
+ private suspend fun pollUntilSuccess(
+ timeout: Duration = 20.seconds,
+ interval: Duration = 250.milliseconds,
+ condition: suspend () -> T?,
+ ): T = withContext(Dispatchers.Default) {
+ withTimeout(timeout) {
+ var lastError: Throwable? = null
+ while (true) {
+ try {
+ condition()?.let { return@withTimeout it }
+ } catch (e: AblyException) {
+ lastError = e
+ }
+ delay(interval)
+ }
+ @Suppress("UNREACHABLE_CODE")
+ throw AssertionError("poll_until_success timed out", lastError)
+ }
+ }
+
+ private fun json(text: String) = JsonParser.parseString(text)
+
+ /**
+ * `channel.object.getCompact(params)`: the compact result, converted from JSON to plain values
+ * (maps, lists, strings, numbers, booleans) so specs' literals compare directly.
+ */
+ private fun Channel.compact(params: Array ? = null): Any? = `object`.getCompact(params)?.let(::fromJson)
+
+ /** Normalises every number in a compact value to `Double`, for numeric comparison. */
+ private fun numbersAsDouble(value: Any?): Any? = when (value) {
+ is Number -> value.toDouble()
+ is Map<*, *> -> value.mapValues { numbersAsDouble(it.value) }
+ is List<*> -> value.map { numbersAsDouble(it) }
+ else -> value
+ }
+
+ /**
+ * @UTS objects/integration/RSO3/mapset-primitives-roundtrip-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO3, RSO2 - mapSet primitives on the channel object, read back in compact and full modes`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO3-primitives", useBinaryProtocol)
+
+ val publishResult = channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapSet("str", PublishObjectData.string("hello")).withObjectId("root"),
+ HttpObjectOperation.mapSet("num", PublishObjectData.number(42.5)).withObjectId("root"),
+ HttpObjectOperation.mapSet("bool", PublishObjectData.bool(true)).withObjectId("root"),
+ HttpObjectOperation.mapSet("bin", PublishObjectData.bytes(byteArrayOf(0x01, 0x02, 0x03))).withObjectId("root"),
+ HttpObjectOperation.mapSet("jobj", PublishObjectData.json(json("{\"a\":[1,2]}").asJsonObject)).withObjectId("root"),
+ HttpObjectOperation.mapSet("jarr", PublishObjectData.json(json("[\"x\",1]").asJsonArray)).withObjectId("root"),
+ ),
+ )
+
+ val compact = pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ if (c?.get("jarr") != null) c else null
+ }
+
+ val full = channel.`object`.get()
+
+ assertIs(publishResult.messageId)
+ assertTrue(publishResult.messageId.isNotEmpty())
+ assertEquals(channel.name, publishResult.channel)
+ assertIs>(publishResult.objectIds)
+
+ assertEquals("hello", compact["str"])
+ assertEquals(42.5, compact["num"])
+ assertEquals(true, compact["bool"])
+
+ assertIs(full)
+ assertEquals("root", full.objectId)
+ assertEquals(ObjectsMapSemantics.LWW, full.semantics)
+ fun leaf(key: String) = assertIs(full.entries.getValue(key).data)
+ assertEquals("hello", leaf("str").string)
+ assertEquals(42.5, leaf("num").number)
+ assertEquals(true, leaf("bool").boolean)
+ assertIs(leaf("bin").bytes)
+ assertContentEquals(byteArrayOf(0x01, 0x02, 0x03), leaf("bin").bytes)
+ assertEquals(json("{\"a\":[1,2]}"), leaf("jobj").json)
+ assertEquals(json("[\"x\",1]"), leaf("jarr").json)
+ }
+
+ /**
+ * @UTS objects/integration/RSO2g3/full-leaf-via-path-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO2g3 - full-mode get of a leaf value via path`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO2g3-leaf", useBinaryProtocol)
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapSet("bin", PublishObjectData.bytes(byteArrayOf(0xFF.toByte(), 0x00))).withObjectId("root"),
+ HttpObjectOperation.mapSet("prefs", PublishObjectData.json(JsonObject().apply { addProperty("theme", "dark") })).withObjectId("root"),
+ ),
+ )
+
+ val binLeaf = pollUntilSuccess {
+ channel.`object`.get(HttpObjectGetParams.ofPath("bin"))
+ }
+ val jsonLeaf = pollUntilSuccess {
+ channel.`object`.get(HttpObjectGetParams.ofPath("prefs"))
+ }
+
+ assertIs(binLeaf)
+ assertIs(binLeaf.bytes)
+ assertContentEquals(byteArrayOf(0xFF.toByte(), 0x00), binLeaf.bytes)
+ // `encoding == null`: HttpObjectData has no encoding accessor (RSO9c1 by construction).
+ assertNull(binLeaf.string)
+
+ assertIs(jsonLeaf)
+ assertEquals(json("{\"theme\":\"dark\"}"), jsonLeaf.json)
+ assertNull(jsonLeaf.string)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5i/counter-create-increment-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5g2, RSO5i, RSO15 - counterCreate at a path, counterInc via path and objectId`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5i-counter", useBinaryProtocol)
+
+ val createResult = channel.`object`.publish(HttpObjectOperation.counterCreate(10).withPath("visits"))
+
+ val counterId = assertNotNull(createResult.objectIds.find { it.startsWith("counter:") })
+
+ channel.`object`.publish(HttpObjectOperation.counterInc(5).withPath("visits"))
+ channel.`object`.publish(HttpObjectOperation.counterInc(-3).withObjectId(counterId))
+
+ pollUntilSuccess {
+ val value = channel.compact(HttpObjectGetParams.ofPath("visits")) as? Number
+ if (value?.toDouble() == 12.0) true else null
+ }
+
+ val full = channel.`object`.get(HttpObjectGetParams.ofObjectId(counterId))
+
+ assertNotNull(counterId)
+
+ assertIs(full)
+ assertEquals(counterId, full.objectId)
+ assertEquals(12.0, full.count)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5j/standalone-map-create-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5c3, RSO5j - standalone mapCreate returns a server-generated objectId`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5j-standalone", useBinaryProtocol)
+
+ val result = channel.`object`.publish(
+ HttpObjectOperation.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf(
+ "name" to PublishObjectData.string("Alice"),
+ "age" to PublishObjectData.number(30),
+ "active" to PublishObjectData.bool(true),
+ "avatar" to PublishObjectData.bytes(byteArrayOf(0x0A, 0x0B)),
+ "prefs" to PublishObjectData.json(JsonObject().apply { addProperty("theme", "dark") }),
+ "tags" to PublishObjectData.json(json("[\"a\",\"b\"]").asJsonArray),
+ ),
+ ),
+ )
+
+ val mapId = result.objectIds[0]
+
+ val full = pollUntilSuccess {
+ channel.`object`.get(HttpObjectGetParams.ofObjectId(mapId))
+ }
+
+ assertEquals(1, result.objectIds.size)
+ assertTrue(mapId.startsWith("map:"))
+
+ assertIs(full)
+ assertEquals(mapId, full.objectId)
+ fun leaf(key: String) = assertIs(full.entries.getValue(key).data)
+ assertEquals("Alice", leaf("name").string)
+ assertEquals(30.0, leaf("age").number)
+ assertEquals(true, leaf("active").boolean)
+ assertIs(leaf("avatar").bytes)
+ assertContentEquals(byteArrayOf(0x0A, 0x0B), leaf("avatar").bytes)
+ assertEquals(json("{\"theme\":\"dark\"}"), leaf("prefs").json)
+ assertEquals(json("[\"a\",\"b\"]"), leaf("tags").json)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5c3/map-create-at-path-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5c3, RSO2g1 - mapCreate at a path is reachable from the channel object`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5c3-path", useBinaryProtocol)
+
+ channel.`object`.publish(
+ HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, mapOf("nickname" to PublishObjectData.string("ally")))
+ .withPath("profile"),
+ )
+
+ val rootFull = pollUntilSuccess {
+ val r = channel.`object`.get() as? HttpLiveMap
+ if (r != null && "profile" in r.entries) r else null
+ }
+
+ val profile = rootFull.entries.getValue("profile").data
+ val nicknameViaPath = channel.compact(HttpObjectGetParams.ofPath("profile.nickname"))
+ assertIs(profile)
+ val nicknameViaId = channel.compact(HttpObjectGetParams.of(profile.objectId, "nickname"))
+
+ assertTrue(profile.objectId.startsWith("map:"))
+ assertEquals("ally", assertIs(profile.entries.getValue("nickname").data).string)
+
+ assertEquals("ally", nicknameViaPath)
+ assertEquals("ally", nicknameViaId)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5e/mapset-nested-path-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5e, RSO6 - mapSet targeted by a nested path`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5e-nested-path", useBinaryProtocol)
+
+ channel.`object`.publish(HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("outer"))
+ pollUntilSuccess { channel.compact(HttpObjectGetParams.ofPath("outer")) }
+
+ channel.`object`.publish(HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("outer.inner"))
+ pollUntilSuccess { channel.compact(HttpObjectGetParams.ofPath("outer.inner")) }
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapSet("shallow", PublishObjectData.number(1)).withPath("outer"),
+ HttpObjectOperation.mapSet("deep", PublishObjectData.string("value")).withPath("outer.inner"),
+ ),
+ )
+
+ val compact = pollUntilSuccess {
+ val c = numbersAsDouble(channel.compact()) as? Map<*, *>
+ val outer = c?.get("outer") as? Map<*, *>
+ if (outer?.get("shallow") == 1.0 && (outer["inner"] as? Map<*, *>)?.get("deep") == "value") c else null
+ }
+
+ // Numbers compared numerically (normalised to Double above).
+ assertEquals(mapOf("shallow" to 1.0, "inner" to mapOf("deep" to "value")), compact["outer"])
+ }
+
+ /**
+ * @UTS objects/integration/RSO5e/mapset-object-reference-1
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5e, RSO7 - mapSet with an objectId reference to an existing object`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5e-object-ref", useBinaryProtocol)
+
+ val createResult = channel.`object`.publish(HttpObjectOperation.counterCreate(5).withPath("first"))
+ val counterId = createResult.objectIds.find { it.startsWith("counter:") }
+
+ pollUntilSuccess {
+ val value = channel.compact(HttpObjectGetParams.ofPath("first")) as? Number
+ if (value?.toDouble() == 5.0) true else null
+ }
+
+ val setResult = channel.`object`.publish(
+ HttpObjectOperation.mapSet("second", PublishObjectData.objectId(counterId!!)).withObjectId("root"),
+ )
+
+ val full = pollUntilSuccess {
+ val r = channel.`object`.get() as? HttpLiveMap
+ if (r != null && "second" in r.entries) r else null
+ }
+
+ val counter = channel.`object`.get(HttpObjectGetParams.ofObjectId(counterId))
+
+ assertNotNull(counterId)
+
+ assertIs(setResult.messageId)
+ assertTrue(setResult.messageId.isNotEmpty())
+ assertEquals(channel.name, setResult.channel)
+ assertIs>(setResult.objectIds)
+
+ // Each key holds the counter inlined (HttpLiveCounter) or as a bare reference (a plain
+ // HttpLiveObject) - both are HttpLiveObject, so check only the objectId, per the spec.
+ assertEquals(counterId, assertIs(full.entries.getValue("first").data).objectId)
+ assertEquals(counterId, assertIs(full.entries.getValue("second").data).objectId)
+
+ assertIs(counter)
+ assertEquals(5.0, counter.count)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5f/map-remove-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5f - mapRemove via path and via objectId`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5f-remove", useBinaryProtocol)
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf("a" to PublishObjectData.number(1), "b" to PublishObjectData.number(2)),
+ ).withPath("settings"),
+ HttpObjectOperation.mapSet("keep", PublishObjectData.string("x")).withObjectId("root"),
+ HttpObjectOperation.mapSet("drop", PublishObjectData.string("y")).withObjectId("root"),
+ ),
+ )
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapRemove("a").withPath("settings"),
+ HttpObjectOperation.mapRemove("drop").withObjectId("root"),
+ ),
+ )
+
+ val compact = pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ val settings = c?.get("settings") as? Map<*, *>
+ if (c != null && "drop" !in c && settings != null && "a" !in settings) c else null
+ }
+
+ assertEquals("x", compact["keep"])
+ assertFalse("drop" in compact)
+ // Compared numerically: the server returns 2 over JSON and 2.0 over msgpack.
+ assertEquals(mapOf("b" to 2.0), numbersAsDouble(compact["settings"]))
+ }
+
+ /**
+ * @UTS objects/integration/RSO6/wildcard-path-passthrough-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO6 - wildcard path passed through to the server`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO6-wildcard", useBinaryProtocol)
+
+ channel.`object`.publish(HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("items"))
+ pollUntilSuccess { channel.compact(HttpObjectGetParams.ofPath("items")) }
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("items.one"),
+ HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("items.two"),
+ ),
+ )
+ pollUntilSuccess {
+ val i = channel.compact(HttpObjectGetParams.ofPath("items")) as? Map<*, *>
+ if (i != null && "one" in i && "two" in i) true else null
+ }
+
+ channel.`object`.publish(HttpObjectOperation.mapSet("flag", PublishObjectData.bool(true)).withPath("items.*"))
+
+ val items = pollUntilSuccess {
+ val i = channel.compact(HttpObjectGetParams.ofPath("items")) as? Map<*, *>
+ if ((i?.get("one") as? Map<*, *>)?.get("flag") == true && (i["two"] as? Map<*, *>)?.get("flag") == true) i else null
+ }
+
+ assertEquals(true, (items["one"] as Map<*, *>)["flag"])
+ assertEquals(true, (items["two"] as Map<*, *>)["flag"])
+ }
+
+ /**
+ * @UTS objects/integration/RSO4/generated-ids-batch-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO4, RSO5d, RSO5h - locally generated object IDs accepted in a single batch`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO4-generated", useBinaryProtocol)
+
+ val counter = channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(7))
+ val map = channel.`object`.generateObjectId(
+ HttpObjectGenerateIdBody.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf(
+ "visits" to PublishObjectData.objectId(counter.objectId),
+ "label" to PublishObjectData.string("stats"),
+ "raw" to PublishObjectData.bytes(byteArrayOf(0x01)),
+ ),
+ ),
+ )
+
+ val result = channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.counterCreateWithObjectId(counter),
+ HttpObjectOperation.mapCreateWithObjectId(map),
+ HttpObjectOperation.mapSet("stats", PublishObjectData.objectId(map.objectId)).withObjectId("root"),
+ ),
+ )
+
+ val stats = pollUntilSuccess {
+ channel.`object`.get(HttpObjectGetParams.ofPath("stats"))
+ }
+
+ assertTrue(counter.objectId.startsWith("counter:"))
+ assertTrue(map.objectId.startsWith("map:"))
+ assertContains(result.objectIds, counter.objectId)
+ assertContains(result.objectIds, map.objectId)
+
+ assertIs(stats)
+ assertEquals(map.objectId, stats.objectId)
+ assertEquals("stats", assertIs(stats.entries.getValue("label").data).string)
+ assertContentEquals(byteArrayOf(0x01), assertIs(stats.entries.getValue("raw").data).bytes)
+
+ val visits = stats.entries.getValue("visits").data
+ assertIs(visits)
+ assertEquals(counter.objectId, visits.objectId)
+ assertEquals(7.0, visits.count)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5a1/idempotent-operation-id-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5a1 - operation id makes a publish idempotent`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5a1-idempotent", useBinaryProtocol)
+
+ channel.`object`.publish(HttpObjectOperation.counterCreate(0).withPath("count"))
+ pollUntilSuccess {
+ val value = channel.compact(HttpObjectGetParams.ofPath("count")) as? Number
+ if (value?.toDouble() == 0.0) true else null
+ }
+
+ val opId = "idem-" + UUID.randomUUID()
+
+ channel.`object`.publish(HttpObjectOperation.counterInc(1).withId(opId).withPath("count"))
+ channel.`object`.publish(HttpObjectOperation.counterInc(1).withId(opId).withPath("count"))
+
+ // Marker write published after both increments; once it is visible, both increments have been processed
+ channel.`object`.publish(HttpObjectOperation.mapSet("marker", PublishObjectData.bool(true)).withObjectId("root"))
+ val compact = pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ if (c?.get("marker") == true) c else null
+ }
+
+ assertEquals(1.0, (compact["count"] as Number).toDouble())
+ }
+
+ /**
+ * @UTS objects/integration/RSO2h/unresolvable-target-error-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO2h - server error for an unresolvable target surfaced as ErrorInfo`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO2h-errors", useBinaryProtocol)
+
+ val error = assertFailsWith {
+ channel.compact(
+ HttpObjectGetParams.ofObjectId("map:doesNotExistAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@1700000000000"),
+ )
+ }
+ assertNotNull(error.errorInfo)
+ assertTrue(error.errorInfo.statusCode in 400..499, "statusCode was ${error.errorInfo.statusCode}")
+
+ val pathError = assertFailsWith {
+ channel.compact(HttpObjectGetParams.ofPath("no.such.path"))
+ }
+ assertNotNull(pathError.errorInfo)
+ assertTrue(pathError.errorInfo.statusCode in 400..499, "statusCode was ${pathError.errorInfo.statusCode}")
+ }
+
+ /**
+ * @UTS objects/integration/RSO3/mapset-edge-values-roundtrip-1
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO3, RSO2 - edge-case leaf values round-trip without being dropped or altered`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO3-edge-values", useBinaryProtocol)
+
+ val maxExactInt = 9007199254740991L // 2^53 - 1
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapSet("emptyStr", PublishObjectData.string("")).withObjectId("root"),
+ HttpObjectOperation.mapSet("emptyBin", PublishObjectData.bytes(byteArrayOf())).withObjectId("root"),
+ HttpObjectOperation.mapSet("zero", PublishObjectData.number(0)).withObjectId("root"),
+ HttpObjectOperation.mapSet("maxInt", PublishObjectData.number(maxExactInt)).withObjectId("root"),
+ HttpObjectOperation.mapSet("minInt", PublishObjectData.number(-maxExactInt)).withObjectId("root"),
+ HttpObjectOperation.mapSet("no", PublishObjectData.bool(false)).withObjectId("root"),
+ ),
+ )
+
+ val compact = pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ if (c != null && "no" in c) c else null
+ }
+
+ val full = channel.`object`.get()
+
+ // Numbers compared numerically: compact numbers arrive as Long over JSON and may be Double over msgpack.
+ assertEquals("", compact["emptyStr"])
+ assertEquals(0.0, numbersAsDouble(compact["zero"]))
+ assertEquals(maxExactInt.toDouble(), numbersAsDouble(compact["maxInt"]))
+ assertEquals(-maxExactInt.toDouble(), numbersAsDouble(compact["minInt"]))
+ assertEquals(false, compact["no"])
+
+ assertIs(full)
+ fun leaf(key: String) = assertIs(full.entries.getValue(key).data)
+ assertEquals("", leaf("emptyStr").string)
+ assertIs(leaf("emptyBin").bytes)
+ assertContentEquals(byteArrayOf(), leaf("emptyBin").bytes)
+ assertEquals(0.0, leaf("zero").number)
+ assertEquals(maxExactInt.toDouble(), leaf("maxInt").number)
+ assertEquals(-maxExactInt.toDouble(), leaf("minInt").number)
+ assertEquals(false, leaf("no").boolean)
+ }
+
+ /**
+ * @UTS objects/integration/RSO5g2/standalone-counter-create-1
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO5g2, RSO5j - standalone counterCreate returns a server-generated objectId`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO5g2-standalone", useBinaryProtocol)
+
+ val result = channel.`object`.publish(HttpObjectOperation.counterCreate(42))
+
+ val counterId = result.objectIds[0]
+
+ val full = pollUntilSuccess {
+ channel.`object`.get(HttpObjectGetParams.ofObjectId(counterId))
+ }
+
+ assertEquals(1, result.objectIds.size)
+ assertTrue(counterId.startsWith("counter:"))
+
+ assertIs(full)
+ assertEquals(counterId, full.objectId)
+ assertEquals(42.0, full.count)
+ }
+
+ /**
+ * @UTS objects/integration/RSO6/wildcard-remove-increment-1
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RSO6, RSO5f, RSO5i - wildcard path with mapRemove and counterInc`(useBinaryProtocol: Boolean) = runTest {
+ val channel = createRestChannel("RSO6-wildcard-ops", useBinaryProtocol)
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("maps"),
+ HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("counters"),
+ ),
+ )
+ pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ if (c != null && "maps" in c && "counters" in c) true else null
+ }
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf("foo" to PublishObjectData.string("a"), "keep" to PublishObjectData.number(1)),
+ ).withPath("maps.one"),
+ HttpObjectOperation.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf("foo" to PublishObjectData.string("b"), "keep" to PublishObjectData.number(2)),
+ ).withPath("maps.two"),
+ HttpObjectOperation.counterCreate(10).withPath("counters.c1"),
+ HttpObjectOperation.counterCreate(20).withPath("counters.c2"),
+ ),
+ )
+ pollUntilSuccess {
+ val c = channel.compact() as? Map<*, *>
+ val maps = c?.get("maps") as? Map<*, *>
+ val counters = c?.get("counters") as? Map<*, *>
+ if (maps != null && counters != null && "one" in maps && "two" in maps && "c1" in counters && "c2" in counters) true else null
+ }
+
+ channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.mapRemove("foo").withPath("maps.*"),
+ HttpObjectOperation.counterInc(5).withPath("counters.*"),
+ ),
+ )
+
+ val compact = pollUntilSuccess {
+ val c = numbersAsDouble(channel.compact()) as? Map<*, *>
+ val maps = c?.get("maps") as? Map<*, *>
+ val counters = c?.get("counters") as? Map<*, *>
+ val one = maps?.get("one") as? Map<*, *>
+ val two = maps?.get("two") as? Map<*, *>
+ if (one != null && two != null && "foo" !in one && "foo" !in two &&
+ counters?.get("c1") == 15.0 && counters["c2"] == 25.0
+ ) c else null
+ }
+
+ // Numbers compared numerically (normalised to Double above).
+ val maps = compact["maps"] as Map<*, *>
+ val counters = compact["counters"] as Map<*, *>
+ assertEquals(mapOf("keep" to 1.0), maps["one"])
+ assertEquals(mapOf("keep" to 2.0), maps["two"])
+ assertEquals(15.0, counters["c1"])
+ assertEquals(25.0, counters["c2"])
+ }
+}
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/HttpObjectHelpers.kt b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/HttpObjectHelpers.kt
new file mode 100644
index 000000000..3953aa5ee
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/HttpObjectHelpers.kt
@@ -0,0 +1,174 @@
+package io.ably.pubsub.uts.unit.objects
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonParser
+import io.ably.pubsub.http.Channel
+import io.ably.pubsub.http.PubSubHttpClient
+import io.ably.pubsub.uts.infra.unit.ClientOptionsBuilder
+import io.ably.pubsub.uts.infra.unit.MockHttpClient
+import io.ably.pubsub.uts.infra.unit.PendingRequest
+import io.ably.pubsub.uts.infra.unit.TestHttpClient
+import org.msgpack.core.MessagePack
+import org.msgpack.core.MessagePacker
+import org.msgpack.value.Value
+import org.msgpack.value.ValueType
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.util.Collections
+import java.util.UUID
+
+/**
+ * Helpers for the `HttpObject` (spec `RestObject`) unit specs — `rest_object_get.md`,
+ * `rest_object_publish.md` and `rest_object_generate_id.md` — which run against the mocked HTTP
+ * client rather than the realtime harness in [Helpers.kt].
+ *
+ * Bodies and expectations use plain Kotlin values: maps, lists, strings, booleans, `ByteArray`,
+ * and numbers as `Long` (integral) or `Double` (fractional), so spec JSON literals translate to
+ * `mapOf(...)` with `Long` integers. `getCompact` returns a `JsonElement`; [fromJson] converts it
+ * to this representation.
+ */
+
+/** The spec's `random_id()`. */
+fun randomId(): String = UUID.randomUUID().toString().take(8)
+
+/** The spec's `setup_rest_object(...)` result. */
+class RestObjectSetup(
+ val client: PubSubHttpClient,
+ val channel: Channel,
+ val capturedRequests: MutableList,
+)
+
+/**
+ * The spec's `setup_rest_object(channel_name, ...)`: an HTTP client on a mock that records every
+ * request and answers it with [respond]. `HttpObject` is built into the HTTP client, so the
+ * spec's `plugins: { Objects: objects_plugin }` needs no wiring.
+ */
+fun setupRestObject(
+ channelName: String,
+ useBinaryProtocol: Boolean = false,
+ clientOptions: ClientOptionsBuilder.() -> Unit = {},
+ respond: (PendingRequest) -> Unit,
+): RestObjectSetup {
+ val captured = Collections.synchronizedList(mutableListOf())
+ val mock = MockHttpClient {
+ onConnectionAttempt = { it.respondWithSuccess() }
+ onRequest = { req ->
+ captured.add(req)
+ respond(req)
+ }
+ }
+ val client = TestHttpClient {
+ this.useBinaryProtocol = useBinaryProtocol
+ install(mock)
+ clientOptions()
+ }
+ return RestObjectSetup(client, client.channels.get(channelName), captured)
+}
+
+/** Responds with [body] encoded as JSON or MessagePack, with the matching `Content-Type`. */
+fun PendingRequest.respondWithBody(status: Int, body: Any?, useBinaryProtocol: Boolean) {
+ if (useBinaryProtocol) {
+ respondWith(status, msgpackEncode(body), mapOf("Content-Type" to "application/x-msgpack"))
+ } else {
+ respondWith(status, jsonEncode(body), mapOf("Content-Type" to "application/json"))
+ }
+}
+
+/** `request.url.path` — the raw (still percent-encoded) path. */
+val PendingRequest.path: String get() = url.path
+
+/** `request.url.query_params` — URL-decoded query parameters. */
+val PendingRequest.queryParams: Map
+ get() = url.query.orEmpty().split("&").filter { it.isNotEmpty() }.associate { pair ->
+ val idx = pair.indexOf('=')
+ val key = if (idx < 0) pair else pair.substring(0, idx)
+ val value = if (idx < 0) "" else pair.substring(idx + 1)
+ URLDecoder.decode(key, "UTF-8") to URLDecoder.decode(value, "UTF-8")
+ }
+
+/** Case-insensitive single header lookup. */
+fun PendingRequest.header(name: String): String? =
+ headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
+
+/**
+ * The spec's `encode_uri_component` — JavaScript's `encodeURIComponent`, written independently of
+ * the SDK's own encoder so the tests check it rather than restate it.
+ */
+fun encodeUriComponent(value: String): String =
+ URLEncoder.encode(value, "UTF-8")
+ .replace("+", "%20")
+ .replace("%21", "!")
+ .replace("%27", "'")
+ .replace("%28", "(")
+ .replace("%29", ")")
+ .replace("%7E", "~")
+
+//region JSON / MessagePack <-> plain values
+
+/** The spec's `parse_json` over a request body or JSON string. */
+fun parseJson(bytes: ByteArray): Any? = parseJson(String(bytes, Charsets.UTF_8))
+
+fun parseJson(text: String): Any? = fromJson(JsonParser.parseString(text))
+
+/** A JSON value as plain Kotlin values, e.g. for comparison with `HttpObjectData.json`. */
+fun fromJson(element: JsonElement): Any? = when {
+ element.isJsonNull -> null
+ element.isJsonObject -> element.asJsonObject.entrySet().associateTo(LinkedHashMap()) { it.key to fromJson(it.value) }
+ element.isJsonArray -> element.asJsonArray.map { fromJson(it) }
+ element.asJsonPrimitive.isBoolean -> element.asBoolean
+ element.asJsonPrimitive.isNumber -> element.asString.let { it.toLongOrNull() ?: it.toDouble() }
+ else -> element.asString
+}
+
+fun jsonEncode(value: Any?): String = io.ably.pubsub.util.Serialisation.gson.toJson(value)
+
+/** The spec's `msgpack_encode`. */
+fun msgpackEncode(value: Any?): ByteArray =
+ MessagePack.newDefaultBufferPacker().use { packer ->
+ pack(packer, value)
+ packer.toByteArray()
+ }
+
+/** The spec's `msgpack_decode`. */
+fun msgpackDecode(bytes: ByteArray): Any? =
+ MessagePack.newDefaultUnpacker(bytes).use { fromMsgpack(it.unpackValue()) }
+
+private fun pack(packer: MessagePacker, value: Any?) {
+ when (value) {
+ null -> packer.packNil()
+ is String -> packer.packString(value)
+ is Boolean -> packer.packBoolean(value)
+ is Double, is Float -> packer.packDouble((value as Number).toDouble())
+ is java.math.BigInteger -> packer.packBigInteger(value)
+ is Number -> packer.packLong(value.toLong())
+ is ByteArray -> {
+ packer.packBinaryHeader(value.size)
+ packer.writePayload(value)
+ }
+ is Map<*, *> -> {
+ packer.packMapHeader(value.size)
+ value.forEach { (k, v) -> packer.packString(k.toString()); pack(packer, v) }
+ }
+ is List<*> -> {
+ packer.packArrayHeader(value.size)
+ value.forEach { pack(packer, it) }
+ }
+ else -> error("Unsupported value for msgpack: $value")
+ }
+}
+
+private fun fromMsgpack(value: Value): Any? = when (value.valueType) {
+ ValueType.NIL -> null
+ ValueType.BOOLEAN -> value.asBooleanValue().boolean
+ ValueType.INTEGER -> value.asIntegerValue().toLong()
+ ValueType.FLOAT -> value.asFloatValue().toDouble()
+ ValueType.STRING -> value.asStringValue().asString()
+ ValueType.BINARY -> value.asBinaryValue().asByteArray()
+ ValueType.ARRAY -> value.asArrayValue().map { fromMsgpack(it) }
+ ValueType.MAP -> value.asMapValue().map().entries.associateTo(LinkedHashMap()) { (k, v) ->
+ k.asStringValue().asString() to fromMsgpack(v)
+ }
+ else -> error("Unsupported msgpack value: $value")
+}
+
+//endregion
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGenerateIdTest.kt b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGenerateIdTest.kt
new file mode 100644
index 000000000..c97dfa9b9
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGenerateIdTest.kt
@@ -0,0 +1,281 @@
+package io.ably.pubsub.uts.unit.objects
+
+import com.google.gson.JsonParser
+import io.ably.pubsub.liveobjects.http.HttpObjectGenerateIdBody
+import io.ably.pubsub.liveobjects.http.HttpObjectOperation
+import io.ably.pubsub.liveobjects.http.PublishObjectData
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics
+import io.ably.pubsub.types.AblyException
+import kotlinx.coroutines.test.runTest
+import java.security.MessageDigest
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+/**
+ * `RestObject#generateObjectId` — ably-java's `HttpObject.generateObjectId`. Spec
+ * `RestObjectGenerateIdBody(mapCreate: ...)` / `(counterCreate: ...)` are the
+ * `HttpObjectGenerateIdBody.mapCreate` / `.counterCreate` factories.
+ *
+ * Source spec: `objects/unit/rest_object_generate_id.md`. Spec points: RSO4, RSO4a1-RSO4k, RSO5d,
+ * RSO5h, RSO12.
+ */
+class RestObjectGenerateIdTest {
+
+ private val serverTime = 1700000000000L
+
+ /** The spec's `setup_rest_object(channel_name, use_binary_protocol)`. */
+ private fun setup(channelName: String, useBinaryProtocol: Boolean = false) =
+ setupRestObject(channelName, useBinaryProtocol) { req ->
+ if (req.path == "/time") {
+ req.respondWith(200, listOf(serverTime))
+ } else {
+ req.respondWith(201, mapOf("messageId" to "msg-id-1", "channel" to channelName, "objectIds" to emptyList()))
+ }
+ }
+
+ private fun parseInitialValue(initialValue: String): Map<*, *> = assertIs>(parseJson(initialValue))
+
+ /**
+ * @UTS objects/unit/RSO4d/map-type-objectid-0
+ */
+ @Test
+ fun `RSO4d - map create body yields a map objectId`() = runTest {
+ val setup = setup("test-RSO4d-map-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(
+ HttpObjectGenerateIdBody.mapCreate(ObjectsMapSemantics.LWW, emptyMap()),
+ )
+
+ assertTrue(result.objectId.startsWith("map:"))
+ }
+
+ /**
+ * @UTS objects/unit/RSO4d/counter-type-objectid-1
+ */
+ @Test
+ fun `RSO4d - counter create body yields a counter objectId`() = runTest {
+ val setup = setup("test-RSO4d-counter-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(5))
+
+ assertTrue(result.objectId.startsWith("counter:"))
+ }
+
+ /**
+ * @UTS objects/unit/RSO4h/objectid-derivation-0
+ */
+ @Test
+ fun `RSO4g, RSO4h - objectId derived per RTO14 from initialValue, nonce and server time`() = runTest {
+ val setup = setup("test-RSO4h-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(42))
+
+ val timeRequests = setup.capturedRequests.filter { it.path == "/time" }
+ assertEquals(1, timeRequests.size)
+ assertEquals("GET", timeRequests[0].method)
+
+ val expectedHash = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ MessageDigest.getInstance("SHA-256").digest((result.initialValue + ":" + result.nonce).toByteArray(Charsets.UTF_8)),
+ )
+ assertEquals("counter:$expectedHash@$serverTime", result.objectId)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4e2/counter-initial-value-0
+ */
+ @Test
+ fun `RSO4e2 - counter initialValue is the JSON representation of the counter create body`() = runTest {
+ val setup = setup("test-RSO4e2-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(42))
+
+ assertEquals(mapOf("count" to 42L), parseJson(result.initialValue))
+ }
+
+ /**
+ * @UTS objects/unit/RSO4e1/map-initial-value-0
+ */
+ @Test
+ fun `RSO4e1 - map initialValue encodes semantics and entry data with JSON protocol rules`() = runTest {
+ val setup = setup("test-RSO4e1-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(
+ HttpObjectGenerateIdBody.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf(
+ "name" to PublishObjectData.string("Alice"),
+ "age" to PublishObjectData.number(30),
+ "active" to PublishObjectData.bool(true),
+ "avatar" to PublishObjectData.bytes(byteArrayOf(0x01, 0x02, 0x03)),
+ "prefs" to PublishObjectData.json(JsonParser.parseString("{\"theme\":\"dark\"}").asJsonObject),
+ "visits" to PublishObjectData.objectId("counter:h@1"),
+ ),
+ ),
+ )
+
+ val initialValue = parseInitialValue(result.initialValue)
+ assertEquals(0L, initialValue["semantics"])
+
+ val entries = assertIs>(initialValue["entries"])
+ assertEquals(setOf("name", "age", "active", "avatar", "prefs", "visits"), entries.keys)
+ assertEquals(mapOf("data" to mapOf("string" to "Alice")), entries["name"])
+ assertEquals(mapOf("data" to mapOf("number" to 30L)), entries["age"])
+ assertEquals(mapOf("data" to mapOf("boolean" to true)), entries["active"])
+ assertEquals(mapOf("data" to mapOf("bytes" to "AQID")), entries["avatar"])
+ val prefsData = assertIs>(assertIs>(entries["prefs"])["data"])
+ assertEquals(setOf("json"), prefsData.keys)
+ assertEquals(mapOf("theme" to "dark"), parseJson(prefsData["json"] as String))
+ assertEquals(mapOf("data" to mapOf("objectId" to "counter:h@1")), entries["visits"])
+ }
+
+ /**
+ * @UTS objects/unit/RSO4e/json-regardless-of-protocol-0
+ */
+ @Test
+ fun `RSO4e - initialValue uses JSON encoding regardless of the client protocol`() = runTest {
+ val createBody = HttpObjectGenerateIdBody.mapCreate(
+ ObjectsMapSemantics.LWW,
+ mapOf("avatar" to PublishObjectData.bytes(byteArrayOf(0x01, 0x02, 0x03))),
+ )
+ val jsonChannel = setup("test-RSO4e-json-" + randomId()).channel
+ val binaryChannel = setup("test-RSO4e-binary-" + randomId(), useBinaryProtocol = true).channel
+
+ val jsonResult = jsonChannel.`object`.generateObjectId(createBody)
+ val binaryResult = binaryChannel.`object`.generateObjectId(createBody)
+
+ assertIs(binaryResult.initialValue)
+ assertEquals(jsonResult.initialValue, binaryResult.initialValue)
+ val avatarData = assertIs>(
+ assertIs>(assertIs>(parseInitialValue(binaryResult.initialValue)["entries"])["avatar"])["data"],
+ )
+ assertEquals("AQID", avatarData["bytes"])
+ }
+
+ /**
+ * @UTS objects/unit/RSO4e1a/unrecognized-semantics-error-0
+ */
+ @Test
+ fun `RSO4e1a - unrecognized mapCreate semantics rejected`() = runTest {
+ val setup = setup("test-RSO4e1a-" + randomId())
+
+ // The closed enum's only unrecognized value is UNKNOWN (spec "not-a-semantics").
+ val error = assertFailsWith {
+ setup.channel.`object`.generateObjectId(
+ HttpObjectGenerateIdBody.mapCreate(ObjectsMapSemantics.UNKNOWN, emptyMap()),
+ )
+ }
+
+ assertEquals(40003, error.errorInfo.code)
+ assertEquals(400, error.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4c/missing-create-body-error-0
+ */
+ @Test
+ fun `RSO4c - missing create body rejected`() = runTest {
+ // DEVIATION T-4 (not applicable, per the spec's own note): HttpObjectGenerateIdBody can only
+ // be built through the mapCreate / counterCreate factories, so an empty body is
+ // unrepresentable and the runtime 40003 check can't be reached. See deviations.md.
+ val setup = setup("test-RSO4c-" + randomId())
+ assertEquals(0, setup.capturedRequests.size)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4f/nonce-generated-0
+ */
+ @Test
+ fun `RSO4f - nonce is a string of at least 16 characters`() = runTest {
+ val setup = setup("test-RSO4f-" + randomId())
+
+ val result = setup.channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(0))
+
+ assertTrue(result.nonce.length >= 16)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4j/unique-per-call-0
+ */
+ @Test
+ fun `RSO4j - repeated calls produce different objectId and nonce`() = runTest {
+ val setup = setup("test-RSO4j-" + randomId())
+ val createBody = HttpObjectGenerateIdBody.counterCreate(1)
+
+ val first = setup.channel.`object`.generateObjectId(createBody)
+ val second = setup.channel.`object`.generateObjectId(createBody)
+
+ assertNotEquals(first.nonce, second.nonce)
+ assertNotEquals(first.objectId, second.objectId)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4k/initial-value-deterministic-0
+ */
+ @Test
+ fun `RSO4k - initialValue is deterministic for a given create body`() = runTest {
+ val setup = setup("test-RSO4k-" + randomId())
+ val createBody = HttpObjectGenerateIdBody.mapCreate(
+ ObjectsMapSemantics.LWW,
+ linkedMapOf(
+ "a" to PublishObjectData.string("x"),
+ "b" to PublishObjectData.json(JsonParser.parseString("{\"k\":[1,2]}").asJsonObject),
+ ),
+ )
+
+ val first = setup.channel.`object`.generateObjectId(createBody)
+ val second = setup.channel.`object`.generateObjectId(createBody)
+
+ assertEquals(first.initialValue, second.initialValue)
+ }
+
+ /**
+ * @UTS objects/unit/RSO4b/generated-id-publish-roundtrip-0
+ */
+ @Test
+ fun `RSO4b, RSO5d, RSO5h - generated values publish verbatim in WithObjectId operations`() = runTest {
+ val channelName = "test-RSO4b-" + randomId()
+ val setup = setup(channelName)
+
+ val counter = setup.channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(3))
+ val map = setup.channel.`object`.generateObjectId(
+ HttpObjectGenerateIdBody.mapCreate(
+ ObjectsMapSemantics.LWW,
+ mapOf("visits" to PublishObjectData.objectId(counter.objectId)),
+ ),
+ )
+
+ setup.channel.`object`.publish(
+ listOf(
+ HttpObjectOperation.counterCreateWithObjectId(counter.objectId, counter.initialValue, counter.nonce),
+ HttpObjectOperation.mapCreateWithObjectId(map.objectId, map.initialValue, map.nonce),
+ HttpObjectOperation.mapSet("stats", PublishObjectData.objectId(map.objectId)).withObjectId("root"),
+ ),
+ )
+
+ val publishRequests = setup.capturedRequests.filter { it.method == "POST" }
+ assertEquals(1, publishRequests.size)
+ assertEquals("/channels/" + encodeUriComponent(channelName) + "/object", publishRequests[0].path)
+
+ val body = assertIs>(parseJson(publishRequests[0].body))
+ assertEquals(
+ mapOf(
+ "objectId" to counter.objectId,
+ "counterCreateWithObjectId" to mapOf("initialValue" to counter.initialValue, "nonce" to counter.nonce),
+ ),
+ body[0],
+ )
+ assertEquals(
+ mapOf(
+ "objectId" to map.objectId,
+ "mapCreateWithObjectId" to mapOf("initialValue" to map.initialValue, "nonce" to map.nonce),
+ ),
+ body[1],
+ )
+ assertEquals(mapOf("objectId" to map.objectId), assertIs>(assertIs>(body[2])["mapSet"])["value"])
+ }
+}
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGetTest.kt b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGetTest.kt
new file mode 100644
index 000000000..4653f0568
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGetTest.kt
@@ -0,0 +1,507 @@
+package io.ably.pubsub.uts.unit.objects
+
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import io.ably.pubsub.liveobjects.HttpObject
+import io.ably.pubsub.liveobjects.http.HttpLiveCounter
+import io.ably.pubsub.liveobjects.http.HttpLiveMap
+import io.ably.pubsub.liveobjects.http.HttpLiveObject
+import io.ably.pubsub.liveobjects.http.HttpObjectData
+import io.ably.pubsub.liveobjects.http.HttpObjectGetParams
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics
+import io.ably.pubsub.types.AblyException
+import kotlinx.coroutines.test.runTest
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotSame
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+
+/**
+ * `RestChannel#object` and `RestObject#get` — spec `RestObject` is ably-java's [HttpObject], reached
+ * as `` channel.`object` `` on an HTTP channel.
+ *
+ * Source spec: `objects/unit/rest_object_get.md`. Spec points: RSL16, RSL16b, RSO1, RSO1a, RSO2,
+ * RSO2a1-RSO2h, RSO6, RSO8, RSO9, RSO9a-RSO9d, RSO14, RSO15, RSO16.
+ */
+class RestObjectGetTest {
+
+ /** The spec's `setup_rest_object(channel_name, response_body, use_binary_protocol)`. */
+ private fun setup(channelName: String, responseBody: Any?, useBinaryProtocol: Boolean = false) =
+ setupRestObject(channelName, useBinaryProtocol) { req ->
+ req.respondWithBody(200, responseBody, useBinaryProtocol)
+ }
+
+ /**
+ * @UTS objects/unit/RSO1a/one-instance-per-channel-0
+ */
+ @Test
+ fun `RSO1a - one RestObject instance per RestChannel`() = runTest {
+ val (client, channel) = setup("test-RSO1a-a-" + randomId(), emptyMap()).let { it.client to it.channel }
+ val otherChannel = client.channels.get("test-RSO1a-b-" + randomId())
+
+ assertSame(channel.`object`, channel.`object`)
+ assertNotSame(channel.`object`, otherChannel.`object`)
+ }
+
+ /**
+ * @UTS objects/unit/RSL16b/missing-plugin-error-0
+ */
+ @Test
+ fun `RSL16b - accessing channel object without the Objects plugin`() = runTest {
+ // DEVIATION (not applicable, per the spec's own note; see deviations.md "RSL16b"): HttpObject
+ // is built into the HTTP client, so there is no plugin to omit and no 40019 error. Assert
+ // instead that a client configured without any plugin gets a working `channel.object`.
+ val setup = setupRestObject("test-RSL16b-" + randomId()) { req -> req.respondWith(200, emptyMap()) }
+
+ assertIs(setup.channel.`object`)
+ setup.channel.`object`.getCompact()
+ assertEquals(1, setup.capturedRequests.size)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2c/get-default-endpoint-0
+ */
+ @Test
+ fun `RSO2c - get with no params sends GET to channel object endpoint`() = runTest {
+ val channelName = "test-RSO2c-default-" + randomId()
+ val setup = setup(channelName, emptyMap())
+
+ setup.channel.`object`.getCompact()
+
+ assertEquals(1, setup.capturedRequests.size)
+ val request = setup.capturedRequests[0]
+ assertEquals("GET", request.method)
+ assertEquals("/channels/" + encodeUriComponent(channelName) + "/object", request.path)
+ assertFalse("path" in request.queryParams)
+ assertFalse("compact" in request.queryParams)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2c/get-objectid-path-segment-0
+ */
+ @Test
+ fun `RSO2c - get with objectId appends percent-encoded path segment`() = runTest {
+ val channelName = "test-RSO2c-objectid-" + randomId()
+ val testCases = listOf(
+ "root",
+ "map:QWsI5HQnz7AbhLgn9U2xlA2wq0Ax7HHYbJtJDF9mVqg@1700000000000",
+ "counter:a/b c?d#e@1700000000000",
+ )
+
+ for (objectId in testCases) {
+ val setup = setup(channelName, emptyMap())
+
+ setup.channel.`object`.getCompact(HttpObjectGetParams.ofObjectId(objectId))
+
+ assertEquals(1, setup.capturedRequests.size)
+ val request = setup.capturedRequests[0]
+ assertEquals("GET", request.method)
+ assertEquals(
+ "/channels/" + encodeUriComponent(channelName) + "/object/" + encodeUriComponent(objectId),
+ request.path,
+ )
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO2d/path-compact-query-params-0
+ */
+ @Test
+ fun `RSO2d - path and compact sent as query parameters`() = runTest {
+ val channelName = "test-RSO2d-" + randomId()
+ // DEVIATION (adapted, see deviations.md "RSO2a1c"): there is no `compact` param; `compact: false`
+ // is `get`, and `compact: true` is `getCompact`, which leaves it unset (the server default).
+ data class Case(val call: (HttpObject) -> Unit, val expectedPathSuffix: String, val expectedQuery: Map)
+ val testCases = listOf(
+ Case({ it.getCompact(HttpObjectGetParams.ofPath("a.b.c")) }, "/object", mapOf("path" to "a.b.c")),
+ Case({ it.get() }, "/object", mapOf("compact" to "false")),
+ Case({ it.getCompact() }, "/object", emptyMap()),
+ Case(
+ { it.get(HttpObjectGetParams.of("map:abc@1", "x.y")) },
+ "/object/" + encodeUriComponent("map:abc@1"),
+ mapOf("path" to "x.y", "compact" to "false"),
+ ),
+ )
+
+ for (testCase in testCases) {
+ val setup = setup(channelName, emptyMap())
+
+ testCase.call(setup.channel.`object`)
+
+ val request = setup.capturedRequests[0]
+ assertEquals("/channels/" + encodeUriComponent(channelName) + testCase.expectedPathSuffix, request.path)
+ assertEquals(testCase.expectedQuery, request.queryParams)
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO6/get-path-verbatim-0
+ */
+ @Test
+ fun `RSO6 - get passes path to the server verbatim`() = runTest {
+ val channelName = "test-RSO6-get-" + randomId()
+ val testCases = listOf(
+ "items.*.count",
+ "a\\.b.c",
+ ".a..b.",
+ "",
+ "with space/and?query&chars=1#frag",
+ "ünïcödé.键",
+ )
+
+ for (path in testCases) {
+ val setup = setup(channelName, emptyMap())
+
+ setup.channel.`object`.getCompact(HttpObjectGetParams.ofPath(path))
+
+ assertEquals(1, setup.capturedRequests.size)
+ // queryParams holds URL-decoded values: the decoded value must equal the input exactly
+ assertEquals(path, setup.capturedRequests[0].queryParams["path"])
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO2f/compact-returned-verbatim-0
+ */
+ @Test
+ fun `RSO2f, RSO8 - compact response returned verbatim`() = runTest {
+ val compactBody = mapOf(
+ "name" to "Alice",
+ "age" to 30L,
+ "active" to true,
+ "visits" to 7L,
+ "avatar" to "AQID",
+ "prefs" to "{\"theme\":\"dark\"}",
+ "tags" to "[\"a\",\"b\"]",
+ "nested" to mapOf("inner" to mapOf("deep" to 1L)),
+ "empty" to emptyMap(),
+ "cycle" to mapOf("objectId" to "map:cycleHash@1700000000000"),
+ )
+ val setup = setup("test-RSO2f-" + randomId(), compactBody)
+
+ // DEVIATION (adapted, see deviations.md "RSO2a1c"): `getCompact()` is the compact format, so the
+ // spec's explicit `compact: true` call is the same call. The result is a JsonElement.
+ val result = assertIs(setup.channel.`object`.getCompact())
+
+ assertEquals(compactBody, fromJson(result))
+
+ // String values are not Base64- or JSON-decoded
+ assertEquals(JsonPrimitive("AQID"), result["avatar"])
+ assertEquals(JsonPrimitive("{\"theme\":\"dark\"}"), result["prefs"])
+ }
+
+ /**
+ * @UTS objects/unit/RSO2f/compact-scalar-verbatim-1
+ */
+ @Test
+ fun `RSO2f - compact scalar response returned verbatim`() = runTest {
+ val testCases = listOf(42L, -1.5, "hello", false)
+
+ for (responseBody in testCases) {
+ val setup = setup("test-RSO2f-scalar-" + randomId(), responseBody)
+
+ val result = assertIs(setup.channel.`object`.getCompact(HttpObjectGetParams.ofPath("some.key")))
+
+ assertEquals(responseBody, fromJson(result))
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO2e/compact-msgpack-decoded-0
+ */
+ @Test
+ fun `RSO2e, RSO2f - compact msgpack response decoded per RSC8 and returned verbatim`() = runTest {
+ val compactBody = mapOf(
+ "name" to "Alice",
+ "count" to 5L,
+ "avatar" to byteArrayOf(0x01, 0x02, 0x03),
+ )
+ val setup = setup("test-RSO2e-msgpack-" + randomId(), compactBody, useBinaryProtocol = true)
+
+ val result = assertIs(setup.channel.`object`.getCompact())
+
+ // DEVIATION (adapted, see deviations.md "RSO2e"): spec expects Accept == "application/x-msgpack";
+ // every binary-protocol request in ably-java sends msgpack first with a JSON fallback.
+ assertEquals("application/x-msgpack,application/json", setup.capturedRequests[0].header("Accept"))
+
+ assertEquals("Alice", result["name"].asString)
+ assertEquals(5L, result["count"].asLong)
+ // DEVIATION (adapted, see deviations.md "RSO2a1c"): the compact result is JSON, so binary
+ // values arrive as Base64 strings, as over the JSON protocol, rather than as raw bytes.
+ assertEquals("AQID", result["avatar"].asString)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g1/full-map-recursive-decode-0
+ */
+ @Test
+ fun `RSO2g1, RSO2g2, RSO2g3, RSO9a, RSO14, RSO16 - full map response decoded recursively`() = runTest {
+ val fullBody = mapOf(
+ "objectId" to "root",
+ "map" to mapOf(
+ "semantics" to "LWW",
+ "entries" to mapOf(
+ "str" to mapOf("data" to mapOf("string" to "hello")),
+ "num" to mapOf("data" to mapOf("number" to 42L)),
+ "bool" to mapOf("data" to mapOf("boolean" to true)),
+ "bin" to mapOf("data" to mapOf("bytes" to Base64.getEncoder().encodeToString(byteArrayOf(0x01, 0x02, 0x03)))),
+ "jobj" to mapOf("data" to mapOf("json" to "{\"a\":1}")),
+ "jarr" to mapOf("data" to mapOf("json" to "[1,2,3]")),
+ "visits" to mapOf(
+ "data" to mapOf(
+ "objectId" to "counter:visitsHash@1700000000000",
+ "counter" to mapOf("data" to mapOf("number" to 7L)),
+ ),
+ ),
+ "profile" to mapOf(
+ "data" to mapOf(
+ "objectId" to "map:profileHash@1700000000000",
+ "map" to mapOf(
+ "semantics" to "LWW",
+ "entries" to mapOf("nickname" to mapOf("data" to mapOf("string" to "ally"))),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+ val setup = setup("test-RSO2g1-" + randomId(), fullBody)
+
+ val result = setup.channel.`object`.get()
+
+ assertIs(result)
+ assertEquals("root", result.objectId)
+ assertEquals(ObjectsMapSemantics.LWW, result.semantics)
+ assertEquals(setOf("str", "num", "bool", "bin", "jobj", "jarr", "visits", "profile"), result.entries.keys)
+
+ // Leaves (RSO2g3, RSO9c)
+ fun leaf(key: String) = assertIs(result.entries.getValue(key).data)
+ assertEquals("hello", leaf("str").string)
+ assertEquals(42.0, leaf("num").number)
+ assertEquals(true, leaf("bool").boolean)
+ assertIs(leaf("bin").bytes)
+ assertContentEquals(byteArrayOf(0x01, 0x02, 0x03), leaf("bin").bytes)
+ assertEquals(mapOf("a" to 1L), fromJson(leaf("jobj").json!!))
+ assertEquals(listOf(1L, 2L, 3L), fromJson(leaf("jarr").json!!))
+ assertNull(leaf("jobj").string)
+
+ // Nested counter (RSO2g2, RSO15)
+ val visits = result.entries.getValue("visits").data
+ assertIs(visits)
+ assertEquals("counter:visitsHash@1700000000000", visits.objectId)
+ assertEquals(7.0, visits.count)
+
+ // Nested map, decoded recursively (RSO2g1, RSO16)
+ val profile = result.entries.getValue("profile").data
+ assertIs(profile)
+ assertEquals("map:profileHash@1700000000000", profile.objectId)
+ assertEquals(ObjectsMapSemantics.LWW, profile.semantics)
+ assertEquals("ally", assertIs(profile.entries.getValue("nickname").data).string)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g1/full-empty-values-decode-1
+ */
+ @Test
+ fun `RSO2g1, RSO2g3, RSO9c - empty map and empty leaf values in a full response`() = runTest {
+ val fullBody = mapOf(
+ "objectId" to "root",
+ "map" to mapOf(
+ "semantics" to "LWW",
+ "entries" to mapOf(
+ "emptyMap" to mapOf(
+ "data" to mapOf(
+ "objectId" to "map:emptyHash@1700000000000",
+ "map" to mapOf("semantics" to "LWW", "entries" to emptyMap()),
+ ),
+ ),
+ "emptyStr" to mapOf("data" to mapOf("string" to "")),
+ "emptyBin" to mapOf("data" to mapOf("bytes" to "")),
+ "zero" to mapOf("data" to mapOf("number" to 0L)),
+ "no" to mapOf("data" to mapOf("boolean" to false)),
+ ),
+ ),
+ )
+ val setup = setup("test-RSO2g1-empty-" + randomId(), fullBody)
+
+ val result = setup.channel.`object`.get()
+
+ assertIs(result)
+ assertEquals(setOf("emptyMap", "emptyStr", "emptyBin", "zero", "no"), result.entries.keys)
+
+ val emptyMap = result.entries.getValue("emptyMap").data
+ assertIs(emptyMap)
+ assertEquals("map:emptyHash@1700000000000", emptyMap.objectId)
+ assertEquals(ObjectsMapSemantics.LWW, emptyMap.semantics)
+ assertEquals(emptyMap(), emptyMap.entries)
+
+ fun leaf(key: String) = assertIs(result.entries.getValue(key).data)
+ assertEquals("", leaf("emptyStr").string)
+ assertIs(leaf("emptyBin").bytes)
+ assertContentEquals(byteArrayOf(), leaf("emptyBin").bytes)
+ assertEquals(0.0, leaf("zero").number)
+ assertEquals(false, leaf("no").boolean)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g2/full-counter-0
+ */
+ @Test
+ fun `RSO2g2, RSO9b, RSO15 - full counter response`() = runTest {
+ val fullBody = mapOf(
+ "objectId" to "counter:abcHash@1700000000000",
+ "counter" to mapOf("data" to mapOf("number" to -3.5)),
+ )
+ val setup = setup("test-RSO2g2-" + randomId(), fullBody)
+
+ val result = setup.channel.`object`.get(
+ HttpObjectGetParams.ofObjectId("counter:abcHash@1700000000000"),
+ )
+
+ assertIs(result)
+ assertEquals("counter:abcHash@1700000000000", result.objectId)
+ assertEquals(-3.5, result.count)
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g3/full-leaf-decode-json-0
+ */
+ @Test
+ fun `RSO2g3, RSO9c - full leaf response decoded per OD5 (JSON protocol)`() = runTest {
+ data class Case(val responseBody: Map, val expectedField: String, val expectedValue: Any?)
+ val testCases = listOf(
+ Case(mapOf("string" to "hello"), "string", "hello"),
+ Case(mapOf("number" to 3.25), "number", 3.25),
+ Case(mapOf("boolean" to false), "boolean", false),
+ Case(mapOf("bytes" to "AQID"), "bytes", byteArrayOf(0x01, 0x02, 0x03)),
+ Case(mapOf("json" to "{\"k\":\"v\"}"), "json", mapOf("k" to "v")),
+ Case(mapOf("json" to "[1,\"a\",null]"), "json", listOf(1L, "a", null)),
+ )
+
+ for (testCase in testCases) {
+ val setup = setup("test-RSO2g3-" + randomId(), testCase.responseBody)
+
+ val result = setup.channel.`object`.get(HttpObjectGetParams.ofPath("leaf"))
+
+ assertIs(result)
+ assertLeafField(result, testCase.expectedField, testCase.expectedValue)
+ assertNull(result.objectId)
+ // `encoding == null`: HttpObjectData has no encoding accessor (RSO9c1 holds by construction).
+ if (testCase.expectedField == "bytes") assertIs(result.bytes)
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g3/full-leaf-decode-msgpack-1
+ */
+ @Test
+ fun `RSO2g3, RSO9c2 - full leaf response decoded per OD5 (MessagePack protocol)`() = runTest {
+ data class Case(val responseBody: Map, val expectedField: String, val expectedValue: Any?)
+ val testCases = listOf(
+ Case(mapOf("bytes" to byteArrayOf(0xFF.toByte(), 0x00)), "bytes", byteArrayOf(0xFF.toByte(), 0x00)),
+ Case(mapOf("json" to "{\"k\":[1,2]}"), "json", mapOf("k" to listOf(1L, 2L))),
+ Case(mapOf("string" to "plain"), "string", "plain"),
+ )
+
+ for (testCase in testCases) {
+ val setup = setup("test-RSO2g3-msgpack-" + randomId(), testCase.responseBody, useBinaryProtocol = true)
+
+ val result = setup.channel.`object`.get(HttpObjectGetParams.ofPath("leaf"))
+
+ assertIs(result)
+ assertLeafField(result, testCase.expectedField, testCase.expectedValue)
+ // `encoding == null`: HttpObjectData has no encoding accessor (RSO9c1 holds by construction).
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RSO2g4/unrecognized-object-passthrough-0
+ */
+ @Test
+ fun `RSO2g4, RSO9d - unrecognized object shape passed through`() = runTest {
+ val fullBody = mapOf(
+ "objectId" to "root",
+ "map" to mapOf(
+ "semantics" to "LWW",
+ "entries" to mapOf(
+ "known" to mapOf("data" to mapOf("string" to "ok")),
+ "future" to mapOf(
+ "data" to mapOf(
+ "objectId" to "future:xyzHash@1700000000000",
+ "futureType" to mapOf("someField" to 1L),
+ ),
+ ),
+ ),
+ ),
+ )
+ val topLevelBody = mapOf(
+ "objectId" to "future:topHash@1700000000000",
+ "futureType" to mapOf("someField" to 2L),
+ )
+
+ val setup = setup("test-RSO2g4-entry-" + randomId(), fullBody)
+ val mapResult = setup.channel.`object`.get()
+
+ val setup2 = setup("test-RSO2g4-top-" + randomId(), topLevelBody)
+ val topResult = setup2.channel.`object`.get()
+
+ assertIs(mapResult)
+ assertEquals("ok", assertIs(mapResult.entries.getValue("known").data).string)
+
+ val futureEntry = assertIs(mapResult.entries.getValue("future").data)
+ assertEquals("future:xyzHash@1700000000000", futureEntry.objectId)
+ assertEquals(mapOf("someField" to 1L), fromJson(futureEntry.unrecognizedFields["futureType"]))
+
+ assertIs(topResult)
+ assertEquals("future:topHash@1700000000000", topResult.objectId)
+ assertEquals(mapOf("someField" to 2L), fromJson(topResult.unrecognizedFields["futureType"]))
+ }
+
+ /**
+ * @UTS objects/unit/RSO2h/no-validation-server-error-0
+ */
+ @Test
+ fun `RSO2h - get performs no client-side validation and surfaces server errors`() = runTest {
+ val channelName = "test-RSO2h-" + randomId()
+ val setup = setupRestObject(channelName) { req ->
+ req.respondWith(
+ 404,
+ mapOf("error" to mapOf("code" to 40400, "statusCode" to 404, "message" to "Object not found")),
+ )
+ }
+
+ val error = assertFailsWith {
+ setup.channel.`object`.get(
+ HttpObjectGetParams.of("not-a-valid-object-id", "..*.."),
+ )
+ }
+
+ assertEquals(1, setup.capturedRequests.size)
+ assertEquals(
+ "/channels/" + encodeUriComponent(channelName) + "/object/" + encodeUriComponent("not-a-valid-object-id"),
+ setup.capturedRequests[0].path,
+ )
+ assertEquals("..*..", setup.capturedRequests[0].queryParams["path"])
+
+ assertEquals(40400, error.errorInfo.code)
+ assertEquals(404, error.errorInfo.statusCode)
+ }
+
+ /** `result[expected_field] == expected_value` over the typed leaf getters. */
+ private fun assertLeafField(result: HttpObjectData, field: String, expected: Any?) {
+ when (field) {
+ "string" -> assertEquals(expected, result.string)
+ "number" -> assertEquals(expected, result.number)
+ "boolean" -> assertEquals(expected, result.boolean)
+ "bytes" -> assertContentEquals(expected as ByteArray, result.bytes)
+ "json" -> assertEquals(expected, fromJson(result.json!!))
+ else -> error("Unknown leaf field $field")
+ }
+ }
+}
diff --git a/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectPublishTest.kt b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectPublishTest.kt
new file mode 100644
index 000000000..a4b8e2b20
--- /dev/null
+++ b/lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectPublishTest.kt
@@ -0,0 +1,447 @@
+package io.ably.pubsub.uts.unit.objects
+
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.ably.pubsub.liveobjects.http.HttpObjectOperation
+import io.ably.pubsub.liveobjects.http.PublishObjectData
+import io.ably.pubsub.liveobjects.message.ObjectsMapSemantics
+import io.ably.pubsub.types.AblyException
+import io.ably.pubsub.uts.infra.unit.ClientOptionsBuilder
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+
+/**
+ * `RestObject#publish` — ably-java's `HttpObject.publish`. Spec `RestObjectOperation(field: Body(...))`
+ * constructions become the `HttpObjectOperation` factories plus `withObjectId` / `withPath` /
+ * `withId`; spec `"LWW"` is `ObjectsMapSemantics.LWW`; `RestObjectMapEntry(data: x)` is the entry
+ * value `x` itself (`Map`); `PublishObjectData(boolean: b)` is
+ * `PublishObjectData.bool(b)`.
+ *
+ * Source spec: `objects/unit/rest_object_publish.md`. Spec points: RSO3, RSO3a1-RSO3h, RSO5,
+ * RSO5a-RSO5i, RSO6, RSO7, RSO10, RSO13.
+ */
+class RestObjectPublishTest {
+
+ private val defaultPublishResponse = mapOf(
+ "messageId" to "msg-id-1",
+ "channel" to "ignored-by-tests",
+ "objectIds" to emptyList(),
+ )
+
+ /** The spec's `setup_rest_object(channel_name, use_binary_protocol, client_options)`. */
+ private fun setup(
+ channelName: String,
+ useBinaryProtocol: Boolean = false,
+ clientOptions: ClientOptionsBuilder.() -> Unit = {},
+ ) = setupRestObject(channelName, useBinaryProtocol, clientOptions) { req ->
+ req.respondWithBody(201, defaultPublishResponse, useBinaryProtocol)
+ }
+
+ /** `parse_json(request.body)` of the first captured request, as a list of wire operations. */
+ private fun RestObjectSetup.jsonBody(): List> =
+ assertIs>(parseJson(capturedRequests[0].body)).map { assertIs>(it) }
+
+ private fun Map<*, *>.obj(key: String): Map<*, *> = assertIs>(this[key])
+
+ /**
+ * @UTS objects/unit/RSO3e/single-op-post-0
+ */
+ @Test
+ fun `RSO3e - single operation sent as POST with a one-element JSON array body`() = runTest {
+ val channelName = "test-RSO3e-single-" + randomId()
+ val setup = setup(channelName)
+
+ setup.channel.`object`.publish(
+ HttpObjectOperation.mapSet("greeting", PublishObjectData.string("hello")).withObjectId("root"),
+ )
+
+ assertEquals(1, setup.capturedRequests.size)
+ val request = setup.capturedRequests[0]
+ assertEquals("POST", request.method)
+ assertEquals("/channels/" + encodeUriComponent(channelName) + "/object", request.path)
+ assertEquals("application/json", request.header("Content-Type"))
+
+ val body = parseJson(request.body)
+ assertIs>(body)
+ assertEquals(1, body.size)
+ val op = assertIs>(body[0])
+ assertEquals("root", op["objectId"])
+ assertEquals(mapOf("key" to "greeting", "value" to mapOf("string" to "hello")), op["mapSet"])
+ }
+
+ /**
+ * @UTS objects/unit/RSO3e/msgpack-body-1
+ */
+ @Test
+ fun `RSO3e - request body encoded as MessagePack with the binary protocol`() = runTest {
+ val channelName = "test-RSO3e-msgpack-" + randomId()
+ val setup = setup(channelName, useBinaryProtocol = true)
+
+ setup.channel.`object`.publish(HttpObjectOperation.counterInc(1).withPath("visits"))
+
+ val request = setup.capturedRequests[0]
+ assertEquals("POST", request.method)
+ assertEquals("application/x-msgpack", request.header("Content-Type"))
+
+ val body = msgpackDecode(request.body)
+ assertIs>(body)
+ assertEquals(1, body.size)
+ val op = assertIs