diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 4b36e72..078f5fa 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -77,12 +77,12 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") } - let metadata: Metadata? = if let metadataJSONObject = data["metadata"]?.objectValue { + let metadata: Metadata? = if let metadataJSONObject = try data.optionalObjectValueForKey("metadata") { try metadataJSONObject.mapValues { try MetadataValue(jsonValue: $0) } } else { nil } - let headers: Headers? = if let headersJSONObject = extras["headers"]?.objectValue { + let headers: Headers? = if let headersJSONObject = try extras.optionalObjectValueForKey("headers") { try headersJSONObject.mapValues { try HeadersValue(jsonValue: $0) } } else { nil diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index ee9276c..e3aeed9 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -23,8 +23,14 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. internal func send(params: SendReactionParams) async throws { logger.log(message: "Sending reaction with params: \(params)", level: .debug) - let extras = ["headers": params.headers ?? [:]] as ARTJsonCompatible - channel.publish(RoomReactionEvents.reaction.rawValue, data: params.asJSONObject(), extras: extras) + + let dto = RoomReactionDTO(type: params.type, metadata: params.metadata, headers: params.headers) + + channel.publish( + RoomReactionEvents.reaction.rawValue, + data: dto.data.toJSONValue.toAblyCocoaData, + extras: dto.extras.toJSONObject.toARTJsonCompatible + ) } // (CHA-ER4) A user may subscribe to reaction events in Realtime. @@ -38,10 +44,8 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { logger.log(message: "Received roomReaction message: \(message)", level: .debug) Task { do { - guard let data = message.data as? [String: Any], - let reactionType = data["type"] as? String - else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + guard let ablyCocoaData = message.data else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") } guard let messageClientID = message.clientId else { @@ -52,18 +56,20 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") } - guard let extras = try message.extras?.toJSON() else { + guard let ablyCocoaExtras = try message.extras?.toJSON() else { throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") } - let metadata = data["metadata"] as? Metadata - let headers = extras["headers"] as? Headers + let dto = try RoomReactionDTO( + data: .init(jsonValue: .init(ablyCocoaData: ablyCocoaData)), + extras: .init(jsonValue: .init(ablyCocoaData: ablyCocoaExtras)) + ) // (CHA-ER4d) Realtime events that are malformed (unknown fields should be ignored) shall not be emitted to listeners. let reaction = Reaction( - type: reactionType, - metadata: metadata ?? .init(), - headers: headers ?? .init(), + type: dto.type, + metadata: dto.metadata ?? [:], + headers: dto.headers ?? [:], createdAt: timestamp, clientID: messageClientID, isSelf: messageClientID == clientID diff --git a/Sources/AblyChat/JSONCodable.swift b/Sources/AblyChat/JSONCodable.swift index 0009ab3..2704ab2 100644 --- a/Sources/AblyChat/JSONCodable.swift +++ b/Sources/AblyChat/JSONCodable.swift @@ -7,3 +7,224 @@ internal protocol JSONDecodable { } internal typealias JSONCodable = JSONDecodable & JSONEncodable + +internal protocol JSONObjectEncodable: JSONEncodable { + var toJSONObject: [String: JSONValue] { get } +} + +// Default implementation of `JSONEncodable` conformance for `JSONObjectEncodable` +internal extension JSONObjectEncodable { + var toJSONValue: JSONValue { + .object(toJSONObject) + } +} + +internal protocol JSONObjectDecodable: JSONDecodable { + init(jsonObject: [String: JSONValue]) throws +} + +internal enum JSONValueDecodingError: Error { + case valueIsNotObject + case noValueForKey(String) + case wrongTypeForKey(String, actualValue: JSONValue) +} + +// Default implementation of `JSONDecodable` conformance for `JSONObjectDecodable` +internal extension JSONObjectDecodable { + init(jsonValue: JSONValue) throws { + guard case let .object(jsonObject) = jsonValue else { + throw JSONValueDecodingError.valueIsNotObject + } + + self = try .init(jsonObject: jsonObject) + } +} + +internal typealias JSONObjectCodable = JSONObjectDecodable & JSONObjectEncodable + +// MARK: - Extracting 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] { + /// 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 -> [String: JSONValue] { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key) + } + + guard case let .object(objectValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return objectValue + } + + /// 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 -> [String: JSONValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .object(objectValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return objectValue + } + + /// 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 -> [JSONValue] { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key) + } + + guard case let .array(arrayValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return arrayValue + } + + /// 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 -> [JSONValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .array(arrayValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return arrayValue + } + + /// 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` + func stringValueForKey(_ key: String) throws -> String { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key) + } + + guard case let .string(stringValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return stringValue + } + + /// 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` + func optionalStringValueForKey(_ key: String) throws -> String? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .string(stringValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return stringValue + } + + /// 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` + func numberValueForKey(_ key: String) throws -> Double { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key) + } + + guard case let .number(numberValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return numberValue + } + + /// 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` + func optionalNumberValueForKey(_ key: String) throws -> Double? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .number(numberValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return numberValue + } + + /// 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` + func boolValueForKey(_ key: String) throws -> Bool { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key) + } + + guard case let .bool(boolValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return boolValue + } + + /// 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` + func optionalBoolValueForKey(_ key: String) throws -> Bool? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .bool(boolValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + } + + return boolValue + } +} diff --git a/Sources/AblyChat/JSONValue.swift b/Sources/AblyChat/JSONValue.swift index 1cffea2..843dcf3 100644 --- a/Sources/AblyChat/JSONValue.swift +++ b/Sources/AblyChat/JSONValue.swift @@ -1,3 +1,4 @@ +import Ably import Foundation /// A JSON value (where "value" has the meaning defined by the [JSON specification](https://www.json.org)). @@ -166,10 +167,11 @@ internal extension JSONValue { /// /// - `ARTPresenceMessage`’s `data` property /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method + /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method var toAblyCocoaData: Any { switch self { case let .object(underlying): - underlying.mapValues(\.toAblyCocoaData) + underlying.toAblyCocoaDataDictionary case let .array(underlying): underlying.map(\.toAblyCocoaData) case let .string(underlying): @@ -183,3 +185,21 @@ internal extension JSONValue { } } } + +internal extension [String: JSONValue] { + /// Creates an ably-cocoa deserialized JSON object from a dictionary that has string keys and `JSONValue` values. + /// + /// Specifically, the value of this property can be used as: + /// + /// - `ARTPresenceMessage`’s `data` property + /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method + /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method + var toAblyCocoaDataDictionary: [String: Any] { + mapValues(\.toAblyCocoaData) + } + + /// Creates an ably-cocoa `ARTJsonCompatible` object from a dictionary that has string keys and `JSONValue` values. + var toARTJsonCompatible: ARTJsonCompatible { + toAblyCocoaDataDictionary as ARTJsonCompatible + } +} diff --git a/Sources/AblyChat/PresenceDataDTO.swift b/Sources/AblyChat/PresenceDataDTO.swift index d2b1e70..3759343 100644 --- a/Sources/AblyChat/PresenceDataDTO.swift +++ b/Sources/AblyChat/PresenceDataDTO.swift @@ -3,32 +3,24 @@ internal struct PresenceDataDTO: Equatable { internal var userCustomData: PresenceData? } -// MARK: - JSONCodable +// MARK: - JSONObjectCodable -extension PresenceDataDTO: JSONCodable { +extension PresenceDataDTO: JSONObjectCodable { internal enum JSONKey: String { case userCustomData } - internal enum DecodingError: Error { - case valueHasWrongType(key: JSONKey) - } - - internal init(jsonValue: JSONValue) throws { - guard case let .object(jsonObject) = jsonValue else { - throw DecodingError.valueHasWrongType(key: .userCustomData) - } - + internal init(jsonObject: [String: JSONValue]) throws { userCustomData = jsonObject[JSONKey.userCustomData.rawValue] } - internal var toJSONValue: JSONValue { + internal var toJSONObject: [String: JSONValue] { var result: [String: JSONValue] = [:] if let userCustomData { result[JSONKey.userCustomData.rawValue] = userCustomData } - return .object(result) + return result } } diff --git a/Sources/AblyChat/RoomReactionDTO.swift b/Sources/AblyChat/RoomReactionDTO.swift new file mode 100644 index 0000000..f92b5bd --- /dev/null +++ b/Sources/AblyChat/RoomReactionDTO.swift @@ -0,0 +1,70 @@ +// CHA-ER3a +internal struct RoomReactionDTO { + internal var data: Data + internal var extras: Extras + + internal struct Data: Equatable { + internal var type: String + internal var metadata: ReactionMetadata? + } + + internal struct Extras: Equatable { + internal var headers: ReactionHeaders? + } +} + +internal extension RoomReactionDTO { + init(type: String, metadata: ReactionMetadata?, headers: ReactionHeaders?) { + data = .init(type: type, metadata: metadata) + extras = .init(headers: headers) + } + + var type: String { + data.type + } + + var metadata: ReactionMetadata? { + data.metadata + } + + var headers: ReactionHeaders? { + extras.headers + } +} + +// MARK: - JSONCodable + +extension RoomReactionDTO.Data: JSONObjectCodable { + internal enum JSONKey: String { + case type + case metadata + } + + internal init(jsonObject: [String: JSONValue]) throws { + type = try jsonObject.stringValueForKey(JSONKey.type.rawValue) + metadata = try jsonObject.optionalObjectValueForKey(JSONKey.metadata.rawValue)?.mapValues { try .init(jsonValue: $0) } + } + + internal var toJSONObject: [String: JSONValue] { + [ + JSONKey.type.rawValue: .string(type), + JSONKey.metadata.rawValue: .object(metadata?.mapValues(\.toJSONValue) ?? [:]), + ] + } +} + +extension RoomReactionDTO.Extras: JSONObjectCodable { + internal enum JSONKey: String { + case headers + } + + internal init(jsonObject: [String: JSONValue]) throws { + headers = try jsonObject.optionalObjectValueForKey(JSONKey.headers.rawValue)?.mapValues { try .init(jsonValue: $0) } + } + + internal var toJSONObject: [String: JSONValue] { + [ + JSONKey.headers.rawValue: .object(headers?.mapValues(\.toJSONValue) ?? [:]), + ] + } +} diff --git a/Sources/AblyChat/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift index b804d0f..eca8ada 100644 --- a/Sources/AblyChat/RoomReactions.swift +++ b/Sources/AblyChat/RoomReactions.swift @@ -27,15 +27,3 @@ public struct SendReactionParams: Sendable { self.headers = headers } } - -internal extension SendReactionParams { - /// Returns a dictionary that `JSONSerialization` can serialize to a JSON "object" value. - /// - /// Suitable to pass as the `data` argument of an ably-cocoa publish operation. - func asJSONObject() -> [String: String] { - var dict: [String: String] = [:] - dict["type"] = "\(type)" - dict["metadata"] = "\(metadata ?? [:])" - return dict - } -} diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift index f5141c4..0482cbc 100644 --- a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -30,8 +30,8 @@ struct DefaultRoomReactionsTests { let sendReactionParams = SendReactionParams( type: "like", - metadata: ["test": MetadataValue.string("test")], - headers: ["test": HeadersValue.string("test")] + metadata: ["someMetadataKey": MetadataValue.string("someMetadataValue")], + headers: ["someHeadersKey": HeadersValue.string("someHeadersValue")] ) // When @@ -39,8 +39,8 @@ struct DefaultRoomReactionsTests { // Then #expect(channel.lastMessagePublishedName == RoomReactionEvents.reaction.rawValue) - #expect(channel.lastMessagePublishedData as? [String: String] == sendReactionParams.asJSONObject()) - #expect(channel.lastMessagePublishedExtras as? Dictionary == ["headers": sendReactionParams.headers]) + #expect(channel.lastMessagePublishedData as? NSObject == ["type": "like", "metadata": ["someMetadataKey": "someMetadataValue"]] as NSObject) + #expect(channel.lastMessagePublishedExtras as? Dictionary == ["headers": ["someHeadersKey": "someHeadersValue"]]) } // @spec CHA-ER4 diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index c2ca767..a43db13 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -165,9 +165,17 @@ struct IntegrationTests { let rxReactionSubscription = await rxRoom.reactions.subscribe() // (2) Now that we’re subscribed to reactions, send a reaction on the other client and check that we receive it on the subscription - try await txRoom.reactions.send(params: .init(type: "heart")) + try await txRoom.reactions.send( + params: .init( + type: "heart", + metadata: ["someMetadataKey": .number(123), "someOtherMetadataKey": .string("foo")], + headers: ["someHeadersKey": .number(456), "someOtherHeadersKey": .string("bar")] + ) + ) let rxReactionFromSubscription = try #require(await rxReactionSubscription.first { _ in true }) #expect(rxReactionFromSubscription.type == "heart") + #expect(rxReactionFromSubscription.metadata == ["someMetadataKey": .number(123), "someOtherMetadataKey": .string("foo")]) + #expect(rxReactionFromSubscription.headers == ["someHeadersKey": .number(456), "someOtherHeadersKey": .string("bar")]) // MARK: - Occupancy diff --git a/Tests/AblyChatTests/PresenceDataDTOTests.swift b/Tests/AblyChatTests/PresenceDataDTOTests.swift index 47a88fd..0e937a9 100644 --- a/Tests/AblyChatTests/PresenceDataDTOTests.swift +++ b/Tests/AblyChatTests/PresenceDataDTOTests.swift @@ -16,8 +16,9 @@ struct PresenceDataDTOTests { #expect(try PresenceDataDTO(jsonValue: jsonValue) == expectedResult) } + @Test func initWithJSONValue_failsIfNotObject() { - #expect(throws: PresenceDataDTO.DecodingError.self) { + #expect(throws: JSONValueDecodingError.self) { try PresenceDataDTO(jsonValue: "hello") } } diff --git a/Tests/AblyChatTests/RoomReactionDTOTests.swift b/Tests/AblyChatTests/RoomReactionDTOTests.swift new file mode 100644 index 0000000..885ea30 --- /dev/null +++ b/Tests/AblyChatTests/RoomReactionDTOTests.swift @@ -0,0 +1,113 @@ +@testable import AblyChat +import Testing + +enum RoomReactionDTOTests { + struct DataTests { + // MARK: - JSONDecodable + + @Test + func initWithJSONValue_failsIfNotObject() { + #expect(throws: JSONValueDecodingError.self) { + try RoomReactionDTO.Data(jsonValue: "hello") + } + } + + @Test + func initWithJSONValue_withNoTypeKey() { + #expect(throws: JSONValueDecodingError.self) { + try RoomReactionDTO.Data(jsonValue: [:]) + } + } + + @Test + func initWithJSONValue_withNoMetadataKey() throws { + #expect(try RoomReactionDTO.Data(jsonValue: ["type": "" /* arbitrary */ ]).metadata == nil) + } + + @Test + func initWithJSONValue() throws { + let data = try RoomReactionDTO.Data( + jsonValue: [ + "type": "someType", + "metadata": [ + "someStringKey": "someStringValue", + "someNumberKey": 123, + ], + ] + ) + + #expect(data == .init(type: "someType", metadata: ["someStringKey": .string("someStringValue"), "someNumberKey": .number(123)])) + } + + // MARK: - JSONCodable + + @Test + func toJSONValue_withNilMetadata() { + // i.e. should create an empty object for metadata + #expect(RoomReactionDTO.Data(type: "" /* arbitrary */, metadata: nil).toJSONValue == .object(["type": "", "metadata": .object([:])])) + } + + @Test + func toJSONValue() { + let data = RoomReactionDTO.Data(type: "someType", metadata: ["someStringKey": .string("someStringValue"), "someNumberKey": .number(123)]) + + #expect(data.toJSONValue == [ + "type": "someType", + "metadata": [ + "someStringKey": "someStringValue", + "someNumberKey": 123, + ], + ]) + } + } + + struct ExtrasTests { + // MARK: - JSONDecodable + + @Test + func initWithJSONValue_failsIfNotObject() { + #expect(throws: JSONValueDecodingError.self) { + try RoomReactionDTO.Extras(jsonValue: "hello") + } + } + + @Test + func initWithJSONValue_withNoHeadersKey() throws { + #expect(try RoomReactionDTO.Extras(jsonValue: [:]).headers == nil) + } + + @Test + func initWithJSONValue() throws { + let data = try RoomReactionDTO.Extras( + jsonValue: [ + "headers": [ + "someStringKey": "someStringValue", + "someNumberKey": 123, + ], + ] + ) + + #expect(data == .init(headers: ["someStringKey": .string("someStringValue"), "someNumberKey": .number(123)])) + } + + // MARK: - JSONCodable + + @Test + func toJSONValue_withNilHeaders() { + // i.e. should create an empty object for headers + #expect(RoomReactionDTO.Extras(headers: nil).toJSONValue == .object(["headers": .object([:])])) + } + + @Test + func toJSONValue() { + let data = RoomReactionDTO.Extras(headers: ["someStringKey": .string("someStringValue"), "someNumberKey": .number(123)]) + + #expect(data.toJSONValue == [ + "headers": [ + "someStringKey": "someStringValue", + "someNumberKey": 123, + ], + ]) + } + } +}