From 36d5367526819d82d499d387ce08ab94dd42be17 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 4 Nov 2026 14:01:49 +0000 Subject: [PATCH 1/4] Add the LiveObjects REST API as channel.object on HTTP channels Implement RestObject (RSL16, RSO1-RSO16 in ably/specification#569) as HttpObject, following ably-js's Rest -> Http naming. It is built into the HTTP client in lib, needs no plugin, and follows the HTTP channel's async convention: blocking methods plus ...Async(..., Callback) variants. - get: compact results are returned as plain Java values; full results decode into HttpLiveMap, HttpLiveCounter, HttpObjectData leaves, or a plain HttpLiveObject for unrecognised types. - publish: operations are built with factories, so each has exactly one body. No operation ids are generated (RSO3f), so a publish goes to the current host only and is never retried on a fallback host, which could apply a batch twice. - generateObjectId: derives the ID locally (RTO14) from per-client server time; concurrent first callers share one /time request. JSON leaf values go on the wire in the OD2g `json` field rather than the OD4d5 `string` + `encoding: "json"` form: the sandbox accepts the latter but drops `encoding` on storage, so JSON would read back as a string. Translate the UTS specs with the uts-to-kotlin skill into :core (uts/unit/objects, uts/integration/standard/objects). The realtime-write REST-read interop test stays in :liveobjects, as it needs the realtime plugin. HttpObjectEdgeCasesTest covers async callbacks, error handling, the no-fallback publish and the shared server-time request. Deviations are recorded in deviations.md. Co-Authored-By: Claude Opus 5.5 --- .../references/objects-mapping.md | 58 ++ .../java/io/ably/pubsub/http/ChannelBase.java | 11 + .../java/io/ably/pubsub/http/PubSubBase.java | 6 + .../ably/pubsub/liveobjects/HttpObject.java | 253 +++++++ .../liveobjects/http/DefaultHttpObject.java | 329 +++++++++ .../liveobjects/http/HttpLiveCounter.java | 18 + .../pubsub/liveobjects/http/HttpLiveMap.java | 26 + .../liveobjects/http/HttpLiveMapEntry.java | 16 + .../liveobjects/http/HttpLiveObject.java | 28 + .../liveobjects/http/HttpObjectCodec.java | 482 ++++++++++++ .../liveobjects/http/HttpObjectData.java | 15 + .../http/HttpObjectGenerateIdBody.java | 59 ++ .../http/HttpObjectGenerateIdResult.java | 23 + .../http/HttpObjectGetFullResult.java | 99 +++ .../liveobjects/http/HttpObjectGetParams.java | 70 ++ .../liveobjects/http/HttpObjectOperation.java | 363 +++++++++ .../http/HttpObjectPublishResult.java | 25 + .../liveobjects/http/HttpObjectResults.java | 314 ++++++++ .../http/HttpObjectServerTime.java | 188 +++++ .../liveobjects/http/PublishObjectData.java | 98 +++ .../pubsub/liveobjects/http/package-info.java | 7 + .../http/HttpObjectEdgeCasesTest.kt | 484 ++++++++++++ .../kotlin/io/ably/pubsub/uts/deviations.md | 51 +- .../standard/objects/RestObjectTest.kt | 691 ++++++++++++++++++ .../uts/unit/objects/HttpObjectHelpers.kt | 174 +++++ .../unit/objects/RestObjectGenerateIdTest.kt | 281 +++++++ .../uts/unit/objects/RestObjectGetTest.kt | 507 +++++++++++++ .../uts/unit/objects/RestObjectPublishTest.kt | 447 +++++++++++ .../ably/pubsub/liveobjects/uts/deviations.md | 2 + .../RestObjectRealtimeInteropTest.kt | 176 +++++ 30 files changed, 5295 insertions(+), 6 deletions(-) create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveCounter.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMap.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveMapEntry.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpLiveObject.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectCodec.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectData.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdBody.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGenerateIdResult.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetFullResult.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectGetParams.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectOperation.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectPublishResult.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectResults.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/PublishObjectData.java create mode 100644 lib/src/main/java/io/ably/pubsub/liveobjects/http/package-info.java create mode 100644 lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt create mode 100644 lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects/RestObjectTest.kt create mode 100644 lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/HttpObjectHelpers.kt create mode 100644 lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGenerateIdTest.kt create mode 100644 lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectGetTest.kt create mode 100644 lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects/RestObjectPublishTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/pubsub/liveobjects/uts/integration/RestObjectRealtimeInteropTest.kt 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/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..08be06ace --- /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. + * + *

The request goes to the current host only and is not retried on a fallback host, because + * without operation ids a batch the first host applied before failing would be applied twice. + * If the request fails with a server or connection error, it may or may not have been applied; + * set operation ids if you need to retry safely. + * + *

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..921cb834a --- /dev/null +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java @@ -0,0 +1,329 @@ +package io.ably.pubsub.liveobjects.http; + +import com.google.gson.JsonElement; +import io.ably.pubsub.http.HttpConstants; +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. + // Sent to the current host only, never retried on a fallback host: operations don't + // carry generated ids (RSO3f), so a batch the first host applied before failing would + // be applied twice. + http.ablyHttpExecuteWithRetry(client.httpCore.getPreferredHost(), basePath, HttpConstants.Methods.POST, + 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..eca50474f --- /dev/null +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java @@ -0,0 +1,188 @@ +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 io.ably.pubsub.util.SystemClock; +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 stored offset to the local clock. + * + *

The offset is kept per client, so each client queries its own endpoint, and 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 volatile Long offset; + + 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 { + client.timeAsync(new Callback() { + @Override + public void onSuccess(Long serverTime) { + storeOffset(serverTime); + notifyWaiters(serverTime, null); + } + + @Override + public void onError(ErrorInfo reason) { + notifyWaiters(null, reason != null ? reason : new ErrorInfo("Failed to retrieve server time", 500, 50000)); + } + }); + } catch (RuntimeException e) { + // the request never started; release the waiters so later calls can try again + notifyWaiters(null, AblyException.fromThrowable(e).errorInfo); + } + } + + /** 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) { + offset = serverTime - SystemClock.clockFrom(client.options).currentTimeMillis(); + } + + private Long knownTime() { + Long known = offset; + return known != null ? SystemClock.clockFrom(client.options).currentTimeMillis() + known : null; + } + + /** + * 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..fad9060cb --- /dev/null +++ b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt @@ -0,0 +1,484 @@ +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 not retried on a fallback host after a server error`() { + val serverError = mapOf("error" to mapOf("code" to 50000, "statusCode" to 500, "message" to "boom")) + val setup = setupRestObject("edge-no-fallback") { it.respondWith(500, serverError) } + + // Control: a GET to the same failing mock is retried on fallback hosts. + assertFailsWith { setup.channel.`object`.getCompact() } + val getRequests = setup.capturedRequests.size + assertTrue(getRequests > 1, "expected the GET to be retried on fallback hosts, saw $getRequests request(s)") + + setup.capturedRequests.clear() + val error = assertFailsWith { setup.channel.`object`.publish(HttpObjectOperation.counterInc(5).withPath("c")) } + assertEquals(500, error.errorInfo.statusCode) + assertEquals(1, setup.capturedRequests.size) + + setup.capturedRequests.clear() + val asyncError = awaitCallback { setup.channel.`object`.publishAsync(HttpObjectOperation.counterInc(5).withPath("c"), it) } + assertEquals(500, asyncError.errorInfo().statusCode) + assertEquals(1, 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) + } + + //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() + } + } +} From 6036ca851171a9fab685fded3a959255e9c9e3a2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 6 Nov 2026 13:40:57 +0000 Subject: [PATCH 2/4] Release generateObjectIdAsync waiters when the /time response is malformed A 200 /time response that can't be read as [time] (e.g. [], [null], HTML or an empty body) makes PubSubBase.timeImpl throw a RuntimeException on the HTTP worker. HttpScheduler only catches AblyException, so the timeAsync callback was never invoked: inFlight stayed ASYNC, and the pending and every later generateObjectIdAsync call on that client hung. The shared async fetch now runs the blocking client.time() on the scheduler and calls notifyWaiters() on every outcome, so waiters always get a result or an error and the next call retries. Co-Authored-By: Claude Opus 5.5 --- .../http/HttpObjectServerTime.java | 28 +++++++++------- .../http/HttpObjectEdgeCasesTest.kt | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) 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 index eca50474f..21bafe00d 100644 --- a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java @@ -105,24 +105,28 @@ void currentTimeMillisAsync(@NotNull Callback callback) { return; } try { - client.timeAsync(new Callback() { - @Override - public void onSuccess(Long serverTime) { - storeOffset(serverTime); - notifyWaiters(serverTime, null); - } - - @Override - public void onError(ErrorInfo reason) { - notifyWaiters(null, reason != null ? reason : new ErrorInfo("Failed to retrieve server time", 500, 50000)); - } - }); + // 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); 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 index fad9060cb..5ae95efe5 100644 --- a/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt +++ b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt @@ -480,5 +480,37 @@ class HttpObjectEdgeCasesTest { assertEquals(2, timeRequests) } + @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 } From df4db77d478e7e34a099b9ccf174cd9fdccd8331 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 6 Nov 2026 13:40:58 +0000 Subject: [PATCH 3/4] Share the server time offset between generateObjectId and queryTime HttpObjectServerTime kept its own offset next to the one Auth keeps for queryTime token requests, so a client using both fetched /time twice, and the objects offset was never discarded after a device clock change. Auth now exposes serverTimestampIfKnown() and setServerTime(), which hold the existing 500 ms clock-jump check; createTokenRequest uses them, and HttpObjectServerTime reads and stores the offset through them while keeping its own coordination of concurrent first fetches. Storing a time now also resets the clock-jump baseline. timeDelta is volatile, as serverTimestamp() and the token expiry check read it without the lock. Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/ably/pubsub/http/Auth.java | 48 +++++++++++++------ .../http/HttpObjectServerTime.java | 13 +++-- .../http/HttpObjectEdgeCasesTest.kt | 21 ++++++++ 3 files changed, 61 insertions(+), 21 deletions(-) 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/liveobjects/http/HttpObjectServerTime.java b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java index 21bafe00d..ba238857f 100644 --- a/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/HttpObjectServerTime.java @@ -5,7 +5,6 @@ import io.ably.pubsub.types.Callback; import io.ably.pubsub.types.ErrorInfo; import io.ably.pubsub.util.Log; -import io.ably.pubsub.util.SystemClock; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -15,9 +14,11 @@ /** * [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 stored offset to the local clock. + * first use, then derived from the offset to the local clock. * - *

The offset is kept per client, so each client queries its own endpoint, and concurrent first + *

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 @@ -30,7 +31,6 @@ public final class HttpObjectServerTime { private static final String TAG = HttpObjectServerTime.class.getName(); private final PubSubBase client; - private volatile Long offset; private enum Fetch { NONE, BLOCKING, ASYNC } @@ -158,12 +158,11 @@ long await() throws AblyException { } private void storeOffset(long serverTime) { - offset = serverTime - SystemClock.clockFrom(client.options).currentTimeMillis(); + client.auth.setServerTime(serverTime); } private Long knownTime() { - Long known = offset; - return known != null ? SystemClock.clockFrom(client.options).currentTimeMillis() + known : null; + return client.auth.serverTimestampIfKnown(); } /** 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 index 5ae95efe5..30dcd31ac 100644 --- a/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt +++ b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt @@ -480,6 +480,27 @@ class HttpObjectEdgeCasesTest { 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 From 33eef377aae4b770108234360e7509945466b383 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 6 Nov 2026 13:42:43 +0000 Subject: [PATCH 4/4] Retry object publishes on fallback hosts, as ably-js does Publish went to the current host only, so during a primary-host outage it failed where ably-js and plain channel.publish fall back. It now goes through the usual fallback path for every batch. No ids are generated (RSO3f), so a batch without ids that the first host applied before failing can be applied twice; the HttpObject.publish docs say to set ids when that matters, as the server de-duplicates by id. Co-Authored-By: Claude Opus 5.5 --- .../ably/pubsub/liveobjects/HttpObject.java | 8 ++-- .../liveobjects/http/DefaultHttpObject.java | 11 ++---- .../http/HttpObjectEdgeCasesTest.kt | 38 +++++++++++-------- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java b/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java index 08be06ace..8f7b575dc 100644 --- a/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/HttpObject.java @@ -184,10 +184,10 @@ public interface HttpObject { * 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. * - *

The request goes to the current host only and is not retried on a fallback host, because - * without operation ids a batch the first host applied before failing would be applied twice. - * If the request fails with a server or connection error, it may or may not have been applied; - * set operation ids if you need to retry safely. + *

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 * 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 index 921cb834a..5d5fc29f0 100644 --- a/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java +++ b/lib/src/main/java/io/ably/pubsub/liveobjects/http/DefaultHttpObject.java @@ -1,7 +1,6 @@ package io.ably.pubsub.liveobjects.http; import com.google.gson.JsonElement; -import io.ably.pubsub.http.HttpConstants; import io.ably.pubsub.http.HttpCore; import io.ably.pubsub.http.HttpRequester; import io.ably.pubsub.http.HttpUtils; @@ -171,12 +170,10 @@ private HttpRequester.Request publishImpl(final List callback.onError(e.errorInfo)); return; } - // RSO3c, RSO3e - one POST carrying all operations as an array. - // Sent to the current host only, never retried on a fallback host: operations don't - // carry generated ids (RSO3f), so a batch the first host applied before failing would - // be applied twice. - http.ablyHttpExecuteWithRetry(client.httpCore.getPreferredHost(), basePath, HttpConstants.Methods.POST, - HttpUtils.defaultAcceptHeaders(binary()), query, body, + // 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); }); } 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 index 30dcd31ac..3110bf5b3 100644 --- a/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt +++ b/lib/src/test/kotlin/io/ably/pubsub/liveobjects/http/HttpObjectEdgeCasesTest.kt @@ -160,24 +160,32 @@ class HttpObjectEdgeCasesTest { //region Fallback hosts @Test - fun `publish is not retried on a fallback host after a server error`() { + 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")) - val setup = setupRestObject("edge-no-fallback") { it.respondWith(500, serverError) } - - // Control: a GET to the same failing mock is retried on fallback hosts. - assertFailsWith { setup.channel.`object`.getCompact() } - val getRequests = setup.capturedRequests.size - assertTrue(getRequests > 1, "expected the GET to be retried on fallback hosts, saw $getRequests request(s)") + 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")), + ) - setup.capturedRequests.clear() - val error = assertFailsWith { setup.channel.`object`.publish(HttpObjectOperation.counterInc(5).withPath("c")) } - assertEquals(500, error.errorInfo.statusCode) - assertEquals(1, setup.capturedRequests.size) + 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() - val asyncError = awaitCallback { setup.channel.`object`.publishAsync(HttpObjectOperation.counterInc(5).withPath("c"), it) } - assertEquals(500, asyncError.errorInfo().statusCode) - assertEquals(1, setup.capturedRequests.size) + setup.capturedRequests.clear() + assertEquals("m", awaitCallback { setup.channel.`object`.publishAsync(operations, it) }.result!!.messageId) + assertEquals(2, setup.capturedRequests.size) + } } //endregion