Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-5181] Fix sending and receiving of reaction metadata and headers #199

Merged
merged 6 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 18 additions & 12 deletions Sources/AblyChat/DefaultRoomReactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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
Expand Down
221 changes: 221 additions & 0 deletions Sources/AblyChat/JSONCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
22 changes: 21 additions & 1 deletion Sources/AblyChat/JSONValue.swift
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down Expand Up @@ -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):
Expand All @@ -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
}
}
18 changes: 5 additions & 13 deletions Sources/AblyChat/PresenceDataDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading
Loading