From bbeeb6fba7425bdd2b86ef57c2b74c091a7f3c27 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 10:50:12 -0300 Subject: [PATCH 1/9] Tweak some Cursor rules --- .cursor/rules/specification.mdc | 2 ++ .cursor/rules/swift.mdc | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/specification.mdc b/.cursor/rules/specification.mdc index d9f35765..c0c086e0 100644 --- a/.cursor/rules/specification.mdc +++ b/.cursor/rules/specification.mdc @@ -10,3 +10,5 @@ alwaysApply: false - If you are given a task that requires knowledge of the Specification Document, you must consult the Specification Document before proceeding. You must never make a guess about the contents of the Specification Document. - If you are given a task that requires you to interpret the Specification Document, but the Specification Document is unclear, be sure to mention this. - The Specification Document is structured as a list of specification points, each with an identifier. An example identifier is "OD1". In the Specification Document, the start of specification point OD1 would be represented by the string @(OD1)@. These specification points are sometimes referred to as "specification items". +- Some specification points have subpoints. For example REC2 has (amongst others) the subpoint RSC2a, which has subpoints REC2a1 and REC2a2. +- The LiveObjects functionality is referred to the in the Specification simply as "Objects". diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 1c15a178..0af35c32 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -7,4 +7,7 @@ When writing Swift: - Be sure to satisfy SwiftLint's `explicit_acl` rule ("All declarations should specify Access Control Level keywords explicitly). - When writing an `extension` of a type, favour placing the access level on the declaration of the extension rather than each of its individual members. - - This does not apply when writing test code. \ No newline at end of file + - This does not apply when writing test code. +- When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. +- When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. +- When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. From 3baca83e15b59bcad931d5f06864c60fe53250cc Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 19 Jun 2025 15:27:46 -0300 Subject: [PATCH 2/9] Remove specific error types from InternalError.Other Have to add a bunch of boilerplate each time we introduce a new error type, to no current benefit. --- .../Utility/InternalError.swift | 18 ++++++------------ .../InternalErrorTests.swift | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 Tests/AblyLiveObjectsTests/InternalErrorTests.swift diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift index e015c82c..e9fbf705 100644 --- a/Sources/AblyLiveObjects/Utility/InternalError.swift +++ b/Sources/AblyLiveObjects/Utility/InternalError.swift @@ -8,8 +8,8 @@ internal enum InternalError: Error { case other(Other) internal enum Other { - case jsonValueDecodingError(JSONValueDecodingError) - case inboundWireObjectMessageDecodingError(InboundWireObjectMessage.DecodingError) + // In ably-chat-swift we have different cases here for different types of errors thrown within the codebase, but we didn't figure out what to actually _do_ with these different types of errors (see implementation of toARTErrorInfo which squashes everything down to the same error), so let's not bother with that for now + case generic(Error) } /// Returns the error that this should be converted to when exposed via the SDK's public API. @@ -24,20 +24,14 @@ internal enum InternalError: Error { } } -internal extension ARTErrorInfo { - func toInternalError() -> InternalError { - .errorInfo(self) - } -} - -internal extension JSONValueDecodingError { +internal extension Error { func toInternalError() -> InternalError { - .other(.jsonValueDecodingError(self)) + .other(.generic(self)) } } -internal extension InboundWireObjectMessage.DecodingError { +internal extension ARTErrorInfo { func toInternalError() -> InternalError { - .other(.inboundWireObjectMessageDecodingError(self)) + .errorInfo(self) } } diff --git a/Tests/AblyLiveObjectsTests/InternalErrorTests.swift b/Tests/AblyLiveObjectsTests/InternalErrorTests.swift new file mode 100644 index 00000000..ac5c9333 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/InternalErrorTests.swift @@ -0,0 +1,18 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +struct InternalErrorTests { + @Test + func artErrorInfo_toInternalError() { + let errorInfo = ARTErrorInfo(domain: "foo", code: 3) + + // Check that we get errorInfo instead of the protocol extension on Swift.Error + switch errorInfo.toInternalError() { + case .errorInfo: + break + case .other: + Issue.record("Expected .errorInfo") + } + } +} From 1d5657ed68b10f59765dc729648ed23cf2778521 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 10:06:42 -0300 Subject: [PATCH 3/9] Introduce non-wire versions of the ObjectMessage types We're going to introduce an additional decoding step in which the ObjectMessages sent and received over the wire need to have some binary and JSON data encoded per the rules of the specification. So introduce new types that will represent the ObjectMessage after it's gone through this upcoming processing. Most of this code was generated by Cursor at my instruction. --- .../AblyLiveObjects/DefaultLiveObjects.swift | 22 +- .../Internal/DefaultInternalPlugin.swift | 42 ++-- .../Protocol/ObjectMessage.swift | 237 ++++++++++++++++++ .../Protocol/WireObjectMessage.swift | 2 + .../AblyLiveObjectsTests.swift | 2 +- 5 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 Sources/AblyLiveObjects/Protocol/ObjectMessage.swift diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultLiveObjects.swift index 99f7bc5f..117ce678 100644 --- a/Sources/AblyLiveObjects/DefaultLiveObjects.swift +++ b/Sources/AblyLiveObjects/DefaultLiveObjects.swift @@ -8,10 +8,10 @@ internal class DefaultLiveObjects: Objects { private let pluginAPI: AblyPlugin.PluginAPIProtocol // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. - private let receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> - private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation - private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> - private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation + private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { self.channel = channel @@ -62,26 +62,26 @@ internal class DefaultLiveObjects: Objects { testsOnly_onChannelAttachedHasObjects = hasObjects } - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { receivedObjectProtocolMessages } - internal func handleObjectProtocolMessage(wireObjectMessages: [InboundWireObjectMessage]) { - receivedObjectProtocolMessagesContinuation.yield(wireObjectMessages) + internal func handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { + receivedObjectProtocolMessagesContinuation.yield(objectMessages) } - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(wireObjectMessages: [InboundWireObjectMessage], protocolMessageChannelSerial _: String) { - receivedObjectSyncProtocolMessagesContinuation.yield(wireObjectMessages) + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String) { + receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) } // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_sendObject(objectMessages: [OutboundWireObjectMessage]) async throws(InternalError) { + internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { guard let channel else { return } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 8ea3844b..43ed497b 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -46,14 +46,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte Self.objectsProperty(for: channel, pluginAPI: pluginAPI) } - /// A class that wraps a ``WireObjectMessage``. + /// A class that wraps an object message. /// - /// We need this intermediate type because we want `WireObjectMessage` to be a struct — because it's nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.WireObjectMessage` protocol. - private final class WireObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { - internal let wireObjectMessage: T + /// We need this intermediate type because we want object messages to be structs — because they're nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.ObjectMessageProtocol`. + private final class ObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { + internal let objectMessage: T - init(wireObjectMessage: T) { - self.wireObjectMessage = wireObjectMessage + init(objectMessage: T) { + self.objectMessage = objectMessage } } @@ -65,7 +65,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte jsonObject: jsonObject, decodingContext: context, ) - return WireObjectMessageBox(wireObjectMessage: wireObjectMessage) + let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) + return ObjectMessageBox(objectMessage: objectMessage) } catch { errorPtr?.pointee = error.toARTErrorInfo() return nil @@ -73,11 +74,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [String: Any] { - guard let wireObjectMessageBox = publicObjectMessage as? WireObjectMessageBox else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { + preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } - return wireObjectMessageBox.wireObjectMessage.toJSONObject.toAblyPluginDataDictionary + let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire() + return wireObjectMessage.toJSONObject.toAblyPluginDataDictionary } internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { @@ -85,26 +87,26 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: ARTRealtimeChannel) { - guard let wireObjectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } - let wireObjectMessages = wireObjectMessageBoxes.map(\.wireObjectMessage) + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) objectsProperty(for: channel).handleObjectProtocolMessage( - wireObjectMessages: wireObjectMessages, + objectMessages: objectMessages, ) } internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { - guard let objectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } - let wireObjectMessages = objectMessageBoxes.map(\.wireObjectMessage) + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) objectsProperty(for: channel).handleObjectSyncProtocolMessage( - wireObjectMessages: wireObjectMessages, + objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, ) } @@ -112,11 +114,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - Sending `OBJECT` ProtocolMessage internal static func sendObject( - objectMessages: [OutboundWireObjectMessage], + objectMessages: [OutboundObjectMessage], channel: ARTRealtimeChannel, pluginAPI: PluginAPIProtocol, ) async throws(InternalError) { - let objectMessageBoxes: [WireObjectMessageBox] = objectMessages.map { .init(wireObjectMessage: $0) } + let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in pluginAPI.sendObject( diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift new file mode 100644 index 00000000..153bfc5e --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -0,0 +1,237 @@ +import Foundation + +// This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. + +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct InboundObjectMessage { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i +} + +/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +internal struct OutboundObjectMessage { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i +} + +internal struct ObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapOp: MapOp? // OOP3c + internal var counterOp: WireCounterOp? // OOP3d + internal var map: Map? // OOP3e + internal var counter: WireCounter? // OOP3f + internal var nonce: String? // OOP3g + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var initialValue: Data? // OOP3h + internal var initialValueEncoding: String? // OOP3i +} + +internal struct ObjectData { + internal var objectId: String? // OD2a + internal var encoding: String? // OD2b + internal var boolean: Bool? // OD2c + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f +} + +internal struct MapOp { + internal var key: String // MOP2a + internal var data: ObjectData? // MOP2b +} + +internal struct MapEntry { + internal var tombstone: Bool? // ME2a + internal var timeserial: String? // ME2b + internal var data: ObjectData // ME2c +} + +internal struct Map { + internal var semantics: WireEnum // MAP3a + internal var entries: [String: MapEntry]? // MAP3b +} + +internal struct ObjectState { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: ObjectOperation? // OST2d + internal var map: Map? // OST2e + internal var counter: WireCounter? // OST2f +} + +internal extension InboundObjectMessage { + /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`. + init(wireObjectMessage: InboundWireObjectMessage) { + id = wireObjectMessage.id + clientId = wireObjectMessage.clientId + connectionId = wireObjectMessage.connectionId + extras = wireObjectMessage.extras + timestamp = wireObjectMessage.timestamp + operation = wireObjectMessage.operation.map { .init(wireObjectOperation: $0) } + object = wireObjectMessage.object.map { .init(wireObjectState: $0) } + serial = wireObjectMessage.serial + siteCode = wireObjectMessage.siteCode + } +} + +internal extension OutboundObjectMessage { + /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`. + func toWire() -> OutboundWireObjectMessage { + .init( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation?.toWire(), + object: object?.toWire(), + serial: serial, + siteCode: siteCode, + ) + } +} + +internal extension ObjectOperation { + /// Initializes an `ObjectOperation` from a `WireObjectOperation`. + init(wireObjectOperation: WireObjectOperation) { + action = wireObjectOperation.action + objectId = wireObjectOperation.objectId + mapOp = wireObjectOperation.mapOp.map { .init(wireMapOp: $0) } + counterOp = wireObjectOperation.counterOp + map = wireObjectOperation.map.map { .init(wireMap: $0) } + counter = wireObjectOperation.counter + nonce = wireObjectOperation.nonce + initialValue = wireObjectOperation.initialValue + initialValueEncoding = wireObjectOperation.initialValueEncoding + } + + /// Converts this `ObjectOperation` to a `WireObjectOperation`. + func toWire() -> WireObjectOperation { + .init( + action: action, + objectId: objectId, + mapOp: mapOp?.toWire(), + counterOp: counterOp, + map: map?.toWire(), + counter: counter, + nonce: nonce, + initialValue: initialValue, + initialValueEncoding: initialValueEncoding, + ) + } +} + +internal extension ObjectData { + /// Initializes an `ObjectData` from a `WireObjectData`. + init(wireObjectData: WireObjectData) { + objectId = wireObjectData.objectId + encoding = wireObjectData.encoding + boolean = wireObjectData.boolean + bytes = wireObjectData.bytes + number = wireObjectData.number + string = wireObjectData.string + } + + /// Converts this `ObjectData` to a `WireObjectData`. + func toWire() -> WireObjectData { + .init( + objectId: objectId, + encoding: encoding, + boolean: boolean, + bytes: bytes, + number: number, + string: string, + ) + } +} + +internal extension MapOp { + /// Initializes a `MapOp` from a `WireMapOp`. + init(wireMapOp: WireMapOp) { + key = wireMapOp.key + data = wireMapOp.data.map { .init(wireObjectData: $0) } + } + + /// Converts this `MapOp` to a `WireMapOp`. + func toWire() -> WireMapOp { + .init( + key: key, + data: data?.toWire(), + ) + } +} + +internal extension MapEntry { + /// Initializes a `MapEntry` from a `WireMapEntry`. + init(wireMapEntry: WireMapEntry) { + tombstone = wireMapEntry.tombstone + timeserial = wireMapEntry.timeserial + data = .init(wireObjectData: wireMapEntry.data) + } + + /// Converts this `MapEntry` to a `WireMapEntry`. + func toWire() -> WireMapEntry { + .init( + tombstone: tombstone, + timeserial: timeserial, + data: data.toWire(), + ) + } +} + +internal extension Map { + /// Initializes a `Map` from a `WireMap`. + init(wireMap: WireMap) { + semantics = wireMap.semantics + entries = wireMap.entries?.mapValues { .init(wireMapEntry: $0) } + } + + /// Converts this `Map` to a `WireMap`. + func toWire() -> WireMap { + .init( + semantics: semantics, + entries: entries?.mapValues { $0.toWire() }, + ) + } +} + +internal extension ObjectState { + /// Initializes an `ObjectState` from a `WireObjectState`. + init(wireObjectState: WireObjectState) { + objectId = wireObjectState.objectId + siteTimeserials = wireObjectState.siteTimeserials + tombstone = wireObjectState.tombstone + createOp = wireObjectState.createOp.map { .init(wireObjectOperation: $0) } + map = wireObjectState.map.map { .init(wireMap: $0) } + counter = wireObjectState.counter + } + + /// Converts this `ObjectState` to a `WireObjectState`. + func toWire() -> WireObjectState { + .init( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp?.toWire(), + map: map?.toWire(), + counter: counter, + ) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 1a18e641..4fdebc71 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -1,6 +1,8 @@ internal import AblyPlugin import Foundation +// This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. + /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. internal struct InboundWireObjectMessage { // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index c9e27cfa..c999a22d 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -91,7 +91,7 @@ struct AblyLiveObjectsTests { let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ - OutboundWireObjectMessage( + OutboundObjectMessage( operation: .init( action: .known(.mapCreate), objectId: realtimeCreatedMapObjectID, From c6a3f8051ff5582d8bf4a379db6e09ddd92552b9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 14:10:11 -0300 Subject: [PATCH 4/9] Fix a documentation comment --- Sources/AblyLiveObjects/Utility/JSONCodable.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/JSONCodable.swift index d844bf13..a8cec9be 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/JSONCodable.swift @@ -211,11 +211,9 @@ internal extension [String: JSONValue] { return boolValue } - /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. + /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { guard let value = self[key] else { return nil From d60d61ddf10c14caa121a4312aa32e0065cbb36f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Jun 2025 15:27:04 -0300 Subject: [PATCH 5/9] Update ably-cocoa Introduces the `format` argument for encoding and decoding ObjectMessages; we'll use this argument shortly to handle binary data. --- .../Internal/DefaultInternalPlugin.swift | 14 ++++++++++++-- ably-cocoa | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 43ed497b..312a95a2 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -57,7 +57,13 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } } - internal func decodeObjectMessage(_ serialized: [String: Any], context: DecodingContextProtocol, error errorPtr: AutoreleasingUnsafeMutablePointer?) -> (any ObjectMessageProtocol)? { + internal func decodeObjectMessage( + _ serialized: [String: Any], + context: DecodingContextProtocol, + // TODO: use + format: EncodingFormat, + error errorPtr: AutoreleasingUnsafeMutablePointer?, + ) -> (any ObjectMessageProtocol)? { let jsonObject = JSONValue.objectFromAblyPluginData(serialized) do { @@ -73,7 +79,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } } - internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [String: Any] { + internal func encodeObjectMessage( + _ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol, + // TODO: use + format: EncodingFormat, + ) -> [String: Any] { guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } diff --git a/ably-cocoa b/ably-cocoa index 5fc038cb..9b2bfe48 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 5fc038cb2b481f51b630211771afd16762af152e +Subproject commit 9b2bfe48b4c8c070404957758869b8918e8eade3 From 3746582843e8c78daa11a65a34b402a021b2d11d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 11:46:39 -0300 Subject: [PATCH 6/9] Introduce a WireValue type This provides us with a JSONValue-like type which also supports binary data. We'll use this in an upcoming commit for encoding and decoding the binary data contained within an ObjectMessage. (Note about naming: the term "wire object" is a bit overloaded now; it's used in protocols like WireObjectCodable and it also appears as a prefix to the name of WireObjectMessage, and the two meanings have nothing to do with each other. I didn't have a better idea for naming though and didn't want to get hung up on it.) This was initially implemented by asking Cursor to copy JSONValue; I then realised that I'd made some mistakes in what I'd asked it to do, so had to make a bunch of corrections myself and also introduced the ExtendedJSONValue type. The MessagePack decoding tests (the ones that make comparisons to hand-crafted byte arrays) were generated by Cursor, and I have not verified the correctness of these byte arrays; my main assurance that they're probably correct is that the tests pass, and that it feels unlikely that Cursor and ably-cocoa's MessagePack encoder contain overlapping mistakes in their handling of MessagePack data. --- .../Internal/DefaultInternalPlugin.swift | 6 +- .../Protocol/WireObjectMessage.swift | 250 ++++++------ .../Utility/ExtendedJSONValue.swift | 100 +++++ .../AblyLiveObjects/Utility/JSONValue.swift | 113 +++--- .../{JSONCodable.swift => WireCodable.swift} | 206 ++++++---- .../AblyLiveObjects/Utility/WireValue.swift | 249 ++++++++++++ .../AblyLiveObjectsTests/JSONValueTests.swift | 50 +-- .../WireObjectMessageTests.swift | 160 ++++---- .../AblyLiveObjectsTests/WireValueTests.swift | 379 ++++++++++++++++++ 9 files changed, 1148 insertions(+), 365 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift rename Sources/AblyLiveObjects/Utility/{JSONCodable.swift => WireCodable.swift} (63%) create mode 100644 Sources/AblyLiveObjects/Utility/WireValue.swift create mode 100644 Tests/AblyLiveObjectsTests/WireValueTests.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 312a95a2..e9eeb8dc 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -64,11 +64,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte format: EncodingFormat, error errorPtr: AutoreleasingUnsafeMutablePointer?, ) -> (any ObjectMessageProtocol)? { - let jsonObject = JSONValue.objectFromAblyPluginData(serialized) + let wireObject = WireValue.objectFromAblyPluginData(serialized) do { let wireObjectMessage = try InboundWireObjectMessage( - jsonObject: jsonObject, + wireObject: wireObject, decodingContext: context, ) let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) @@ -89,7 +89,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire() - return wireObjectMessage.toJSONObject.toAblyPluginDataDictionary + return wireObjectMessage.toWireObject.toAblyPluginDataDictionary } internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 4fdebc71..43d138a0 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -31,7 +31,7 @@ internal struct OutboundWireObjectMessage { } /// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. -internal enum WireObjectMessageJSONKey: String { +internal enum WireObjectMessageWireKey: String { case id case clientId case connectionId @@ -57,71 +57,79 @@ internal extension InboundWireObjectMessage { /// Decodes the `ObjectMessage` and then uses the containing `ProtocolMessage` to populate some absent fields per the rules of the specification. init( - jsonObject: [String: JSONValue], + wireObject: [String: WireValue], decodingContext: AblyPlugin.DecodingContextProtocol ) throws(InternalError) { // OM2a - if let id = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.id.rawValue) { + if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { self.id = id } else if let parentID = decodingContext.parentID { id = "\(parentID):\(decodingContext.indexInParent)" } - clientId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.clientId.rawValue) + clientId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.clientId.rawValue) // OM2c - if let connectionId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.connectionId.rawValue) { + if let connectionId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.connectionId.rawValue) { self.connectionId = connectionId } else if let parentConnectionID = decodingContext.parentConnectionID { connectionId = parentConnectionID } - extras = try jsonObject.optionalObjectValueForKey(WireObjectMessageJSONKey.extras.rawValue) + // Convert WireValue extras to JSONValue extras + if let wireExtras = try wireObject.optionalObjectValueForKey(WireObjectMessageWireKey.extras.rawValue) { + extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(InternalError) in + try wireValue.toJSONValue + } + } else { + extras = nil + } // OM2e - if let timestamp = try jsonObject.optionalAblyProtocolDateValueForKey(WireObjectMessageJSONKey.timestamp.rawValue) { + if let timestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.timestamp.rawValue) { self.timestamp = timestamp } else if let parentTimestamp = decodingContext.parentTimestamp { timestamp = parentTimestamp } - operation = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.operation.rawValue) - object = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.object.rawValue) - serial = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.serial.rawValue) - siteCode = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.siteCode.rawValue) + operation = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.operation.rawValue) + object = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.object.rawValue) + serial = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.serial.rawValue) + siteCode = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.siteCode.rawValue) } } -extension OutboundWireObjectMessage: JSONObjectEncodable { - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] +extension OutboundWireObjectMessage: WireObjectEncodable { + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let id { - result[WireObjectMessageJSONKey.id.rawValue] = .string(id) + result[WireObjectMessageWireKey.id.rawValue] = .string(id) } if let connectionId { - result[WireObjectMessageJSONKey.connectionId.rawValue] = .string(connectionId) + result[WireObjectMessageWireKey.connectionId.rawValue] = .string(connectionId) } if let timestamp { - result[WireObjectMessageJSONKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) + result[WireObjectMessageWireKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) } if let siteCode { - result[WireObjectMessageJSONKey.siteCode.rawValue] = .string(siteCode) + result[WireObjectMessageWireKey.siteCode.rawValue] = .string(siteCode) } if let serial { - result[WireObjectMessageJSONKey.serial.rawValue] = .string(serial) + result[WireObjectMessageWireKey.serial.rawValue] = .string(serial) } if let clientId { - result[WireObjectMessageJSONKey.clientId.rawValue] = .string(clientId) + result[WireObjectMessageWireKey.clientId.rawValue] = .string(clientId) } if let extras { - result[WireObjectMessageJSONKey.extras.rawValue] = .object(extras) + // Convert JSONValue extras to WireValue extras + result[WireObjectMessageWireKey.extras.rawValue] = .object(extras.mapValues { .init(jsonValue: $0) }) } if let operation { - result[WireObjectMessageJSONKey.operation.rawValue] = .object(operation.toJSONObject) + result[WireObjectMessageWireKey.operation.rawValue] = .object(operation.toWireObject) } if let object { - result[WireObjectMessageJSONKey.object.rawValue] = .object(object.toJSONObject) + result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) } return result } @@ -155,8 +163,8 @@ internal struct WireObjectOperation { internal var initialValueEncoding: String? // OOP3i } -extension WireObjectOperation: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectOperation: WireObjectCodable { + internal enum WireKey: String { case action case objectId case mapOp @@ -168,40 +176,40 @@ extension WireObjectOperation: JSONObjectCodable { case initialValueEncoding } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - action = try jsonObject.wireEnumValueForKey(JSONKey.action.rawValue) - objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) - mapOp = try jsonObject.optionalDecodableValueForKey(JSONKey.mapOp.rawValue) - counterOp = try jsonObject.optionalDecodableValueForKey(JSONKey.counterOp.rawValue) - map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) - counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) - nonce = try jsonObject.optionalStringValueForKey(JSONKey.nonce.rawValue) - initialValueEncoding = try jsonObject.optionalStringValueForKey(JSONKey.initialValueEncoding.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) + counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) + map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) + counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) + nonce = try wireObject.optionalStringValueForKey(WireKey.nonce.rawValue) + initialValueEncoding = try wireObject.optionalStringValueForKey(WireKey.initialValueEncoding.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.action.rawValue: .number(action.rawValue as NSNumber), - JSONKey.objectId.rawValue: .string(objectId), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.action.rawValue: .number(action.rawValue as NSNumber), + WireKey.objectId.rawValue: .string(objectId), ] if let mapOp { - result[JSONKey.mapOp.rawValue] = .object(mapOp.toJSONObject) + result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) } if let counterOp { - result[JSONKey.counterOp.rawValue] = .object(counterOp.toJSONObject) + result[WireKey.counterOp.rawValue] = .object(counterOp.toWireObject) } if let map { - result[JSONKey.map.rawValue] = .object(map.toJSONObject) + result[WireKey.map.rawValue] = .object(map.toWireObject) } if let counter { - result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + result[WireKey.counter.rawValue] = .object(counter.toWireObject) } if let nonce { - result[JSONKey.nonce.rawValue] = .string(nonce) + result[WireKey.nonce.rawValue] = .string(nonce) } if let initialValueEncoding { - result[JSONKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) + result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) } return result @@ -217,8 +225,8 @@ internal struct WireObjectState { internal var counter: WireCounter? // OST2f } -extension WireObjectState: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectState: WireObjectCodable { + internal enum WireKey: String { case objectId case siteTimeserials case tombstone @@ -227,35 +235,35 @@ extension WireObjectState: JSONObjectCodable { case counter } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) - siteTimeserials = try jsonObject.objectValueForKey(JSONKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + internal init(wireObject: [String: WireValue]) throws(InternalError) { + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + siteTimeserials = try wireObject.objectValueForKey(WireKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in guard case let .string(string) = value else { - throw JSONValueDecodingError.wrongTypeForKey(JSONKey.siteTimeserials.rawValue, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.siteTimeserials.rawValue, actualValue: value).toInternalError() } return string } - tombstone = try jsonObject.boolValueForKey(JSONKey.tombstone.rawValue) - createOp = try jsonObject.optionalDecodableValueForKey(JSONKey.createOp.rawValue) - map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) - counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) + tombstone = try wireObject.boolValueForKey(WireKey.tombstone.rawValue) + createOp = try wireObject.optionalDecodableValueForKey(WireKey.createOp.rawValue) + map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) + counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.objectId.rawValue: .string(objectId), - JSONKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), - JSONKey.tombstone.rawValue: .bool(tombstone), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.objectId.rawValue: .string(objectId), + WireKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), + WireKey.tombstone.rawValue: .bool(tombstone), ] if let createOp { - result[JSONKey.createOp.rawValue] = .object(createOp.toJSONObject) + result[WireKey.createOp.rawValue] = .object(createOp.toWireObject) } if let map { - result[JSONKey.map.rawValue] = .object(map.toJSONObject) + result[WireKey.map.rawValue] = .object(map.toWireObject) } if let counter { - result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + result[WireKey.counter.rawValue] = .object(counter.toWireObject) } return result @@ -267,24 +275,24 @@ internal struct WireMapOp { internal var data: WireObjectData? // MOP2b } -extension WireMapOp: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMapOp: WireObjectCodable { + internal enum WireKey: String { case key case data } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - key = try jsonObject.stringValueForKey(JSONKey.key.rawValue) - data = try jsonObject.optionalDecodableValueForKey(JSONKey.data.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + key = try wireObject.stringValueForKey(WireKey.key.rawValue) + data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.key.rawValue: .string(key), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.key.rawValue: .string(key), ] if let data { - result[JSONKey.data.rawValue] = .object(data.toJSONObject) + result[WireKey.data.rawValue] = .object(data.toWireObject) } return result @@ -295,18 +303,18 @@ internal struct WireCounterOp { internal var amount: NSNumber // COP2a } -extension WireCounterOp: JSONObjectCodable { - internal enum JSONKey: String { +extension WireCounterOp: WireObjectCodable { + internal enum WireKey: String { case amount } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - amount = try jsonObject.numberValueForKey(JSONKey.amount.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + amount = try wireObject.numberValueForKey(WireKey.amount.rawValue) } - internal var toJSONObject: [String: JSONValue] { + internal var toWireObject: [String: WireValue] { [ - JSONKey.amount.rawValue: .number(amount), + WireKey.amount.rawValue: .number(amount), ] } } @@ -316,29 +324,29 @@ internal struct WireMap { internal var entries: [String: WireMapEntry]? // MAP3b } -extension WireMap: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMap: WireObjectCodable { + internal enum WireKey: String { case semantics case entries } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - semantics = try jsonObject.wireEnumValueForKey(JSONKey.semantics.rawValue) - entries = try jsonObject.optionalObjectValueForKey(JSONKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + internal init(wireObject: [String: WireValue]) throws(InternalError) { + semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) + entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in guard case let .object(object) = value else { - throw JSONValueDecodingError.wrongTypeForKey(JSONKey.entries.rawValue, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toInternalError() } - return try WireMapEntry(jsonObject: object) + return try WireMapEntry(wireObject: object) } } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), ] if let entries { - result[JSONKey.entries.rawValue] = .object(entries.mapValues { .object($0.toJSONObject) }) + result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) } return result @@ -349,19 +357,19 @@ internal struct WireCounter { internal var count: NSNumber? // CNT2a } -extension WireCounter: JSONObjectCodable { - internal enum JSONKey: String { +extension WireCounter: WireObjectCodable { + internal enum WireKey: String { case count } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - count = try jsonObject.optionalNumberValueForKey(JSONKey.count.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let count { - result[JSONKey.count.rawValue] = .number(count) + result[WireKey.count.rawValue] = .number(count) } return result } @@ -373,29 +381,29 @@ internal struct WireMapEntry { internal var data: WireObjectData // ME2c } -extension WireMapEntry: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMapEntry: WireObjectCodable { + internal enum WireKey: String { case tombstone case timeserial case data } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - tombstone = try jsonObject.optionalBoolValueForKey(JSONKey.tombstone.rawValue) - timeserial = try jsonObject.optionalStringValueForKey(JSONKey.timeserial.rawValue) - data = try jsonObject.decodableValueForKey(JSONKey.data.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) + timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) + data = try wireObject.decodableValueForKey(WireKey.data.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.data.rawValue: .object(data.toJSONObject), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.data.rawValue: .object(data.toWireObject), ] if let tombstone { - result[JSONKey.tombstone.rawValue] = .bool(tombstone) + result[WireKey.tombstone.rawValue] = .bool(tombstone) } if let timeserial { - result[JSONKey.timeserial.rawValue] = .string(timeserial) + result[WireKey.timeserial.rawValue] = .string(timeserial) } return result @@ -412,8 +420,8 @@ internal struct WireObjectData { internal var string: String? // OD2f } -extension WireObjectData: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectData: WireObjectCodable { + internal enum WireKey: String { case objectId case encoding case boolean @@ -422,31 +430,31 @@ extension WireObjectData: JSONObjectCodable { case string } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - objectId = try jsonObject.optionalStringValueForKey(JSONKey.objectId.rawValue) - encoding = try jsonObject.optionalStringValueForKey(JSONKey.encoding.rawValue) - boolean = try jsonObject.optionalBoolValueForKey(JSONKey.boolean.rawValue) - number = try jsonObject.optionalNumberValueForKey(JSONKey.number.rawValue) - string = try jsonObject.optionalStringValueForKey(JSONKey.string.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) + encoding = try wireObject.optionalStringValueForKey(WireKey.encoding.rawValue) + boolean = try wireObject.optionalBoolValueForKey(WireKey.boolean.rawValue) + number = try wireObject.optionalNumberValueForKey(WireKey.number.rawValue) + string = try wireObject.optionalStringValueForKey(WireKey.string.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let objectId { - result[JSONKey.objectId.rawValue] = .string(objectId) + result[WireKey.objectId.rawValue] = .string(objectId) } if let encoding { - result[JSONKey.encoding.rawValue] = .string(encoding) + result[WireKey.encoding.rawValue] = .string(encoding) } if let boolean { - result[JSONKey.boolean.rawValue] = .bool(boolean) + result[WireKey.boolean.rawValue] = .bool(boolean) } if let number { - result[JSONKey.number.rawValue] = .number(number) + result[WireKey.number.rawValue] = .number(number) } if let string { - result[JSONKey.string.rawValue] = .string(string) + result[WireKey.string.rawValue] = .string(string) } return result diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift new file mode 100644 index 00000000..dc43adc3 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Like ``JSONValue``, but provides an additional case named `extra`, which allows you to support additional types of data. It's used as a common base for the implementations of ``JSONValue`` and ``WireValue``, and for converting between them. +internal indirect enum ExtendedJSONValue { + case object([String: Self]) + case array([Self]) + case string(String) + case number(NSNumber) + case bool(Bool) + case null + case extra(Extra) +} + +// MARK: - Bridging with Foundation + +internal extension ExtendedJSONValue { + /// Creates an `ExtendedJSONValue` from an object. + /// + /// The rules for what `deserialized` will accept are the same as those of `JSONValue.init(jsonSerializationOutput)`, with one addition: any nonsupported values are passed to the `createExtraValue` function, and the result of this function will be used to create an `ExtendedJSONValue` of case `.extra`. + init(deserialized: Any, createExtraValue: (Any) -> Extra) { + switch deserialized { + case let dictionary as [String: Any]: + self = .object(dictionary.mapValues { .init(deserialized: $0, createExtraValue: createExtraValue) }) + case let array as [Any]: + self = .array(array.map { .init(deserialized: $0, createExtraValue: createExtraValue) }) + case let string as String: + self = .string(string) + case let number as NSNumber: + // We need to be careful to distinguish booleans from numbers of value 0 or 1; technique taken from https://forums.swift.org/t/jsonserialization-turns-bool-value-to-nsnumber/31909/3 + if number === kCFBooleanTrue { + self = .bool(true) + } else if number === kCFBooleanFalse { + self = .bool(false) + } else { + self = .number(number) + } + case is NSNull: + self = .null + default: + self = .extra(createExtraValue(deserialized)) + } + } + + /// Converts an `ExtendedJSONValue` to an object. + /// + /// The contract for what this will return are the same as those of `JSONValue.toJSONSerializationInputElemtn`, with one addition: any values in the input of case `.extra` will be passed to the `serializeExtraValue` function, and the result of this function call will be inserted into the output object. + func serialized(serializeExtraValue: (Extra) -> Any) -> Any { + switch self { + case let .object(underlying): + underlying.mapValues { $0.serialized(serializeExtraValue: serializeExtraValue) } + case let .array(underlying): + underlying.map { $0.serialized(serializeExtraValue: serializeExtraValue) } + case let .string(underlying): + underlying + case let .number(underlying): + underlying + case let .bool(underlying): + underlying + case .null: + NSNull() + case let .extra(extra): + serializeExtraValue(extra) + } + } +} + +internal extension ExtendedJSONValue where Extra == Never { + var serialized: Any { + // swiftlint:disable:next trailing_closure + serialized(serializeExtraValue: { _ in }) + } +} + +// MARK: - Transforming the extra data + +internal extension ExtendedJSONValue { + /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using a given transformation. + func map(_ transform: @escaping (Extra) throws(Failure) -> NewExtra) throws(Failure) -> ExtendedJSONValue { + switch self { + case let .object(underlying): + try .object(underlying.ablyLiveObjects_mapValuesWithTypedThrow { value throws(Failure) in + try value.map(transform) + }) + case let .array(underlying): + try .array(underlying.map { element throws(Failure) in + try element.map(transform) + }) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .extra(extra): + try .extra(transform(extra)) + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index c08ef605..839cd54e 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -126,73 +126,84 @@ extension JSONValue: ExpressibleByBooleanLiteral { } } -// MARK: - Bridging with ably-cocoa +// MARK: - Bridging with JSONSerialization internal extension JSONValue { - /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. + /// Creates a `JSONValue` from the output of Foundation's `JSONSerialization`. /// - /// Specifically, `ablyCocoaData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - init(ablyPluginData: Any) { - switch ablyPluginData { - case let dictionary as [String: Any]: - self = .object(dictionary.mapValues { .init(ablyPluginData: $0) }) - case let array as [Any]: - self = .array(array.map { .init(ablyPluginData: $0) }) - case let string as String: - self = .string(string) - case let number as NSNumber: - // We need to be careful to distinguish booleans from numbers of value 0 or 1; technique taken from https://forums.swift.org/t/jsonserialization-turns-bool-value-to-nsnumber/31909/3 - if number === kCFBooleanTrue { - self = .bool(true) - } else if number === kCFBooleanFalse { - self = .bool(false) - } else { - self = .number(number) - } - case is NSNull: - self = .null - default: - // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("JSONValue(ablyPluginData:) was given \(ablyPluginData)") - } + /// This means that it accepts either: + /// + /// - The result of serializing an array or dictionary using `JSONSerialization` + /// - Some nested element of the result of serializing such an array or dictionary + init(jsonSerializationOutput: Any) { + // swiftlint:disable:next trailing_closure + let extended = ExtendedJSONValue(deserialized: jsonSerializationOutput, createExtraValue: { deserializedExtraValue in + // JSONSerialization is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("JSONValue(jsonSerializationOutput:) was given unsupported value \(deserializedExtraValue)") + }) + + self.init(extendedJSONValue: extended) } - /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: JSONValue] { - let jsonValue = JSONValue(ablyPluginData: ablyPluginData) - guard case let .object(jsonObject) = jsonValue else { - preconditionFailure() - } + /// Converts a `JSONValue` to an input for Foundation's `JSONSerialization`. + /// + /// This means that it returns: + /// + /// - All cases: An object which we can put inside an array or dictionary that we ask `JSONSerialization` to serialize + /// - Additionally, if case `object` or `array`: An object which we can ask `JSONSerialization` to serialize + var toJSONSerializationInputElement: Any { + toExtendedJSONValue.serialized + } +} - return jsonObject +internal extension [String: JSONValue] { + /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. + var toJSONSerializationInput: [String: Any] { + mapValues(\.toJSONSerializationInputElement) } +} - /// Creates an AblyPlugin deserialized JSON object from a `JSONValue`. - /// - /// Used by `[String: JSONValue].toAblyPluginDataDictionary`. - var toAblyPluginData: Any { - switch self { +internal extension [JSONValue] { + /// Converts an array that has `JSONValue` values into an input for Foundation's `JSONSerialization`. + var toJSONSerializationInput: [Any] { + map(\.toJSONSerializationInputElement) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension JSONValue { + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { case let .object(underlying): - underlying.toAblyPluginDataDictionary + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) case let .array(underlying): - underlying.map(\.toAblyPluginData) + self = .array(underlying.map { .init(extendedJSONValue: $0) }) case let .string(underlying): - underlying + self = .string(underlying) case let .number(underlying): - underlying + self = .number(underlying) case let .bool(underlying): - underlying + self = .bool(underlying) case .null: - NSNull() + self = .null } } -} -internal extension [String: JSONValue] { - /// Creates an AblyPlugin deserialized JSON object from a dictionary that has string keys and `JSONValue` values. - /// - /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. - var toAblyPluginDataDictionary: [String: Any] { - mapValues(\.toAblyPluginData) + var toExtendedJSONValue: ExtendedJSONValue { + switch self { + case let .object(underlying): + .object(underlying.mapValues(\.toExtendedJSONValue)) + case let .array(underlying): + .array(underlying.map(\.toExtendedJSONValue)) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + } } } diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/WireCodable.swift similarity index 63% rename from Sources/AblyLiveObjects/Utility/JSONCodable.swift rename to Sources/AblyLiveObjects/Utility/WireCodable.swift index a8cec9be..b00b7edd 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -1,67 +1,67 @@ import Ably import Foundation -internal protocol JSONEncodable { - var toJSONValue: JSONValue { get } +internal protocol WireEncodable { + var toWireValue: WireValue { get } } -internal protocol JSONDecodable { - init(jsonValue: JSONValue) throws(InternalError) +internal protocol WireDecodable { + init(wireValue: WireValue) throws(InternalError) } -internal typealias JSONCodable = JSONDecodable & JSONEncodable +internal typealias WireCodable = WireDecodable & WireEncodable -internal protocol JSONObjectEncodable: JSONEncodable { - var toJSONObject: [String: JSONValue] { get } +internal protocol WireObjectEncodable: WireEncodable { + var toWireObject: [String: WireValue] { get } } -// Default implementation of `JSONEncodable` conformance for `JSONObjectEncodable` -internal extension JSONObjectEncodable { - var toJSONValue: JSONValue { - .object(toJSONObject) +// Default implementation of `WireEncodable` conformance for `WireObjectEncodable` +internal extension WireObjectEncodable { + var toWireValue: WireValue { + .object(toWireObject) } } -internal protocol JSONObjectDecodable: JSONDecodable { - init(jsonObject: [String: JSONValue]) throws(InternalError) +internal protocol WireObjectDecodable: WireDecodable { + init(wireObject: [String: WireValue]) throws(InternalError) } -internal enum JSONValueDecodingError: Error { +internal enum WireValueDecodingError: Error { case valueIsNotObject case noValueForKey(String) - case wrongTypeForKey(String, actualValue: JSONValue) + case wrongTypeForKey(String, actualValue: WireValue) case failedToDecodeFromRawValue(String) } -// Default implementation of `JSONDecodable` conformance for `JSONObjectDecodable` -internal extension JSONObjectDecodable { - init(jsonValue: JSONValue) throws(InternalError) { - guard case let .object(jsonObject) = jsonValue else { - throw JSONValueDecodingError.valueIsNotObject.toInternalError() +// Default implementation of `WireDecodable` conformance for `WireObjectDecodable` +internal extension WireObjectDecodable { + init(wireValue: WireValue) throws(InternalError) { + guard case let .object(wireObject) = wireValue else { + throw WireValueDecodingError.valueIsNotObject.toInternalError() } - self = try .init(jsonObject: jsonObject) + self = try .init(wireObject: wireObject) } } -internal typealias JSONObjectCodable = JSONObjectDecodable & JSONObjectEncodable +internal typealias WireObjectCodable = WireObjectDecodable & WireObjectEncodable // MARK: - Extracting primitive values from a dictionary -/// This extension adds some helper methods for extracting values from a dictionary of `JSONValue` values; you may find them helpful when implementing `JSONCodable`. -internal extension [String: JSONValue] { +/// This extension adds some helper methods for extracting values from a dictionary of `WireValue` values; you may find them helpful when implementing `WireCodable`. +internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` - func objectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue] { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` + func objectValueForKey(_ key: String) throws(InternalError) -> [String: WireValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -69,8 +69,8 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` - func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue]? { + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` + func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: WireValue]? { guard let value = self[key] else { return nil } @@ -80,7 +80,7 @@ internal extension [String: JSONValue] { } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -89,15 +89,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` - func arrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue] { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` + func arrayValueForKey(_ key: String) throws(InternalError) -> [WireValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -105,8 +105,8 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` - func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue]? { + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` + func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [WireValue]? { guard let value = self[key] else { return nil } @@ -116,7 +116,7 @@ internal extension [String: JSONValue] { } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -125,15 +125,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` func stringValueForKey(_ key: String) throws(InternalError) -> String { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -141,7 +141,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` func optionalStringValueForKey(_ key: String) throws(InternalError) -> String? { guard let value = self[key] else { return nil @@ -152,7 +152,7 @@ internal extension [String: JSONValue] { } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -161,15 +161,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` func numberValueForKey(_ key: String) throws(InternalError) -> NSNumber { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -177,7 +177,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalNumberValueForKey(_ key: String) throws(InternalError) -> NSNumber? { guard let value = self[key] else { return nil @@ -188,7 +188,7 @@ internal extension [String: JSONValue] { } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -197,15 +197,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` func boolValueForKey(_ key: String) throws(InternalError) -> Bool { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return boolValue @@ -213,7 +213,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { guard let value = self[key] else { return nil @@ -224,21 +224,57 @@ internal extension [String: JSONValue] { } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return boolValue } + + /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` + func dataValueForKey(_ key: String) throws(InternalError) -> Data { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .data(dataValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return dataValue + } + + /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` or `null` + func optionalDataValueForKey(_ key: String) throws(InternalError) -> Data? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .data(dataValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return dataValue + } } // MARK: - Extracting dates from a dictionary -internal extension [String: JSONValue] { +internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value @@ -247,7 +283,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalAblyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date? { guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { return nil @@ -262,13 +298,13 @@ internal extension [String: JSONValue] { // MARK: - Extracting RawRepresentable values from a dictionary -internal extension [String: JSONValue] { +internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` - /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` + /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` func rawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { let rawValue = try stringValueForKey(key) @@ -278,8 +314,8 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` - /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` func optionalRawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? where T.RawValue == String { guard let rawValue = try optionalStringValueForKey(key) else { return nil @@ -290,7 +326,7 @@ internal extension [String: JSONValue] { private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { guard let value = T(rawValue: rawValue) else { - throw JSONValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() + throw WireValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() } return value @@ -299,12 +335,12 @@ internal extension [String: JSONValue] { // MARK: - Extracting WireEnum values from a dictionary -internal extension [String: JSONValue] { +internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` func wireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(InternalError) -> WireEnum where Known.RawValue == Int { let rawValue = try numberValueForKey(key).intValue return WireEnum(rawValue: rawValue) @@ -312,7 +348,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalWireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(InternalError) -> WireEnum? where Known.RawValue == Int { guard let rawValue = try optionalNumberValueForKey(key)?.intValue else { return nil @@ -321,26 +357,26 @@ internal extension [String: JSONValue] { } } -// MARK: - Extracting JSONDecodable values from a dictionary +// MARK: - Extracting WireDecodable values from a dictionary -internal extension [String: JSONValue] { - /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(jsonValue:)` initializer. +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - Any error thrown by `T.init(jsonValue:)` - func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - Any error thrown by `T.init(wireValue:)` + func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } - return try T(jsonValue: value) + return try T(wireValue: value) } - /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(jsonValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// - /// - Throws: Any error thrown by `T.init(jsonValue:)` - func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? { + /// - Throws: Any error thrown by `T.init(wireValue:)` + func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? { guard let value = self[key] else { return nil } @@ -349,6 +385,6 @@ internal extension [String: JSONValue] { return nil } - return try T(jsonValue: value) + return try T(wireValue: value) } } diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift new file mode 100644 index 00000000..e2e6f5d8 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -0,0 +1,249 @@ +import Ably +import Foundation + +/// A wire value that can be represents the kinds of data that we expect to find inside a deserialized wire object received from AblyPlugin, or which we may put inside a serialized wire object that we send to AblyPlugin. +/// +/// Its cases are a superset of those of ``JSONValue``, adding a further `data` case for binary data (we expect to be able to send and receive binary data in the case where ably-cocoa is using the MessagePack format). +internal indirect enum WireValue: Sendable, Equatable { + case object([String: WireValue]) + case array([WireValue]) + case string(String) + case number(NSNumber) + case bool(Bool) + case null + case data(Data) + + // MARK: - Convenience getters for associated values + + /// If this `WireValue` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: WireValue]? { + if case let .object(objectValue) = self { + objectValue + } else { + nil + } + } + + /// If this `WireValue` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [WireValue]? { + if case let .array(arrayValue) = self { + arrayValue + } else { + nil + } + } + + /// If this `WireValue` has case `string`, this returns the associated value. Else, it returns `nil`. + internal var stringValue: String? { + if case let .string(stringValue) = self { + stringValue + } else { + nil + } + } + + /// If this `WireValue` has case `number`, this returns the associated value. Else, it returns `nil`. + internal var numberValue: NSNumber? { + if case let .number(numberValue) = self { + numberValue + } else { + nil + } + } + + /// If this `WireValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + internal var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil + } + } + + /// If this `WireValue` has case `data`, this returns the associated value. Else, it returns `nil`. + internal var dataValue: Data? { + if case let .data(dataValue) = self { + dataValue + } else { + nil + } + } + + /// Returns true if and only if this `WireValue` has case `null`. + internal var isNull: Bool { + if case .null = self { + true + } else { + false + } + } +} + +extension WireValue: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, WireValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension WireValue: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: WireValue...) { + self = .array(elements) + } +} + +extension WireValue: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self = .string(value) + } +} + +extension WireValue: ExpressibleByIntegerLiteral { + internal init(integerLiteral value: Int) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByFloatLiteral { + internal init(floatLiteral value: Double) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByBooleanLiteral { + internal init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with ably-cocoa + +internal extension WireValue { + /// Creates a `WireValue` from an AblyPlugin deserialized wire object. + /// + /// Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + init(ablyPluginData: Any) { + // swiftlint:disable:next trailing_closure + let extendedJSONValue = ExtendedJSONValue(deserialized: ablyPluginData, createExtraValue: { deserializedExtraValue in + // We support binary data (used for MessagePack format) in addition to JSON values + if let data = deserializedExtraValue as? Data { + return .data(data) + } + + // ably-cocoa is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("WireValue(ablyPluginData:) was given unsupported value \(deserializedExtraValue)") + }) + + self.init(extendedJSONValue: extendedJSONValue) + } + + /// Creates a `WireValue` from an AblyPlugin deserialized wire object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: WireValue] { + let wireValue = WireValue(ablyPluginData: ablyPluginData) + guard case let .object(wireObject) = wireValue else { + preconditionFailure() + } + + return wireObject + } + + /// Creates an AblyPlugin deserialized wire object from a `WireValue`. + /// + /// Used by `[String: WireValue].toAblyPluginDataDictionary`. + var toAblyPluginData: Any { + // swiftlint:disable:next trailing_closure + toExtendedJSONValue.serialized(serializeExtraValue: { extendedValue in + switch extendedValue { + case let .data(data): + data + } + }) + } +} + +internal extension [String: WireValue] { + /// Creates an AblyPlugin deserialized wire object from a dictionary that has string keys and `WireValue` values. + /// + /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. + var toAblyPluginDataDictionary: [String: Any] { + mapValues(\.toAblyPluginData) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension WireValue { + enum ExtraValue { + case data(Data) + } + + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { + case let .object(underlying): + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) + case let .array(underlying): + self = .array(underlying.map { .init(extendedJSONValue: $0) }) + case let .string(underlying): + self = .string(underlying) + case let .number(underlying): + self = .number(underlying) + case let .bool(underlying): + self = .bool(underlying) + case .null: + self = .null + case let .extra(extra): + switch extra { + case let .data(data): + self = .data(data) + } + } + } + + var toExtendedJSONValue: ExtendedJSONValue { + switch self { + case let .object(underlying): + .object(underlying.mapValues(\.toExtendedJSONValue)) + case let .array(underlying): + .array(underlying.map(\.toExtendedJSONValue)) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .data(data): + .extra(.data(data)) + } + } +} + +// MARK: - Conversion to/from JSONValue + +internal extension WireValue { + /// Converts a `JSONValue` to its corresponding `WireValue`. + init(jsonValue: JSONValue) { + // swiftlint:disable:next array_init + self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map { (extra: Never) in extra }) + } + + enum ConversionError: Error { + case dataCannotBeConvertedToJSONValue + } + + /// Tries to convert this `WireValue` to its corresponding `JSONValue`. + /// + /// - Throws: `ConversionError.dataCannotBeConvertedToJSONValue` if `WireValue` represents binary data. + var toJSONValue: JSONValue { + get throws(InternalError) { + let neverExtended = try toExtendedJSONValue.map { extra throws(InternalError) -> Never in + switch extra { + case .data: + throw ConversionError.dataCannotBeConvertedToJSONValue.toInternalError() + } + } + + return .init(extendedJSONValue: neverExtended) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/Tests/AblyLiveObjectsTests/JSONValueTests.swift index aea08424..02b92d48 100644 --- a/Tests/AblyLiveObjectsTests/JSONValueTests.swift +++ b/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -3,33 +3,33 @@ import Foundation import Testing struct JSONValueTests { - // MARK: Conversion from AblyPlugin data + // MARK: Conversion from JSONSerialization output @Test(arguments: [ // object - (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + (jsonSerializationOutput: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), // array - (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), + (jsonSerializationOutput: ["someElement"], expectedResult: ["someElement"]), // string - (ablyPluginData: "someString", expectedResult: "someString"), + (jsonSerializationOutput: "someString", expectedResult: "someString"), // number - (ablyPluginData: NSNumber(value: 0), expectedResult: 0), - (ablyPluginData: NSNumber(value: 1), expectedResult: 1), - (ablyPluginData: NSNumber(value: 123), expectedResult: 123), - (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), + (jsonSerializationOutput: NSNumber(value: 0), expectedResult: 0), + (jsonSerializationOutput: NSNumber(value: 1), expectedResult: 1), + (jsonSerializationOutput: NSNumber(value: 123), expectedResult: 123), + (jsonSerializationOutput: NSNumber(value: 123.456), expectedResult: 123.456), // bool - (ablyPluginData: NSNumber(value: true), expectedResult: true), - (ablyPluginData: NSNumber(value: false), expectedResult: false), + (jsonSerializationOutput: NSNumber(value: true), expectedResult: true), + (jsonSerializationOutput: NSNumber(value: false), expectedResult: false), // null - (ablyPluginData: NSNull(), expectedResult: .null), - ] as[(ablyPluginData: Sendable, expectedResult: JSONValue?)]) - func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: JSONValue?) { - #expect(JSONValue(ablyPluginData: ablyPluginData) == expectedResult) + (jsonSerializationOutput: NSNull(), expectedResult: .null), + ] as[(jsonSerializationOutput: Sendable, expectedResult: JSONValue?)]) + func initWithJSONSerializationOutput(jsonSerializationOutput: Sendable, expectedResult: JSONValue?) { + #expect(JSONValue(jsonSerializationOutput: jsonSerializationOutput) == expectedResult) } - // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for deserialization). + // Tests that it correctly handles an object deserialized by `JSONSerialization`. @Test - func initWithAblyPluginData_endToEnd() throws { + func initWithJSONSerializationOutput_endToEnd() throws { let jsonString = """ { "someArray": [ @@ -51,7 +51,7 @@ struct JSONValueTests { } """ - let ablyPluginData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + let jsonSerializationOutput = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) let expected: JSONValue = [ "someArray": [ @@ -72,10 +72,10 @@ struct JSONValueTests { ], ] - #expect(JSONValue(ablyPluginData: ablyPluginData) == expected) + #expect(JSONValue(jsonSerializationOutput: jsonSerializationOutput) == expected) } - // MARK: Conversion to AblyPlugin data + // MARK: Conversion to JSONSerialization input @Test(arguments: [ // object @@ -95,16 +95,16 @@ struct JSONValueTests { // null (value: .null, expectedResult: NSNull()), ] as[(value: JSONValue, expectedResult: Sendable)]) - func toAblyPluginData(value: JSONValue, expectedResult: Sendable) throws { - let resultAsNSObject = try #require(value.toAblyPluginData as? NSObject) + func toJSONSerializationInput(value: JSONValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toJSONSerializationInputElement as? NSObject) let expectedResultAsNSObject = try #require(expectedResult as? NSObject) #expect(resultAsNSObject == expectedResultAsNSObject) } - // Tests that it creates an object that can be serialized by `JSONSerialization` (which is what ably-cocoa uses for serialization), and that the result of this serialization is what we’d expect. + // Tests that it creates an object that can be serialized by `JSONSerialization`, and that the result of this serialization is what we’d expect. @Test - func toAblyPluginData_endToEnd() throws { - let value: JSONValue = [ + func toJSONSerializationInput_endToEnd() throws { + let value: [String: JSONValue] = [ "someArray": [ [ "someStringKey": "someString", @@ -146,7 +146,7 @@ struct JSONValueTests { let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] - let valueData = try JSONSerialization.data(withJSONObject: value.toAblyPluginData, options: jsonSerializationOptions) + let valueData = try JSONSerialization.data(withJSONObject: value.toJSONSerializationInput, options: jsonSerializationOptions) let expectedData = try { let serialized = try JSONSerialization.jsonObject(with: #require(expectedJSONString.data(using: .utf8))) return try JSONSerialization.data(withJSONObject: serialized, options: jsonSerializationOptions) diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 5a1a6880..49dbefd5 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -22,7 +22,7 @@ enum WireObjectMessageTests { @Test func decodesAllFields() throws { let timestamp = Date(timeIntervalSince1970: 1_234_567_890) - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "id": "id1", "clientId": "client1", "connectionId": "conn1", @@ -34,7 +34,7 @@ enum WireObjectMessageTests { "siteCode": "siteA", ] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == "id1") #expect(msg.clientId == "client1") #expect(msg.connectionId == "conn1") @@ -48,9 +48,9 @@ enum WireObjectMessageTests { @Test func optionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == nil) #expect(msg.clientId == nil) #expect(msg.connectionId == nil) @@ -65,36 +65,36 @@ enum WireObjectMessageTests { // @specOneOf(1/2) OM2a @Test func idFromParent_whenPresentInParent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: "parent1", parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == "parent1:2") } // @specOneOf(2/2) OM2a @Test func idFromParent_whenAbsentInParent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == nil) } // @spec OM2c @Test(arguments: [nil, "parentConn1"]) func connectionIdFromParent(parentValue: String?) throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: parentValue, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.connectionId == parentValue) } // @spec OM2e @Test(arguments: [nil, Date(timeIntervalSince1970: 1_234_567_890)]) func timestampFromParent(parentValue: Date?) throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: parentValue, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.timestamp == parentValue) } } @@ -124,8 +124,8 @@ enum WireObjectMessageTests { serial: "s1", siteCode: "siteA", ) - let json = msg.toJSONObject - #expect(json == [ + let wire = msg.toWireObject + #expect(wire == [ "id": "id1", "clientId": "client1", "connectionId": "conn1", @@ -151,8 +151,8 @@ enum WireObjectMessageTests { serial: nil, siteCode: nil, ) - let json = msg.toJSONObject - #expect(json == [ + let wire = msg.toWireObject + #expect(wire == [ "id": "id1", "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), ]) @@ -162,7 +162,7 @@ enum WireObjectMessageTests { struct WireObjectOperationTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 0, // mapCreate "objectId": "obj1", "mapOp": ["key": "key1", "data": ["string": "value1"]], @@ -172,7 +172,7 @@ enum WireObjectMessageTests { "nonce": "nonce1", "initialValueEncoding": "utf8", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") #expect(op.mapOp?.key == "key1") @@ -188,11 +188,11 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 0, "objectId": "obj1", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") #expect(op.mapOp == nil) @@ -206,11 +206,11 @@ enum WireObjectMessageTests { @Test func decodesWithUnknownAction() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 999, // Unknown WireObjectOperation "objectId": "obj1", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .unknown(999)) } @@ -230,8 +230,8 @@ enum WireObjectMessageTests { initialValue: nil, initialValueEncoding: "utf8", ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "action": 0, "objectId": "obj1", "mapOp": ["key": "key1", "data": ["string": "value1"]], @@ -256,8 +256,8 @@ enum WireObjectMessageTests { initialValue: nil, initialValueEncoding: nil, ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "action": 0, "objectId": "obj1", ]) @@ -267,7 +267,7 @@ enum WireObjectMessageTests { struct WireObjectStateTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "objectId": "obj1", "siteTimeserials": ["site1": "ts1"], "tombstone": true, @@ -275,7 +275,7 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], ] - let state = try WireObjectState(jsonObject: json) + let state = try WireObjectState(wireObject: wire) #expect(state.objectId == "obj1") #expect(state.siteTimeserials["site1"] == "ts1") #expect(state.tombstone == true) @@ -289,12 +289,12 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "objectId": "obj1", "siteTimeserials": [:], "tombstone": false, ] - let state = try WireObjectState(jsonObject: json) + let state = try WireObjectState(wireObject: wire) #expect(state.objectId == "obj1") #expect(state.siteTimeserials.isEmpty) #expect(state.tombstone == false) @@ -326,8 +326,8 @@ enum WireObjectMessageTests { ), counter: WireCounter(count: 42), ) - let json = state.toJSONObject - #expect(json == [ + let wire = state.toWireObject + #expect(wire == [ "objectId": "obj1", "siteTimeserials": ["site1": "ts1"], "tombstone": true, @@ -347,8 +347,8 @@ enum WireObjectMessageTests { map: nil, counter: nil, ) - let json = state.toJSONObject - #expect(json == [ + let wire = state.toWireObject + #expect(wire == [ "objectId": "obj1", "siteTimeserials": [:], "tombstone": false, @@ -359,14 +359,14 @@ enum WireObjectMessageTests { struct WireObjectDataTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "objectId": "obj1", "encoding": "utf8", "boolean": true, "number": 42, "string": "value1", ] - let data = try WireObjectData(jsonObject: json) + let data = try WireObjectData(wireObject: json) #expect(data.objectId == "obj1") #expect(data.encoding == "utf8") #expect(data.boolean == true) @@ -376,8 +376,8 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] - let data = try WireObjectData(jsonObject: json) + let json: [String: WireValue] = [:] + let data = try WireObjectData(wireObject: json) #expect(data.objectId == nil) #expect(data.encoding == nil) #expect(data.boolean == nil) @@ -396,8 +396,8 @@ enum WireObjectMessageTests { number: 42, string: "value1", ) - let json = data.toJSONObject - #expect(json == [ + let wire = data.toWireObject + #expect(wire == [ "objectId": "obj1", "encoding": "utf8", "boolean": true, @@ -416,27 +416,27 @@ enum WireObjectMessageTests { number: nil, string: nil, ) - let json = data.toJSONObject - #expect(json.isEmpty) + let wire = data.toWireObject + #expect(wire.isEmpty) } } struct WireMapOpTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "key": "key1", "data": ["string": "value1"], ] - let op = try WireMapOp(jsonObject: json) + let op = try WireMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data?.string == "value1") } @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["key": "key1"] - let op = try WireMapOp(jsonObject: json) + let json: [String: WireValue] = ["key": "key1"] + let op = try WireMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data == nil) } @@ -447,8 +447,8 @@ enum WireObjectMessageTests { key: "key1", data: WireObjectData(string: "value1"), ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "key": "key1", "data": ["string": "value1"], ]) @@ -460,8 +460,8 @@ enum WireObjectMessageTests { key: "key1", data: nil, ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "key": "key1", ]) } @@ -470,30 +470,30 @@ enum WireObjectMessageTests { struct WireCounterOpTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = ["amount": 42] - let op = try WireCounterOp(jsonObject: json) + let json: [String: WireValue] = ["amount": 42] + let op = try WireCounterOp(wireObject: json) #expect(op.amount == 42) } @Test func encodesAllFields() { let op = WireCounterOp(amount: 42) - let json = op.toJSONObject - #expect(json == ["amount": 42]) + let wire = op.toWireObject + #expect(wire == ["amount": 42]) } } struct WireMapTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "semantics": 0, "entries": [ "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], "key2": ["data": ["string": "value2"], "tombstone": true], ], ] - let map = try WireMap(jsonObject: json) + let map = try WireMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries?["key1"]?.data.string == "value1") #expect(map.entries?["key1"]?.tombstone == false) @@ -505,18 +505,18 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["semantics": 0] - let map = try WireMap(jsonObject: json) + let json: [String: WireValue] = ["semantics": 0] + let map = try WireMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries == nil) } @Test func decodesWithUnknownSemantics() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "semantics": 999, // Unknown MapSemantics ] - let map = try WireMap(jsonObject: json) + let map = try WireMap(wireObject: json) #expect(map.semantics == .unknown(999)) } @@ -529,8 +529,8 @@ enum WireObjectMessageTests { "key2": WireMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), ], ) - let json = map.toJSONObject - #expect(json == [ + let wire = map.toWireObject + #expect(wire == [ "semantics": 0, "entries": [ "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], @@ -545,8 +545,8 @@ enum WireObjectMessageTests { semantics: .known(.lww), entries: nil, ) - let json = map.toJSONObject - #expect(json == [ + let wire = map.toWireObject + #expect(wire == [ "semantics": 0, ]) } @@ -555,42 +555,42 @@ enum WireObjectMessageTests { struct WireCounterTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = ["count": 42] - let counter = try WireCounter(jsonObject: json) + let json: [String: WireValue] = ["count": 42] + let counter = try WireCounter(wireObject: json) #expect(counter.count == 42) } @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] - let counter = try WireCounter(jsonObject: json) + let json: [String: WireValue] = [:] + let counter = try WireCounter(wireObject: json) #expect(counter.count == nil) } @Test func encodesAllFields() { let counter = WireCounter(count: 42) - let json = counter.toJSONObject - #expect(json == ["count": 42]) + let wire = counter.toWireObject + #expect(wire == ["count": 42]) } @Test func encodesWithOptionalFieldsNil() { let counter = WireCounter(count: nil) - let json = counter.toJSONObject - #expect(json.isEmpty) + let wire = counter.toWireObject + #expect(wire.isEmpty) } } struct WireMapEntryTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "data": ["string": "value1"], "tombstone": true, "timeserial": "ts1", ] - let entry = try WireMapEntry(jsonObject: json) + let entry = try WireMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == true) #expect(entry.timeserial == "ts1") @@ -598,8 +598,8 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["data": ["string": "value1"]] - let entry = try WireMapEntry(jsonObject: json) + let json: [String: WireValue] = ["data": ["string": "value1"]] + let entry = try WireMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == nil) #expect(entry.timeserial == nil) @@ -612,8 +612,8 @@ enum WireObjectMessageTests { timeserial: "ts1", data: WireObjectData(string: "value1"), ) - let json = entry.toJSONObject - #expect(json == [ + let wire = entry.toWireObject + #expect(wire == [ "data": ["string": "value1"], "tombstone": true, "timeserial": "ts1", @@ -627,8 +627,8 @@ enum WireObjectMessageTests { timeserial: nil, data: WireObjectData(string: "value1"), ) - let json = entry.toJSONObject - #expect(json == [ + let wire = entry.toWireObject + #expect(wire == [ "data": ["string": "value1"], ]) } diff --git a/Tests/AblyLiveObjectsTests/WireValueTests.swift b/Tests/AblyLiveObjectsTests/WireValueTests.swift new file mode 100644 index 00000000..91e557e0 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/WireValueTests.swift @@ -0,0 +1,379 @@ +import Ably.Private +@testable import AblyLiveObjects +import Foundation +import Testing + +struct WireValueTests { + // MARK: Conversion from AblyPlugin data + + @Test(arguments: [ + // object + (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), + // string + (ablyPluginData: "someString", expectedResult: "someString"), + // number + (ablyPluginData: NSNumber(value: 0), expectedResult: 0), + (ablyPluginData: NSNumber(value: 1), expectedResult: 1), + (ablyPluginData: NSNumber(value: 123), expectedResult: 123), + (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), + // bool + (ablyPluginData: NSNumber(value: true), expectedResult: true), + (ablyPluginData: NSNumber(value: false), expectedResult: false), + // null + (ablyPluginData: NSNull(), expectedResult: .null), + // data + (ablyPluginData: Data([0x01, 0x02, 0x03]), expectedResult: .data(Data([0x01, 0x02, 0x03]))), + ] as[(ablyPluginData: Sendable, expectedResult: WireValue?)]) + func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: WireValue?) { + #expect(WireValue(ablyPluginData: ablyPluginData) == expectedResult) + } + + // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for JSON deserialization). + @Test + func initWithAblyPluginData_endToEnd_json() throws { + let jsonString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let ablyPluginData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + + let expected: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + #expect(WireValue(ablyPluginData: ablyPluginData) == expected) + } + + // Tests that it correctly handles an object deserialized by `ARTMsgPackEncoder` (which is what ably-cocoa uses for MessagePack deserialization). + @Test + func initWithAblyPluginData_endToEnd_msgpack() throws { + // MessagePack representation of the same data structure as in the JSON test above, plus binary data + // This represents: + // { + // "someArray": [ + // { + // "someStringKey": "someString", + // "someIntegerKey": 123, + // "zero": 0, + // "someFloatKey": 123.456, + // "someTrueKey": true, + // "someFalseKey": false, + // "someNullKey": null, + // "one": 1, + // "someBinaryKey": + // }, + // "someOtherArrayElement" + // ], + // "someNestedObject": { + // "someOtherKey": "someOtherValue" + // } + // } + let msgpackData = Data([ + // Root object - 2 elements map (fixmap format: 0x80 | count) + 0x82, + + // Key 1: "someArray" (fixstr format: 0xa0 | length = 9) + 0xA9, 0x73, 0x6F, 0x6D, 0x65, 0x41, 0x72, 0x72, 0x61, 0x79, // "someArray" + + // Value 1: Array with 2 elements (fixarray format: 0x90 | count) + 0x92, + + // Array element 1: Object with 9 elements (fixmap format: 0x80 | count) + 0x89, + + // Key-value pairs in map (order determined by MessagePack encoder): + + // "someStringKey": "someString" + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x4B, 0x65, 0x79, // key (13 chars) + 0xAA, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, // value (10 chars) + + // "someIntegerKey": 123 + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x49, 0x6E, 0x74, 0x65, 0x67, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (14 chars) + 0x7B, // value 123 (positive fixint) + + // "zero": 0 + 0xA4, 0x7A, 0x65, 0x72, 0x6F, // key "zero" (4 chars) + 0x00, // value 0 (positive fixint) + + // "someFloatKey": 123.456 + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x6C, 0x6F, 0x61, 0x74, 0x4B, 0x65, 0x79, // key (12 chars) + 0xCB, 0x40, 0x5E, 0xDD, 0x2F, 0x1A, 0x9F, 0xBE, 0x77, // value 123.456 (float64) + + // "someTrueKey": true + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x54, 0x72, 0x75, 0x65, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC3, // value true + + // "someFalseKey": false + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x61, 0x6C, 0x73, 0x65, 0x4B, 0x65, 0x79, // key (12 chars) + 0xC2, // value false + + // "someNullKey": null + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x75, 0x6C, 0x6C, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC0, // value null + + // "one": 1 + 0xA3, 0x6F, 0x6E, 0x65, // key "one" (3 chars) + 0x01, // value 1 (positive fixint) + + // "someBinaryKey": binary data + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x42, 0x69, 0x6E, 0x61, 0x72, 0x79, 0x4B, 0x65, 0x79, // key "someBinaryKey" (13 chars) + 0xC4, 0x04, 0x01, 0x02, 0x03, 0x04, // value: bin 8 format (0xc4 + 1 byte length + 4 bytes data) + + // Array element 2: "someOtherArrayElement" + 0xB5, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x41, 0x72, 0x72, 0x61, 0x79, 0x45, 0x6C, 0x65, 0x6D, 0x65, 0x6E, 0x74, // "someOtherArrayElement" (21 chars) + + // Key 2: "someNestedObject" + 0xB0, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4F, 0x62, 0x6A, 0x65, 0x63, 0x74, // "someNestedObject" (16 chars) + + // Value 2: Object with 1 element (fixmap format: 0x80 | count) + 0x81, + + // "someOtherKey": "someOtherValue" + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (12 chars) + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) + ]) + + let ablyPluginData = try ARTMsgPackEncoder().decode(msgpackData) + + let expected: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + "someBinaryKey": .data(Data([0x01, 0x02, 0x03, 0x04])), + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + #expect(WireValue(ablyPluginData: ablyPluginData) == expected) + } + + // MARK: Conversion to AblyPlugin data + + @Test(arguments: [ + // object + (value: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (value: ["someElement"], expectedResult: ["someElement"]), + // string + (value: "someString", expectedResult: "someString"), + // number + (value: 0, expectedResult: NSNumber(value: 0)), + (value: 1, expectedResult: NSNumber(value: 1)), + (value: 123, expectedResult: NSNumber(value: 123)), + (value: 123.456, expectedResult: NSNumber(value: 123.456)), + // bool + (value: true, expectedResult: NSNumber(value: true)), + (value: false, expectedResult: NSNumber(value: false)), + // null + (value: .null, expectedResult: NSNull()), + // data + (value: .data(Data([0x01, 0x02, 0x03])), expectedResult: Data([0x01, 0x02, 0x03])), + ] as[(value: WireValue, expectedResult: Sendable)]) + func toAblyPluginData(value: WireValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toAblyPluginData as? NSObject) + let expectedResultAsNSObject = try #require(expectedResult as? NSObject) + #expect(resultAsNSObject == expectedResultAsNSObject) + } + + // Tests that it creates an object that can be serialized by `JSONSerialization` (which is what ably-cocoa uses for JSON serialization), and that the result of this serialization is what we’d expect. + @Test + func toAblyPluginData_endToEnd_json() throws { + let value: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + let expectedJSONString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "someIntegerKey": 123, + "someFloatKey": 123.456, + "zero": 0, + "one": 1, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] + + let valueData = try JSONSerialization.data(withJSONObject: value.toAblyPluginData, options: jsonSerializationOptions) + let expectedData = try { + let serialized = try JSONSerialization.jsonObject(with: #require(expectedJSONString.data(using: .utf8))) + return try JSONSerialization.data(withJSONObject: serialized, options: jsonSerializationOptions) + }() + + #expect(valueData == expectedData) + } + + // Tests that it creates an object that can be serialized by `ARTMsgPackEncoder` (which is what ably-cocoa uses for MessagePack serialization), and that the result of this serialization is what we’d expect. + @Test + func toAblyPluginData_endToEnd_msgpack() throws { + let value: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + "someBinaryKey": .data(Data([0x01, 0x02, 0x03, 0x04])), + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + // Expected MessagePack data - manually crafted representation of the WireValue structure including binary data + // Note: The exact byte order may vary depending on how the encoder orders map keys, + // so we'll verify by decoding both and comparing the results + let expectedMsgPackData = Data([ + // Root object - 2 elements map (fixmap format: 0x80 | count) + 0x82, + + // Key 1: "someArray" (fixstr format: 0xa0 | length = 9) + 0xA9, 0x73, 0x6F, 0x6D, 0x65, 0x41, 0x72, 0x72, 0x61, 0x79, // "someArray" + + // Value 1: Array with 2 elements (fixarray format: 0x90 | count) + 0x92, + + // Array element 1: Object with 9 elements (fixmap format: 0x80 | count) + 0x89, + + // Key-value pairs in map (order determined by MessagePack encoder): + + // "someStringKey": "someString" + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x4B, 0x65, 0x79, // key (13 chars) + 0xAA, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, // value (10 chars) + + // "someIntegerKey": 123 + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x49, 0x6E, 0x74, 0x65, 0x67, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (14 chars) + 0x7B, // value 123 (positive fixint) + + // "zero": 0 + 0xA4, 0x7A, 0x65, 0x72, 0x6F, // key "zero" (4 chars) + 0x00, // value 0 (positive fixint) + + // "someFloatKey": 123.456 + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x6C, 0x6F, 0x61, 0x74, 0x4B, 0x65, 0x79, // key (12 chars) + 0xCB, 0x40, 0x5E, 0xDD, 0x2F, 0x1A, 0x9F, 0xBE, 0x77, // value 123.456 (float64) + + // "someTrueKey": true + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x54, 0x72, 0x75, 0x65, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC3, // value true + + // "someFalseKey": false + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x61, 0x6C, 0x73, 0x65, 0x4B, 0x65, 0x79, // key (12 chars) + 0xC2, // value false + + // "someNullKey": null + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x75, 0x6C, 0x6C, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC0, // value null + + // "one": 1 + 0xA3, 0x6F, 0x6E, 0x65, // key "one" (3 chars) + 0x01, // value 1 (positive fixint) + + // "someBinaryKey": binary data + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x42, 0x69, 0x6E, 0x61, 0x72, 0x79, 0x4B, 0x65, 0x79, // key "someBinaryKey" (13 chars) + 0xC4, 0x04, 0x01, 0x02, 0x03, 0x04, // value: bin 8 format (0xc4 + 1 byte length + 4 bytes data) + + // Array element 2: "someOtherArrayElement" + 0xB5, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x41, 0x72, 0x72, 0x61, 0x79, 0x45, 0x6C, 0x65, 0x6D, 0x65, 0x6E, 0x74, // "someOtherArrayElement" (21 chars) + + // Key 2: "someNestedObject" + 0xB0, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4F, 0x62, 0x6A, 0x65, 0x63, 0x74, // "someNestedObject" (16 chars) + + // Value 2: Object with 1 element (fixmap format: 0x80 | count) + 0x81, + + // "someOtherKey": "someOtherValue" + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (12 chars) + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) + ]) + + let actualMsgPackData = try ARTMsgPackEncoder().encode(value.toAblyPluginData) + + // Verify that both decode to the same Foundation object structure + let expectedDecoded = try ARTMsgPackEncoder().decode(expectedMsgPackData) + let actualDecoded = try ARTMsgPackEncoder().decode(actualMsgPackData) + + let expectedDecodedAsNSObject = try #require(expectedDecoded as? NSObject) + let actualDecodedAsNSObject = try #require(actualDecoded as? NSObject) + + #expect(actualDecodedAsNSObject == expectedDecodedAsNSObject) + } +} From a1961279eb5c1c1053219f69356f50bd78b9239c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 15:33:02 -0300 Subject: [PATCH 7/9] Implement spec points that say not to access certain fields Based on [1] at 2e975cb. [1] https://github.com/ably/specification/pull/335 --- Sources/AblyLiveObjects/Protocol/ObjectMessage.swift | 10 +++++++--- .../AblyLiveObjects/Protocol/WireObjectMessage.swift | 11 ++++++++--- .../WireObjectMessageTests.swift | 12 ++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 153bfc5e..d5bd3671 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -117,9 +117,13 @@ internal extension ObjectOperation { counterOp = wireObjectOperation.counterOp map = wireObjectOperation.map.map { .init(wireMap: $0) } counter = wireObjectOperation.counter - nonce = wireObjectOperation.nonce - initialValue = wireObjectOperation.initialValue - initialValueEncoding = wireObjectOperation.initialValueEncoding + + // Do not access on inbound data, per OOP3g + nonce = nil + // Do not access on inbound data, per OOP3h + initialValue = nil + // Do not access on inbound data, per OOP3i + initialValueEncoding = nil } /// Converts this `ObjectOperation` to a `WireObjectOperation`. diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 43d138a0..b53228f6 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -158,7 +158,7 @@ internal struct WireObjectOperation { internal var map: WireMap? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + // TODO: Not yet clear how to encode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var initialValue: Data? // OOP3h internal var initialValueEncoding: String? // OOP3i } @@ -183,8 +183,13 @@ extension WireObjectOperation: WireObjectCodable { counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) - nonce = try wireObject.optionalStringValueForKey(WireKey.nonce.rawValue) - initialValueEncoding = try wireObject.optionalStringValueForKey(WireKey.initialValueEncoding.rawValue) + + // Do not access on inbound data, per OOP3g + nonce = nil + // Do not access on inbound data, per OOP3h + initialValue = nil + // Do not access on inbound data, per OOP3i + initialValueEncoding = nil } internal var toWireObject: [String: WireValue] { diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 49dbefd5..bf208168 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -160,6 +160,9 @@ enum WireObjectMessageTests { } struct WireObjectOperationTests { + // @spec OOP3g + // @spec OOP3h + // @spec OOP3i @Test func decodesAllFields() throws { let wire: [String: WireValue] = [ @@ -182,8 +185,13 @@ enum WireObjectMessageTests { #expect(op.map?.entries?["key1"]?.data.string == "value1") #expect(op.map?.entries?["key1"]?.tombstone == false) #expect(op.counter?.count == 42) - #expect(op.nonce == "nonce1") - #expect(op.initialValueEncoding == "utf8") + + // Per OOP3g we should not try and extract this + #expect(op.nonce == nil) + // Per OOP3h we should not try and extract this + #expect(op.initialValueEncoding == nil) + // Per OOP3i we should not try and extract this + #expect(op.initialValue == nil) } @Test From c05f1f121160171a178a9b71c84fb332e78caecf Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 18:01:12 -0300 Subject: [PATCH 8/9] Add methods for (de)serializing JSONValue (from) to string Preparation for decoding and encoding JSON-valued ObjectData. We also introduce a type that represents a JSON value or array; as well as representing JSONSerialization's supported top-level objects, we'll also shortly use it to represent the kind of JSON data that you can put in ObjectData. --- .../AblyLiveObjects/Utility/JSONValue.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 839cd54e..d03b86e5 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -156,6 +156,41 @@ internal extension JSONValue { } } +// MARK: - JSON objects and arrays + +/// A subset of ``JSONValue`` that has only `object` or `array` cases. +internal enum JSONObjectOrArray: Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + + internal enum ConversionError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + internal init(jsonValue: JSONValue) throws(InternalError) { + self = switch jsonValue { + case let .array(array): + .array(array) + case let .object(object): + .object(object) + case .bool, .number, .string, .null: + throw ConversionError.incompatibleJSONValue(jsonValue).toInternalError() + } + } +} + +extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSONObjectOrArray: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + internal extension [String: JSONValue] { /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. var toJSONSerializationInput: [String: Any] { @@ -207,3 +242,51 @@ internal extension JSONValue { } } } + +// MARK: Serializing to and deserializing from a JSON string + +internal extension JSONObjectOrArray { + enum DecodingError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + /// Deserializes a JSON string into a `JSONObjectOrArray`. Throws an error if not given a valid JSON string. + init(jsonString: String) throws(InternalError) { + let data = Data(jsonString.utf8) + let jsonSerializationOutput: Any + do { + jsonSerializationOutput = try JSONSerialization.jsonObject(with: data) + } catch { + throw error.toInternalError() + } + + let jsonValue = JSONValue(jsonSerializationOutput: jsonSerializationOutput) + try self.init(jsonValue: jsonValue) + } + + /// Converts a `JSONObjectOrArray` into an input for Foundation's `JSONSerialization`. + private var toJSONSerializationInput: Any { + switch self { + case let .array(array): + array.toJSONSerializationInput + case let .object(object): + object.toJSONSerializationInput + } + } + + /// Serializes a `JSONObjectOrArray` to a JSON string. + var toJSONString: String { + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: toJSONSerializationInput) + } catch { + preconditionFailure("Unexpected error encoding to JSON: \(error)") + } + + guard let string = String(data: data, encoding: .utf8) else { + preconditionFailure("Unexpected failure to decode output of JSONSerialization as UTF-8") + } + + return string + } +} From 2715aebbd5700f8fc7d7b57fc8c4783dd8c09d9d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 11:32:21 -0300 Subject: [PATCH 9/9] Encode and decode data of WireObject Based on [1] at 2e975cb. This was implemented by updating the code to reflect the internal API that I wanted to exist, and then asking Cursor to implement the rules of the spec and to write tests. I then edited the generated code to simplify it a bit and add things like @spec annotations and other explanatory comments. I wanted some round-trip tests that go through the Realtime backend but decided to leave them until later once I have a bit more knowledge of the LiveObjects protocol; have created #17. [1] https://github.com/ably/specification/pull/335 --- .../Internal/DefaultInternalPlugin.swift | 9 +- .../Protocol/ObjectMessage.swift | 319 ++++++++-- .../Protocol/WireObjectMessage.swift | 49 +- .../Utility/Data+Extensions.swift | 19 + Tests/AblyLiveObjectsTests/.swiftformat | 2 + Tests/AblyLiveObjectsTests/.swiftlint.yml | 3 + .../ObjectMessageTests.swift | 568 ++++++++++++++++++ 7 files changed, 902 insertions(+), 67 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/Data+Extensions.swift create mode 100644 Tests/AblyLiveObjectsTests/.swiftformat create mode 100644 Tests/AblyLiveObjectsTests/.swiftlint.yml create mode 100644 Tests/AblyLiveObjectsTests/ObjectMessageTests.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index e9eeb8dc..21b59fa1 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -60,7 +60,6 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal func decodeObjectMessage( _ serialized: [String: Any], context: DecodingContextProtocol, - // TODO: use format: EncodingFormat, error errorPtr: AutoreleasingUnsafeMutablePointer?, ) -> (any ObjectMessageProtocol)? { @@ -71,7 +70,10 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte wireObject: wireObject, decodingContext: context, ) - let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) + let objectMessage = try InboundObjectMessage( + wireObjectMessage: wireObjectMessage, + format: format, + ) return ObjectMessageBox(objectMessage: objectMessage) } catch { errorPtr?.pointee = error.toARTErrorInfo() @@ -81,14 +83,13 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal func encodeObjectMessage( _ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol, - // TODO: use format: EncodingFormat, ) -> [String: Any] { guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } - let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire() + let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire(format: format) return wireObjectMessage.toWireObject.toAblyPluginDataDictionary } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index d5bd3671..a98dc049 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -1,3 +1,4 @@ +internal import AblyPlugin import Foundation // This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. @@ -36,19 +37,23 @@ internal struct ObjectOperation { internal var map: Map? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var initialValue: Data? // OOP3h internal var initialValueEncoding: String? // OOP3i } internal struct ObjectData { + /// The values that the `string` property might hold, before being encoded per OD4 or after being decoded per OD5. + internal enum StringPropertyContent { + case string(String) + case json(JSONObjectOrArray) + } + internal var objectId: String? // OD2a internal var encoding: String? // OD2b internal var boolean: Bool? // OD2c - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var bytes: Data? // OD2d internal var number: NSNumber? // OD2e - internal var string: String? // OD2f + internal var string: StringPropertyContent? // OD2f } internal struct MapOp { @@ -77,31 +82,45 @@ internal struct ObjectState { } internal extension InboundObjectMessage { - /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`. - init(wireObjectMessage: InboundWireObjectMessage) { + /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectMessage: InboundWireObjectMessage, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { id = wireObjectMessage.id clientId = wireObjectMessage.clientId connectionId = wireObjectMessage.connectionId extras = wireObjectMessage.extras timestamp = wireObjectMessage.timestamp - operation = wireObjectMessage.operation.map { .init(wireObjectOperation: $0) } - object = wireObjectMessage.object.map { .init(wireObjectState: $0) } + operation = try wireObjectMessage.operation.map { wireObjectOperation throws(InternalError) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + object = try wireObjectMessage.object.map { wireObjectState throws(InternalError) in + try .init(wireObjectState: wireObjectState, format: format) + } serial = wireObjectMessage.serial siteCode = wireObjectMessage.siteCode } } internal extension OutboundObjectMessage { - /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`. - func toWire() -> OutboundWireObjectMessage { + /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> OutboundWireObjectMessage { .init( id: id, clientId: clientId, connectionId: connectionId, extras: extras, timestamp: timestamp, - operation: operation?.toWire(), - object: object?.toWire(), + operation: operation?.toWire(format: format), + object: object?.toWire(format: format), serial: serial, siteCode: siteCode, ) @@ -109,13 +128,24 @@ internal extension OutboundObjectMessage { } internal extension ObjectOperation { - /// Initializes an `ObjectOperation` from a `WireObjectOperation`. - init(wireObjectOperation: WireObjectOperation) { + /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectOperation: WireObjectOperation, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { action = wireObjectOperation.action objectId = wireObjectOperation.objectId - mapOp = wireObjectOperation.mapOp.map { .init(wireMapOp: $0) } + mapOp = try wireObjectOperation.mapOp.map { wireMapOp throws(InternalError) in + try .init(wireMapOp: wireMapOp, format: format) + } counterOp = wireObjectOperation.counterOp - map = wireObjectOperation.map.map { .init(wireMap: $0) } + map = try wireObjectOperation.map.map { wireMap throws(InternalError) in + try .init(wireMap: wireMap, format: format) + } counter = wireObjectOperation.counter // Do not access on inbound data, per OOP3g @@ -126,115 +156,286 @@ internal extension ObjectOperation { initialValueEncoding = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`. - func toWire() -> WireObjectOperation { - .init( + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4 and OOP5. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4 and OOP5. + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + // OOP5: Encode initialValue based on format + let wireInitialValue: StringOrData? + let wireInitialValueEncoding: String? + + if let initialValue { + switch format { + case .messagePack: + // OOP5a: When the MessagePack protocol is used + // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type + wireInitialValue = .data(initialValue) + // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack + wireInitialValueEncoding = "msgpack" + + case .json: + // OOP5b: When the JSON protocol is used + // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string + wireInitialValue = .string(initialValue.base64EncodedString()) + // OOP5b2: Set ObjectOperation.initialValueEncoding to json + wireInitialValueEncoding = "json" + } + } else { + wireInitialValue = nil + wireInitialValueEncoding = nil + } + + return .init( action: action, objectId: objectId, - mapOp: mapOp?.toWire(), + mapOp: mapOp?.toWire(format: format), counterOp: counterOp, - map: map?.toWire(), + map: map?.toWire(format: format), counter: counter, nonce: nonce, - initialValue: initialValue, - initialValueEncoding: initialValueEncoding, + initialValue: wireInitialValue, + initialValueEncoding: wireInitialValueEncoding, ) } } internal extension ObjectData { - /// Initializes an `ObjectData` from a `WireObjectData`. - init(wireObjectData: WireObjectData) { + /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectData: WireObjectData, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { objectId = wireObjectData.objectId encoding = wireObjectData.encoding boolean = wireObjectData.boolean - bytes = wireObjectData.bytes number = wireObjectData.number - string = wireObjectData.string + + // OD5: Decode data based on format + switch format { + case .messagePack: + // OD5a: When the MessagePack protocol is used + // OD5a1: The payloads in (…) ObjectData.bytes (…) are decoded as their corresponding MessagePack types + if let wireBytes = wireObjectData.bytes { + switch wireBytes { + case let .data(data): + bytes = data + case .string: + // Not very clear what we're meant to do if `bytes` contains a string; let's ignore it. I think it's a bit moot - shouldn't happen. The only reason I'm considering it here is because of our slightly weird WireObjectData.bytes type which is typed as a string or data; might be good to at some point figure out how to rule out the string case earlier when using MessagePack, but it's not a big issue + bytes = nil + } + } else { + bytes = nil + } + case .json: + // OD5b: When the JSON protocol is used + // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value + if let wireBytes = wireObjectData.bytes { + switch wireBytes { + case let .string(base64String): + bytes = try Data.fromBase64Throwing(base64String) + case .data: + // This is an error in our logic, not a malformed wire value + preconditionFailure("Should not receive Data for JSON encoding format") + } + } else { + bytes = nil + } + } + + if let wireString = wireObjectData.string { + // OD5a2, OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + if wireObjectData.encoding == "json" { + let jsonValue = try JSONObjectOrArray(jsonString: wireString) + string = .json(jsonValue) + } else { + string = .string(wireString) + } + } else { + string = nil + } } - /// Converts this `ObjectData` to a `WireObjectData`. - func toWire() -> WireObjectData { - .init( + /// Converts this `ObjectData` to a `WireObjectData`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectData { + // OD4: Encode data based on format + let wireBytes: StringOrData? = if let bytes { + switch format { + case .messagePack: + // OD4c: When the MessagePack protocol is used + // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute + .data(bytes) + case .json: + // OD4d: When the JSON protocol is used + // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute + .string(bytes.base64EncodedString()) + } + } else { + nil + } + + // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute + // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute + let (wireString, wireEncoding): (String?, String?) = if let stringContent = string { + switch stringContent { + case let .string(str): + (str, nil) + case let .json(jsonValue): + // OD4c5, OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + (jsonValue.toJSONString, "json") + } + } else { + (nil, nil) + } + + let wireNumber: NSNumber? = if let number { + switch format { + case .json: + number + case .messagePack: + // OD4c: When the MessagePack protocol is used + // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute + .init(value: number.doubleValue) + } + } else { + nil + } + + return .init( objectId: objectId, - encoding: encoding, + encoding: wireEncoding, boolean: boolean, - bytes: bytes, - number: number, - string: string, + bytes: wireBytes, + number: wireNumber, + string: wireString, ) } } internal extension MapOp { - /// Initializes a `MapOp` from a `WireMapOp`. - init(wireMapOp: WireMapOp) { + /// Initializes a `MapOp` from a `WireMapOp`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMapOp: WireMapOp, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { key = wireMapOp.key - data = wireMapOp.data.map { .init(wireObjectData: $0) } + data = try wireMapOp.data.map { wireObjectData throws(InternalError) in + try .init(wireObjectData: wireObjectData, format: format) + } } - /// Converts this `MapOp` to a `WireMapOp`. - func toWire() -> WireMapOp { + /// Converts this `MapOp` to a `WireMapOp`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMapOp { .init( key: key, - data: data?.toWire(), + data: data?.toWire(format: format), ) } } internal extension MapEntry { - /// Initializes a `MapEntry` from a `WireMapEntry`. - init(wireMapEntry: WireMapEntry) { + /// Initializes a `MapEntry` from a `WireMapEntry`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMapEntry: WireMapEntry, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { tombstone = wireMapEntry.tombstone timeserial = wireMapEntry.timeserial - data = .init(wireObjectData: wireMapEntry.data) + data = try .init(wireObjectData: wireMapEntry.data, format: format) } - /// Converts this `MapEntry` to a `WireMapEntry`. - func toWire() -> WireMapEntry { + /// Converts this `MapEntry` to a `WireMapEntry`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMapEntry { .init( tombstone: tombstone, timeserial: timeserial, - data: data.toWire(), + data: data.toWire(format: format), ) } } internal extension Map { - /// Initializes a `Map` from a `WireMap`. - init(wireMap: WireMap) { + /// Initializes a `Map` from a `WireMap`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMap: WireMap, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { semantics = wireMap.semantics - entries = wireMap.entries?.mapValues { .init(wireMapEntry: $0) } + entries = try wireMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in + try .init(wireMapEntry: wireMapEntry, format: format) + } } - /// Converts this `Map` to a `WireMap`. - func toWire() -> WireMap { + /// Converts this `Map` to a `WireMap`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMap { .init( semantics: semantics, - entries: entries?.mapValues { $0.toWire() }, + entries: entries?.mapValues { $0.toWire(format: format) }, ) } } internal extension ObjectState { - /// Initializes an `ObjectState` from a `WireObjectState`. - init(wireObjectState: WireObjectState) { + /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectState: WireObjectState, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { objectId = wireObjectState.objectId siteTimeserials = wireObjectState.siteTimeserials tombstone = wireObjectState.tombstone - createOp = wireObjectState.createOp.map { .init(wireObjectOperation: $0) } - map = wireObjectState.map.map { .init(wireMap: $0) } + createOp = try wireObjectState.createOp.map { wireObjectOperation throws(InternalError) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + map = try wireObjectState.map.map { wireMap throws(InternalError) in + try .init(wireMap: wireMap, format: format) + } counter = wireObjectState.counter } - /// Converts this `ObjectState` to a `WireObjectState`. - func toWire() -> WireObjectState { + /// Converts this `ObjectState` to a `WireObjectState`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectState { .init( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, - createOp: createOp?.toWire(), - map: map?.toWire(), + createOp: createOp?.toWire(format: format), + map: map?.toWire(format: format), counter: counter, ) } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index b53228f6..482c0357 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -158,8 +158,7 @@ internal struct WireObjectOperation { internal var map: WireMap? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 - internal var initialValue: Data? // OOP3h + internal var initialValue: StringOrData? // OOP3h internal var initialValueEncoding: String? // OOP3i } @@ -213,6 +212,9 @@ extension WireObjectOperation: WireObjectCodable { if let nonce { result[WireKey.nonce.rawValue] = .string(nonce) } + if let initialValue { + result[WireKey.initialValue.rawValue] = initialValue.toWireValue + } if let initialValueEncoding { result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) } @@ -419,8 +421,7 @@ internal struct WireObjectData { internal var objectId: String? // OD2a internal var encoding: String? // OD2b internal var boolean: Bool? // OD2c - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 - internal var bytes: Data? // OD2d + internal var bytes: StringOrData? // OD2d internal var number: NSNumber? // OD2e internal var string: String? // OD2f } @@ -439,6 +440,7 @@ extension WireObjectData: WireObjectCodable { objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) encoding = try wireObject.optionalStringValueForKey(WireKey.encoding.rawValue) boolean = try wireObject.optionalBoolValueForKey(WireKey.boolean.rawValue) + bytes = try wireObject.optionalDecodableValueForKey(WireKey.bytes.rawValue) number = try wireObject.optionalNumberValueForKey(WireKey.number.rawValue) string = try wireObject.optionalStringValueForKey(WireKey.string.rawValue) } @@ -455,6 +457,9 @@ extension WireObjectData: WireObjectCodable { if let boolean { result[WireKey.boolean.rawValue] = .bool(boolean) } + if let bytes { + result[WireKey.bytes.rawValue] = bytes.toWireValue + } if let number { result[WireKey.number.rawValue] = .number(number) } @@ -465,3 +470,39 @@ extension WireObjectData: WireObjectCodable { return result } } + +/// A type that can be either a string or binary data. +/// +/// Used to represent: +/// +/// - the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5 +/// - the values that `WireObjectOperation.initialValue` might hold, after being encoded per OOP5 +internal enum StringOrData: WireCodable { + case string(String) + case data(Data) + + /// An error that can occur when decoding a ``StringOrData``. + internal enum DecodingError: Error { + case unsupportedValue(WireValue) + } + + internal init(wireValue: WireValue) throws(InternalError) { + self = switch wireValue { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + default: + throw DecodingError.unsupportedValue(wireValue).toInternalError() + } + } + + internal var toWireValue: WireValue { + switch self { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift new file mode 100644 index 00000000..7143a329 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -0,0 +1,19 @@ +import Ably +import Foundation + +/// Errors that can occur during decoding operations. +internal enum DecodingError: Error, Equatable { + case invalidBase64String(String) +} + +internal extension Data { + /// Initialize Data from a Base64-encoded string, throwing an error if decoding fails. + /// - Parameter base64String: The Base64-encoded string to decode + /// - Throws: `DecodingError.invalidBase64String` if the string cannot be decoded as Base64 + static func fromBase64Throwing(_ base64String: String) throws(InternalError) -> Data { + guard let data = Data(base64Encoded: base64String) else { + throw DecodingError.invalidBase64String(base64String).toInternalError() + } + return data + } +} diff --git a/Tests/AblyLiveObjectsTests/.swiftformat b/Tests/AblyLiveObjectsTests/.swiftformat new file mode 100644 index 00000000..b7ca949e --- /dev/null +++ b/Tests/AblyLiveObjectsTests/.swiftformat @@ -0,0 +1,2 @@ +# To avoid turning Swift Testing suites (which are usually written as structs — whether there's a compelling reason for them to be, I'm not sure, but it's what the documentation shows) into enums +--disable enumNamespaces diff --git a/Tests/AblyLiveObjectsTests/.swiftlint.yml b/Tests/AblyLiveObjectsTests/.swiftlint.yml new file mode 100644 index 00000000..d7c5fc36 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/.swiftlint.yml @@ -0,0 +1,3 @@ +disabled_rules: + # See disabling of enumNamespaces in .swiftformat in this directory for motivation (Swift Testing suite types) + - convenience_type diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift new file mode 100644 index 00000000..7a614f47 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -0,0 +1,568 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +// Note that the usage of rawValue when referring to an EncodingFormat in a parameterised test argument in this test file is a workaround for an Xcode issue that means that, if you use the struct value directly, you get a runtime crash of "Internal inconsistency: No test reporter for test case argumentIDs"; seems like it's an issue that people report happening with various combinations of test arguments, I guess this is one that triggers it. See if fixed in a future Xcode version (I tested in Xcode 16.4 and it wasn't). + +struct ObjectMessageTests { + struct ObjectDataTests { + struct EncodingTests { + struct MessagePackTests { + // @spec OD4c1 + // @specOneOf(1/8) OD4b + @Test + func boolean() { + let objectData = ObjectData(boolean: true) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c1: A boolean payload is encoded as a MessagePack boolean type, and the result is set on the ObjectData.boolean attribute + #expect(wireData.boolean == true) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c2 + // @specOneOf(2/8) OD4b + @Test + func binary() { + let testData = Data([1, 2, 3, 4]) + let objectData = ObjectData(bytes: testData) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute + #expect(wireData.boolean == nil) + switch wireData.bytes { + case let .data(data): + #expect(data == testData) + default: + Issue.record("Expected .data case") + } + #expect(wireData.number == nil) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c3 + // @specOneOf(3/8) OD4b + @Test(arguments: [15, 42.0]) + func number(testNumber: NSNumber) throws { + let objectData = ObjectData(number: testNumber) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + CFNumberGetType(testNumber) + let number = try #require(wireData.number) + #expect(CFNumberGetType(number) == .float64Type) + #expect(number == testNumber) + #expect(wireData.number == testNumber) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c4 + // @specOneOf(4/8) OD4b + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: .string(testString)) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == testString) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c5 + @Test(arguments: [ + // We intentionally use a single-element object so that we get a stable encoding to JSON + (jsonObjectOrArray: ["key": "value"] as JSONObjectOrArray, expectedJSONString: #"{"key":"value"}"#), + (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), + ]) + func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { + let objectData = ObjectData(string: .json(jsonObjectOrArray)) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == expectedJSONString) + #expect(wireData.encoding == "json") + } + } + + struct JSONTests { + // @spec OD4d1 + // @specOneOf(5/8) OD4b + @Test + func boolean() { + let objectData = ObjectData(boolean: true) + let wireData = objectData.toWire(format: .json) + + // OD4d1: A boolean payload is represented as a JSON boolean and set on the ObjectData.boolean attribute + #expect(wireData.boolean == true) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d2 + // @specOneOf(6/8) OD4b + @Test + func binary() { + let testData = Data([1, 2, 3, 4]) + let objectData = ObjectData(bytes: testData) + let wireData = objectData.toWire(format: .json) + + // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute + #expect(wireData.boolean == nil) + switch wireData.bytes { + case let .string(base64String): + #expect(base64String == testData.base64EncodedString()) + default: + Issue.record("Expected .string case") + } + #expect(wireData.number == nil) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d3 + // @specOneOf(7/8) OD4b + @Test + func number() { + let testNumber = NSNumber(value: 42) + let objectData = ObjectData(number: testNumber) + let wireData = objectData.toWire(format: .json) + + // OD4d3: A number payload is represented as a JSON number and set on the ObjectData.number attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == testNumber) + #expect(wireData.string == nil) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d4 + // @specOneOf(8/8) OD4b + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: .string(testString)) + let wireData = objectData.toWire(format: .json) + + // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == testString) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d5 + @Test(arguments: [ + // We intentionally use a single-element object so that we get a stable encoding to JSON + (jsonObjectOrArray: ["key": "value"] as JSONObjectOrArray, expectedJSONString: #"{"key":"value"}"#), + (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), + ]) + func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { + let objectData = ObjectData(string: .json(jsonObjectOrArray)) + let wireData = objectData.toWire(format: .json) + + // OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == expectedJSONString) + #expect(wireData.encoding == "json") + } + } + } + + struct DecodingTests { + struct MessagePackTests { + // @specOneOf(1/5) OD5a1 + @Test + func boolean() throws { + let wireData = WireObjectData(boolean: true) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == true) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + } + + // @specOneOf(2/5) OD5a1 + @Test + func binary() throws { + let testData = Data([1, 2, 3, 4]) + let wireData = WireObjectData(bytes: .data(testData)) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == testData) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + } + + // @specOneOf(3/5) OD5a1 - The spec isn't clear about what's meant to happen if you get string data in the `bytes` field; I'm choosing to ignore it but I think it's a bit moot - shouldn't happen. The only reason I'm considering it here is because of our slightly weird WireObjectData.bytes type which is typed as a string or data; might be good to at some point figure out how to rule out the string case earlier when using MessagePack, but it's not a big issue + @Test + func whenBytesIsString() throws { + let testData = Data([1, 2, 3, 4]) + let base64String = testData.base64EncodedString() + let wireData = WireObjectData(bytes: .string(base64String)) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + } + + // @specOneOf(4/5) OD5a1 + @Test + func number() throws { + let testNumber = NSNumber(value: 42) + let wireData = WireObjectData(number: testNumber) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == testNumber) + #expect(objectData.string == nil) + } + + // @specOneOf(5/5) OD5a1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + @Test + func string() throws { + let testString = "hello world" + let wireData = WireObjectData(string: testString) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .string(str): + #expect(str == testString) + default: + Issue.record("Expected .string case") + } + } + + // @specOneOf(1/3) OD5a2 + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(encoding: "json", string: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a2: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .json(jsonValue): + #expect(jsonValue == ["key": "value", "number": 123]) + default: + Issue.record("Expected .json case") + } + } + + // @specOneOf(2/3) OD5a2 - The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + @Test + func json_invalidJson() { + let invalidJsonString = "invalid json" + let wireData = WireObjectData(encoding: "json", string: invalidJsonString) + + // Should throw when JSON parsing fails, even in MessagePack format + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + + // @specOneOf(3/3) OD5a2 - The spec doesn't say what to do if given serialized JSON that contains a non-object-or-array value; I'm choosing to treat it as an error + @Test(arguments: [ + // string + "\"hello world\"", + // number + "42", + // boolean true + "true", + // boolean false + "false", + // null + "null", + ]) + func json_validJsonButNotObjectOrArray(jsonString: String) { + let wireData = WireObjectData(encoding: "json", string: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + } + + struct JSONTests { + // @specOneOf(1/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + @Test + func boolean() throws { + let wireData = WireObjectData(boolean: true) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == true) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + } + + // @specOneOf(2/3) OD5b1 + @Test + func number() throws { + let testNumber = NSNumber(value: 42) + let wireData = WireObjectData(number: testNumber) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == testNumber) + #expect(objectData.string == nil) + } + + // @specOneOf(3/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a3 does not apply to the `string` property + @Test + func string() throws { + let testString = "hello world" + let wireData = WireObjectData(string: testString) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .string(str): + #expect(str == testString) + default: + Issue.record("Expected .string case") + } + } + + // @specOneOf(1/2) OB5b2 + @Test + func binary() throws { + let testData = Data([1, 2, 3, 4]) + let base64String = testData.base64EncodedString() + let wireData = WireObjectData(bytes: .string(base64String)) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value + #expect(objectData.boolean == nil) + #expect(objectData.bytes == testData) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + } + + // @specOneOf(2/2) OB5b2 - The spec doesn't say what to do if Base64 decoding fails; we're choosing to treat it as an error + @Test + func binary_invalidBase64() { + let invalidBase64String = "not base64!" + let wireData = WireObjectData(bytes: .string(invalidBase64String)) + + // Should throw when Base64 decoding fails + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // @specOneOf(1/3) OD5b3 + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(encoding: "json", string: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .json(jsonValue): + #expect(jsonValue == ["key": "value", "number": 123]) + default: + Issue.record("Expected .json case") + } + } + + // @specOneOf(2/3) OD5b3 - The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + @Test + func json_invalidJson() { + let invalidJsonString = "invalid json" + let wireData = WireObjectData(encoding: "json", string: invalidJsonString) + + // Should throw when JSON parsing fails + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // @specOneOf(3/3) OD5b3 - The spec doesn't say what to do if given serialized JSON that contains a non-object-or-array value; I'm choosing to treat it as an error + @Test(arguments: [ + // string + "\"hello world\"", + // number + "42", + // boolean true + "true", + // boolean false + "false", + // null + "null", + ]) + func json_validJsonButNotObjectOrArray(jsonString: String) { + let wireData = WireObjectData(encoding: "json", string: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + } + } + } + + struct RoundTripTests { + @Test(arguments: [ + // Test formats + EncodingFormat.json.rawValue, + EncodingFormat.messagePack.rawValue, + ], [ + // Test each property type individually + ObjectData(boolean: true), + ObjectData(bytes: Data([1, 2, 3, 4])), + ObjectData(number: NSNumber(value: 42)), + ObjectData(string: .string("hello world")), + ObjectData(string: .json(["key": "value", "number": 123])), + ObjectData(string: .json([123, "hello world"])), + ]) + func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ObjectData) throws { + let format = try #require(EncodingFormat(rawValue: formatRawValue)) + let wireData = originalData.toWire(format: format) + let decodedData = try ObjectData(wireObjectData: wireData, format: format) + + // Compare boolean values + #expect(decodedData.boolean == originalData.boolean) + + // Compare bytes values + #expect(decodedData.bytes == originalData.bytes) + + // Compare number values + #expect(decodedData.number == originalData.number) + + // Compare string values, handling both .string and .json cases + switch (decodedData.string, originalData.string) { + case (.none, .none): + break + case let (.string(decoded), .string(original)): + #expect(decoded == original) + case let (.json(decoded), .json(original)): + #expect(decoded == original) + default: + Issue.record("String cases did not match") + } + } + } + + struct ObjectOperationTests { + struct EncodingTests { + @Test(arguments: [EncodingFormat.json.rawValue, EncodingFormat.messagePack.rawValue]) + func noInitialValue(formatRawValue: EncodingFormat.RawValue) throws { + let format = try #require(EncodingFormat(rawValue: formatRawValue)) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + ) + let wireOperation = operation.toWire(format: format) + + #expect(wireOperation.initialValue == nil) + #expect(wireOperation.initialValueEncoding == nil) + } + + struct MessagePackTests { + // @spec OOP5a1 + // @spec OOP5a2 + @Test + func initialValue() { + let testData = Data([1, 2, 3, 4]) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + initialValue: testData, + ) + let wireOperation = operation.toWire(format: .messagePack) + + // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type + switch wireOperation.initialValue { + case let .data(data): + #expect(data == testData) + default: + Issue.record("Expected .data case") + } + // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack + #expect(wireOperation.initialValueEncoding == "msgpack") + } + } + + struct JSONTests { + // @spec OOP5b1 + // @spec OOP5b2 + @Test + func initialValue() { + let testData = Data([1, 2, 3, 4]) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + initialValue: testData, + ) + let wireOperation = operation.toWire(format: .json) + + // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string + switch wireOperation.initialValue { + case let .string(base64String): + #expect(base64String == testData.base64EncodedString()) + default: + Issue.record("Expected .string case") + } + // OOP5b2: Set ObjectOperation.initialValueEncoding to json + #expect(wireOperation.initialValueEncoding == "json") + } + } + } + } +}