diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md index 4826ce72b..73404566a 100644 --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md @@ -31,6 +31,7 @@ doubt, that IDL is the source of truth; this doc is the applied version of it fo 15. [Worked example](#15-worked-example) 16. [Quick symbol index](#16-symbol-index) 17. [Internal-graph symbol map (unit specs → `:liveobjects`'s own tests)](#17-internal-mappings) +18. [REST entry point: `RestObject` → `HttpObject`](#18-http-object) --- @@ -896,3 +897,60 @@ tiers, and none of the 5 internal unit specs use it.) Keep the `uts` discipline in the migrated tests: one `@Test` per spec case, `/** @UTS objects/unit/… */` KDoc tags, and a module-local `deviations.md` recording every §17.9 deviation used. + +--- + +## 18. REST entry point: `RestObject` → `HttpObject` + +The `rest_object_*.md` unit specs and `rest_object_test.md` integration spec target the stateless REST +API (RSL16, RSO*). ably-java follows ably-js's Rest → Http rename: spec `RestObject*` / `RestLive*` types +are `HttpObject*` / `HttpLive*`. The entry point is in `io.ably.pubsub.liveobjects`; every other type is +in `io.ably.pubsub.liveobjects.http`. + +**These specs live in `:core`, not `:liveobjects`.** `HttpObject` is part of the core HTTP client, so +override the resolver's objects `targetDir` for the `rest_object_*` specs: + +| Tier | Directory | Package | +|---|---|---| +| unit | `lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects` | `io.ably.pubsub.uts.unit.objects` | +| integration | `lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects` | `io.ably.pubsub.uts.integration.standard.objects` | + +Run them with `./gradlew :core:runUtsUnitTests` / `:core:runUtsIntegrationTests`, and record their +deviations in `lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md`. The one exception is +`objects/integration/RSL16/realtime-write-rest-read-0`: it writes through the realtime LiveObjects +plugin, so it stays in `:liveobjects` as `uts/integration/RestObjectRealtimeInteropTest.kt`. + +Unit-tier helpers are in `lib/.../uts/unit/objects/HttpObjectHelpers.kt` (`setupRestObject`, +`respondWithBody`, `parseJson`, `msgpackEncode`/`msgpackDecode`, `encodeUriComponent`, and +`PendingRequest.path` / `.queryParams` / `.header`). + +| Spec | ably-java (Kotlin) | +|---|---| +| `Rest(options: …, plugins: { Objects })` | `TestHttpClient { … }` — `HttpObject` is built into the HTTP client (`lib`), no plugin | +| `channel.object` (REST) | `` channel.`object` `` — field of type `HttpObject`, always available (RSL16b not applicable) | +| `AWAIT channel.object.get(RestObjectGetParams(objectId:, path:))` (compact unset or `true`) | `` channel.`object`.getCompact(HttpObjectGetParams.of(objectId, path)) `` (or `ofObjectId(…)` / `ofPath(…)` when only one is set) → `JsonElement?` (blocking); `compact` is never sent | +| `AWAIT channel.object.get(RestObjectGetParams(…, compact: false))` | `` channel.`object`.get(HttpObjectGetParams.of(…)) `` → `HttpObjectGetFullResult?` (blocking); sends `compact=false` | +| compact result | Gson `JsonElement` (`JsonObject` for a map); binary values are Base64 strings for both protocols. Compare with spec literals via `fromJson(…)` from `HttpObjectHelpers.kt` (deviation RSO2a1c) | +| narrowing a full result (`x.map…`, `x.counter…`, leaf fields) | `x.type` (`ValueType`) plus fail-fast casts like realtime `Instance`: `asLiveMap()`, `asLiveCounter()`, `asLiveObject()`, `asObjectData()`, and leaf values `asString()`, `asNumber()`, `asBoolean()`, `asBinary()`, `asJsonObject()`, `asJsonArray()`; prefer them over `assertIs` chains | +| full result `RestLiveMap` / `RestLiveCounter` | `HttpLiveMap` (`objectId`, `semantics`, `entries`) / `HttpLiveCounter` (`objectId`, `count`) | +| `x.map.semantics` / `x.map.entries[k].data` | `x.semantics` / `x.entries.getValue(k).data` | +| `x.counter.data.number` | `x.count` (`Double`) | +| full leaf `ObjectData` | `HttpObjectData` (extends `ObjectData`): `string`, `number` (`Double`), `boolean`, `bytes`, `json` (`JsonElement`); no `encoding` accessor | +| unrecognised object `{ objectId, futureType }` | plain `HttpLiveObject`: `objectId` + `unrecognizedFields["futureType"]` (`JsonObject`; compare via `fromJson`) | +| `RestObjectOperation(objectId: o, mapSet: RestObjectMapSet(key: k, value: v))` | `HttpObjectOperation.mapSet(k, v).withObjectId(o)` (also `withPath`, `withId`) | +| `mapCreate: RestObjectMapCreate(semantics: "LWW", entries: { k: RestObjectMapEntry(data: v) })` | `HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, mapOf(k to v))` — entries map straight to `PublishObjectData` | +| `mapRemove` / `counterCreate` / `counterInc` | `HttpObjectOperation.mapRemove(k)` / `.counterCreate(n)` / `.counterInc(n)` | +| `*CreateWithObjectId(initialValue:, nonce:)` + `objectId:` | `HttpObjectOperation.mapCreateWithObjectId(objectId, initialValue, nonce)` (or pass the `HttpObjectGenerateIdResult`) | +| `PublishObjectData(string:/number:/boolean:/bytes:/json:/objectId:)` | `PublishObjectData.string/number/bool/bytes/json/objectId(…)` | +| `semantics: "not-a-semantics"` | `ObjectsMapSemantics.UNKNOWN` | +| `AWAIT channel.object.publish(op \| [ops])` | `` channel.`object`.publish(op) `` / `.publish(listOf(…))` → `HttpObjectPublishResult` (blocking) | +| `AWAIT channel.object.generateObjectId(RestObjectGenerateIdBody(counterCreate: RestObjectCounterCreate(count: n)))` | `` channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(n)) `` (blocking) | + +**Async: not §3.** Unlike `RealtimeObject`, `HttpObject` follows the HTTP channel convention, not +`CompletableFuture`: each method is blocking (returns the result, throws `AblyException`) and has an +`…Async(…, Callback)` twin. Translate spec `AWAIT` to the blocking call (no `.await()`), and +`AWAIT x FAILS WITH error` to `assertFailsWith { x }`. + +Operations and generate-ID bodies with zero or several bodies are unrepresentable (T-4, in +`deviations.md`). JSON leaves go on the wire in the OD2g `json` field (OD4c5/OD4d5), as the spec says. + diff --git a/lib/src/main/java/io/ably/pubsub/http/Auth.java b/lib/src/main/java/io/ably/pubsub/http/Auth.java index b20bb4d71..bcbc3020f 100644 --- a/lib/src/main/java/io/ably/pubsub/http/Auth.java +++ b/lib/src/main/java/io/ably/pubsub/http/Auth.java @@ -876,21 +876,12 @@ else if(!request.keyName.equals(keyName)) /* timestamp */ if(request.timestamp == 0) { if(options.queryTime) { - long oldNanoTimeDelta = nanoTimeDelta; - long currentNanoTimeDelta = clock.currentTimeMillis() - clock.nanoTime()/(1000*1000); - - if (timeDelta != Long.MAX_VALUE) { - /* system time changed by more than 500ms since last time? */ - if(Math.abs(oldNanoTimeDelta - currentNanoTimeDelta) > 500) - timeDelta = Long.MAX_VALUE; - } - - if (timeDelta != Long.MAX_VALUE) { - request.timestamp = timestamp() + timeDelta; - nanoTimeDelta = currentNanoTimeDelta; + Long known = serverTimestampIfKnown(); + if (known != null) { + request.timestamp = known; } else { request.timestamp = ably.time(); - timeDelta = request.timestamp - timestamp(); + setServerTime(request.timestamp); } } else { @@ -1256,6 +1247,35 @@ public long serverTimestamp() { return delta != Long.MAX_VALUE ? clientTime + timeDelta : clientTime; } + /** + * [Internal] The current server time derived from the stored offset, or {@code null} if the + * offset is unknown or the device clock has jumped by more than 500 ms since it was last used + * (in which case the offset is discarded). Shared by {@code queryTime} token requests and + * {@code generateObjectId}, so a client fetches {@code /time} once for both. + */ + public synchronized Long serverTimestampIfKnown() { + if (timeDelta == Long.MAX_VALUE) return null; + long now = timestamp(); + long currentNanoTimeDelta = now - clock.nanoTime()/(1000*1000); + /* system time changed by more than 500ms since last time? */ + if (Math.abs(nanoTimeDelta - currentNanoTimeDelta) > 500) { + timeDelta = Long.MAX_VALUE; + return null; + } + nanoTimeDelta = currentNanoTimeDelta; + return now + timeDelta; + } + + /** + * [Internal] Stores the offset to the local clock from a server time just fetched from + * {@code /time}. + */ + public synchronized void setServerTime(long serverTime) { + long now = timestamp(); + timeDelta = serverTime - now; + nanoTimeDelta = now - clock.nanoTime()/(1000*1000); + } + private static final String TAG = Auth.class.getName(); private final PubSubBase ably; private final Clock clock; @@ -1270,7 +1290,7 @@ public long serverTimestamp() { /** * Time delta is server time minus client time, in milliseconds, MAX_VALUE if not obtained yet */ - private long timeDelta = Long.MAX_VALUE; + private volatile long timeDelta = Long.MAX_VALUE; /** * Time delta between System.nanoTime() and System.currentTimeMillis. If it changes significantly it * suggests device time/date has changed diff --git a/lib/src/main/java/io/ably/pubsub/http/ChannelBase.java b/lib/src/main/java/io/ably/pubsub/http/ChannelBase.java index 8440b7530..16ab1565a 100644 --- a/lib/src/main/java/io/ably/pubsub/http/ChannelBase.java +++ b/lib/src/main/java/io/ably/pubsub/http/ChannelBase.java @@ -1,6 +1,8 @@ package io.ably.pubsub.http; import io.ably.pubsub.http.BasePaginatedQuery; +import io.ably.pubsub.liveobjects.HttpObject; +import io.ably.pubsub.liveobjects.http.DefaultHttpObject; import io.ably.pubsub.http.HttpRequester; import io.ably.pubsub.http.HttpScheduler; import io.ably.pubsub.http.HttpCore; @@ -48,6 +50,14 @@ public class ChannelBase { */ public final HttpAnnotations annotations; + /** + * The LiveObjects entry point for this channel. It is built into the HTTP client and needs no + * plugin. + *

+ * Spec: RSL16 + */ + public final HttpObject object; + private final MessageEditsMixin messageEditsMixin; @@ -607,6 +617,7 @@ public void getMessageVersionsAsync(String serial, Param[] params, Callback 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>(body[0]) + assertEquals("visits", op["path"]) + assertEquals(mapOf("number" to 1L), op["counterInc"]) + } + + /** + * @UTS objects/unit/RSO3c/batch-single-request-0 + */ + @Test + fun `RSO3c - array of operations sent in a single request, in order`() = runTest { + val setup = setup("test-RSO3c-" + randomId()) + + setup.channel.`object`.publish( + listOf( + HttpObjectOperation.mapSet("x", PublishObjectData.number(1)).withPath("a"), + HttpObjectOperation.mapRemove("old").withObjectId("root"), + HttpObjectOperation.counterInc(-2).withPath("score"), + ), + ) + + assertEquals(1, setup.capturedRequests.size) + + val body = setup.jsonBody() + assertEquals(3, body.size) + assertEquals("x", body[0].obj("mapSet")["key"]) + assertEquals(mapOf("key" to "old"), body[1]["mapRemove"]) + assertEquals(mapOf("number" to -2L), body[2]["counterInc"]) + } + + /** + * @UTS objects/unit/RSO3h/no-batch-size-limit-0 + */ + @Test + fun `RSO3h - no client-side limit on batch size`() = runTest { + val setup = setup("test-RSO3h-" + randomId()) + val ops = (0..999).map { i -> + HttpObjectOperation.mapSet("k$i", PublishObjectData.number(i)).withObjectId("root") + } + + setup.channel.`object`.publish(ops) + + assertEquals(1, setup.capturedRequests.size) + val body = setup.jsonBody() + assertEquals(1000, body.size) + assertEquals("k999", body[999].obj("mapSet")["key"]) + } + + /** + * @UTS objects/unit/RSO10/publish-result-0 + */ + @Test + fun `RSO3b, RSO10 - publish result decoded from the response`() = runTest { + val channelName = "test-RSO10-" + randomId() + val setup = setupRestObject(channelName) { req -> + req.respondWith( + 201, + mapOf( + "messageId" to "abc123:0", + "channel" to channelName, + "objectIds" to listOf("map:newHash@1700000000000", "root"), + ), + ) + } + + val result = setup.channel.`object`.publish( + HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("profile"), + ) + + assertEquals("abc123:0", result.messageId) + assertEquals(channelName, result.channel) + assertEquals(listOf("map:newHash@1700000000000", "root"), result.objectIds) + } + + /** + * @UTS objects/unit/RSO3d4/op-wire-shape-no-action-0 + */ + @Test + fun `RSO3d4, RSO5c-RSO5i - wire shape of each operation type, without an action field`() = runTest { + val channelName = "test-RSO3d4-" + randomId() + val testCases = listOf( + HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap()).withPath("m") to + mapOf("path" to "m", "mapCreate" to mapOf("semantics" to 0L, "entries" to emptyMap())), + HttpObjectOperation.mapCreateWithObjectId("map:h@1", "{\"semantics\":0,\"entries\":{}}", "nonce-0123456789ab") to + mapOf( + "objectId" to "map:h@1", + "mapCreateWithObjectId" to mapOf( + "initialValue" to "{\"semantics\":0,\"entries\":{}}", + "nonce" to "nonce-0123456789ab", + ), + ), + HttpObjectOperation.mapSet("k", PublishObjectData.bool(true)).withObjectId("root") to + mapOf("objectId" to "root", "mapSet" to mapOf("key" to "k", "value" to mapOf("boolean" to true))), + HttpObjectOperation.mapRemove("k").withPath("a.b") to + mapOf("path" to "a.b", "mapRemove" to mapOf("key" to "k")), + HttpObjectOperation.counterCreate(10) to + mapOf("counterCreate" to mapOf("count" to 10L)), + HttpObjectOperation.counterCreateWithObjectId("counter:h@1", "{\"count\":10}", "nonce-0123456789ab") to + mapOf( + "objectId" to "counter:h@1", + "counterCreateWithObjectId" to mapOf("initialValue" to "{\"count\":10}", "nonce" to "nonce-0123456789ab"), + ), + HttpObjectOperation.counterInc(-5).withObjectId("counter:h@1") to + mapOf("objectId" to "counter:h@1", "counterInc" to mapOf("number" to -5L)), + ) + + for ((operation, expectedWireObject) in testCases) { + val setup = setup(channelName) + + setup.channel.`object`.publish(operation) + + val wireOp = setup.jsonBody()[0] + assertEquals(expectedWireObject, wireOp) + assertFalse("action" in wireOp) + } + } + + /** + * @UTS objects/unit/RSO3d3/targeting-fields-verbatim-0 + */ + @Test + fun `RSO3d3 - id, objectId and path preserved verbatim`() = runTest { + val setup = setup("test-RSO3d3-" + randomId()) + + setup.channel.`object`.publish( + listOf( + HttpObjectOperation.mapRemove("k").withId("my-op-id:0").withObjectId("map:Ab-_9@1700000000000"), + HttpObjectOperation.counterInc(1).withId(" spaced id ").withPath("a\\.b.*.c"), + ), + ) + + val body = setup.jsonBody() + + assertEquals("my-op-id:0", body[0]["id"]) + assertEquals("map:Ab-_9@1700000000000", body[0]["objectId"]) + assertFalse("path" in body[0]) + + assertEquals(" spaced id ", body[1]["id"]) + assertEquals("a\\.b.*.c", body[1]["path"]) + assertFalse("objectId" in body[1]) + } + + /** + * @UTS objects/unit/RSO6/publish-path-verbatim-0 + */ + @Test + fun `RSO6 - publish passes path to the server verbatim`() = runTest { + val channelName = "test-RSO6-publish-" + randomId() + val testCases = listOf("items.*", "a\\.b.c", ".a..b.", "", "ünïcödé.键") + + for (path in testCases) { + val setup = setup(channelName) + + setup.channel.`object`.publish( + HttpObjectOperation.mapSet("k", PublishObjectData.string("v")).withPath(path), + ) + + assertEquals(1, setup.capturedRequests.size) + assertEquals(path, setup.jsonBody()[0]["path"]) + } + } + + /** + * @UTS objects/unit/RSO3d1/mapcreate-semantics-encoded-0 + */ + @Test + fun `RSO3d1 - mapCreate semantics encoded per OMP2`() = runTest { + val setup = setup("test-RSO3d1-" + randomId()) + + setup.channel.`object`.publish(HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, emptyMap())) + + assertEquals(0L, setup.jsonBody()[0].obj("mapCreate")["semantics"]) + } + + /** + * @UTS objects/unit/RSO3d1/unrecognized-semantics-error-1 + */ + @Test + fun `RSO3d1 - unrecognized mapCreate semantics rejected`() = runTest { + val setup = setup("test-RSO3d1-invalid-" + randomId()) + + // The closed enum's only unrecognized value is UNKNOWN (spec "not-a-semantics"). + val error = assertFailsWith { + setup.channel.`object`.publish( + HttpObjectOperation.mapCreate(ObjectsMapSemantics.UNKNOWN, emptyMap()), + ) + } + + assertEquals(40003, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + assertEquals(0, setup.capturedRequests.size) + } + + /** + * @UTS objects/unit/RSO3d2/mapset-value-encoded-json-0 + */ + @Test + fun `RSO3d2, RSO7 - mapSet value encoded per OD4 (JSON protocol)`() = runTest { + val channelName = "test-RSO3d2-json-" + randomId() + val testCases = listOf( + PublishObjectData.string("hello") to mapOf("string" to "hello"), + PublishObjectData.number(1.5) to mapOf("number" to 1.5), + PublishObjectData.bool(false) to mapOf("boolean" to false), + PublishObjectData.bytes(byteArrayOf(0x01, 0x02, 0x03)) to mapOf("bytes" to "AQID"), + PublishObjectData.json(JsonParser.parseString("{\"a\":[1,2]}").asJsonObject) to null, + PublishObjectData.json(JsonParser.parseString("[1,\"x\"]").asJsonArray) to null, + PublishObjectData.objectId("counter:h@1") to mapOf("objectId" to "counter:h@1"), + ) + + for ((value, expectedWireValue) in testCases) { + val setup = setup(channelName) + + setup.channel.`object`.publish(HttpObjectOperation.mapSet("k", value).withObjectId("root")) + + val wireValue = setup.jsonBody()[0].obj("mapSet").obj("value") + val json = value.json + if (json != null) { + assertEquals(setOf("json"), wireValue.keys) + val string = assertIs(wireValue["json"]) + assertEquals(fromJson(json), parseJson(string)) + } else { + assertEquals(expectedWireValue, wireValue) + } + } + } + + /** + * @UTS objects/unit/RSO3d2/mapset-value-encoded-msgpack-1 + */ + @Test + fun `RSO3d2 - mapSet value encoded per OD4 (MessagePack protocol)`() = runTest { + val setup = setup("test-RSO3d2-msgpack-" + randomId(), useBinaryProtocol = true) + + setup.channel.`object`.publish( + listOf( + HttpObjectOperation.mapSet("bin", PublishObjectData.bytes(byteArrayOf(0x01, 0x02, 0x03))).withObjectId("root"), + HttpObjectOperation.mapSet("obj", PublishObjectData.json(JsonObject().apply { addProperty("a", 1) })).withObjectId("root"), + ), + ) + + val body = assertIs>(msgpackDecode(setup.capturedRequests[0].body)) + val value0 = assertIs>(assertIs>(assertIs>(body[0])["mapSet"])["value"]) + val value1 = assertIs>(assertIs>(assertIs>(body[1])["mapSet"])["value"]) + + assertIs(value0["bytes"]) + assertContentEquals(byteArrayOf(0x01, 0x02, 0x03), value0["bytes"] as ByteArray) + + assertEquals(setOf("json"), value1.keys) + assertEquals(mapOf("a" to 1L), parseJson(assertIs(value1["json"]))) + } + + /** + * @UTS objects/unit/RSO3d2/mapcreate-entries-encoded-2 + */ + @Test + fun `RSO3d2, RSO13 - mapCreate entries encoded per OD4`() = runTest { + val setup = setup("test-RSO3d2-entries-" + randomId()) + + setup.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(0x01, 0x02, 0x03)), + "prefs" to PublishObjectData.json(JsonObject().apply { addProperty("theme", "dark") }), + "visits" to PublishObjectData.objectId("counter:h@1"), + ), + ).withPath("profile"), + ) + + val entries = setup.jsonBody()[0].obj("mapCreate").obj("entries") + + 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 = entries.obj("prefs").obj("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/RSO3f/no-auto-generated-id-0 + */ + @Test + fun `RSO3f - no auto-generated operation id, even with idempotentRestPublishing`() = runTest { + val setup = setup("test-RSO3f-" + randomId(), clientOptions = { idempotentRestPublishing = true }) + + setup.channel.`object`.publish( + listOf( + HttpObjectOperation.counterInc(1).withPath("a"), + HttpObjectOperation.counterInc(1).withId("caller-id").withPath("b"), + ), + ) + + val body = setup.jsonBody() + assertFalse("id" in body[0]) + assertEquals("caller-id", body[1]["id"]) + } + + /** + * @UTS objects/unit/RSO5b1/no-body-field-error-0 + */ + @Test + fun `RSO5b1 - operation with no body field rejected before any request`() = runTest { + // DEVIATION T-4 (not applicable, per the spec's own note): an HttpObjectOperation can only be + // built through a factory that sets exactly one body, so `RestObjectOperation(objectId: "root")` + // with no body is unrepresentable and the runtime check can't be reached. See deviations.md. + val setup = setup("test-RSO5b1-none-" + randomId()) + assertEquals(0, setup.capturedRequests.size) + } + + /** + * @UTS objects/unit/RSO5b1/multiple-body-fields-error-1 + */ + @Test + fun `RSO5b1 - operation with multiple body fields rejected, no partial batch sent`() = runTest { + // DEVIATION T-4 (not applicable, per the spec's own note): no HttpObjectOperation factory + // or `with*` method sets a second body, so an operation with both mapSet and mapRemove is + // unrepresentable. See deviations.md. + val setup = setup("test-RSO5b1-multi-" + randomId()) + assertEquals(0, setup.capturedRequests.size) + } + + /** + * @UTS objects/unit/RSO3g/no-targeting-validation-0 + */ + @Test + fun `RSO3g - no client-side validation of targeting, server error surfaced`() = runTest { + val setup = setupRestObject("test-RSO3g-" + randomId()) { req -> + req.respondWith( + 400, + mapOf("error" to mapOf("code" to 40000, "statusCode" to 400, "message" to "Invalid operation targeting")), + ) + } + val testCases = listOf( + HttpObjectOperation.mapSet("k", PublishObjectData.string("v")).withObjectId("root").withPath("a"), + HttpObjectOperation.mapRemove("k"), + HttpObjectOperation.counterCreate(1).withObjectId("counter:h@1"), + ) + + for (operation in testCases) { + setup.capturedRequests.clear() + + val error = assertFailsWith { setup.channel.`object`.publish(operation) } + + assertEquals(1, setup.capturedRequests.size) + assertEquals(40000, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + } + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/deviations.md b/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/deviations.md index 65b823e77..ad7858c92 100644 --- a/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/deviations.md +++ b/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/deviations.md @@ -4,6 +4,8 @@ > objects **unit** (`io.ably.pubsub.liveobjects.uts.unit`), **integration** > (`io.ably.pubsub.liveobjects.uts.integration`) and **proxy** (`io.ably.pubsub.liveobjects.uts.proxy`). > The integration/proxy tiers moved here from `:uts` in Phase 3 alongside the tests they document. +> Exception: the REST `HttpObject` specs (`rest_object_*.md`) run in `:core`, so their deviations are in +> `lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md`. > > Records every place a generated test deviates from its UTS spec, using the manual's > **Recording deviations** entry format. Structural deviation vocabulary for these suites: diff --git a/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/integration/RestObjectRealtimeInteropTest.kt b/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/integration/RestObjectRealtimeInteropTest.kt new file mode 100644 index 000000000..0a0cd1189 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/integration/RestObjectRealtimeInteropTest.kt @@ -0,0 +1,176 @@ +package io.ably.pubsub.liveobjects.uts.integration + +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.uts.infra.integration.SandboxApp +import io.ably.pubsub.uts.infra.unit.TestHttpClient +import io.ably.pubsub.uts.infra.unit.TestRealtimeClient +import io.ably.pubsub.uts.infra.awaitState +import io.ably.pubsub.realtime.ConnectionState +import io.ably.pubsub.types.ChannelMode +import io.ably.pubsub.types.ChannelOptions +import io.ably.pubsub.liveobjects.value.LiveCounter +import io.ably.pubsub.liveobjects.value.LiveMap +import io.ably.pubsub.liveobjects.value.LiveMapValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await +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. + * + * Realtime → REST interop for `HttpObject` (spec `RestObject`): objects written over a realtime + * connection are read back with `channel.object.get()` on an HTTP channel. The rest of the spec's + * tests need no realtime plugin and live in `:core` + * (`io.ably.pubsub.uts.integration.standard.objects.RestObjectTest`). + * + * Spec points: RSL16, RSO2g. Source spec: `objects/integration/rest_object_test.md` + * (test `objects/integration/RSL16/realtime-write-rest-read-0` only). + * + * 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 RestObjectRealtimeInteropTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * 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) + + /** + * @UTS objects/integration/RSL16/realtime-write-rest-read-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RSL16, RSO2g - state written by a realtime client is readable via REST`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-rest-RSL16-interop-" + UUID.randomUUID() + + val realtime = TestRealtimeClient { + key = app.defaultKey + endpoint = SandboxApp.sandboxEndpoint + autoConnect = false + this.useBinaryProtocol = useBinaryProtocol + } + try { + realtime.connect() + awaitState(realtime, ConnectionState.connected, 15.seconds) + + val realtimeChannel = realtime.channels.get( + channelName, + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = realtimeChannel.`object`.get().await() + + val rest = TestHttpClient { + key = app.defaultKey + endpoint = SandboxApp.sandboxEndpoint + this.useBinaryProtocol = useBinaryProtocol + } + val channel = rest.channels.get(channelName) + + root.set("str", LiveMapValue.of("hello")).await() + root.set("bin", LiveMapValue.of(byteArrayOf(0x01, 0x02, 0x03))).await() + root.set("prefs", LiveMapValue.of(JsonObject().apply { addProperty("theme", "dark") })).await() + root.set("visits", LiveMapValue.of(LiveCounter.create(7))).await() + root.set("profile", LiveMapValue.of(LiveMap.create(mapOf("nickname" to LiveMapValue.of("ally"))))).await() + + val full = pollUntilSuccess { + val r = channel.`object`.get() as? HttpLiveMap + if (r != null && "profile" in r.entries) r else null + } + val compact = assertIs(channel.`object`.getCompact()) + + assertEquals("hello", compact["str"].asString) + assertEquals(7.0, compact["visits"].asDouble) + assertEquals("ally", compact["profile"].asJsonObject["nickname"].asString) + + assertEquals("root", full.objectId) + fun leaf(key: String) = assertIs(full.entries.getValue(key).data) + assertEquals("hello", leaf("str").string) + assertIs(leaf("bin").bytes) + assertContentEquals(byteArrayOf(0x01, 0x02, 0x03), leaf("bin").bytes) + assertEquals(json("{\"theme\":\"dark\"}"), leaf("prefs").json) + + val visits = full.entries.getValue("visits").data + assertIs(visits) + assertTrue(visits.objectId.startsWith("counter:")) + assertEquals(7.0, visits.count) + + val profile = full.entries.getValue("profile").data + assertIs(profile) + assertTrue(profile.objectId.startsWith("map:")) + assertEquals("ally", assertIs(profile.entries.getValue("nickname").data).string) + } finally { + realtime.close() + } + } +}