Skip to content

Commit d920775

Browse files
Add helpers for creating ObjectMessage for LiveObject creation
Based on [1] at cb11ba8. Interface by me, code largely by Cursor and then tweaked by me. TODO unit test [1] ably/specification#353
1 parent 1badcec commit d920775

2 files changed

Lines changed: 243 additions & 0 deletions

File tree

Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,36 @@ internal enum InternalLiveMapValue: Sendable, Equatable {
66
case liveMap(InternalDefaultLiveMap)
77
case liveCounter(InternalDefaultLiveCounter)
88

9+
// MARK: - Representation in the Realtime protocol
10+
11+
/// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f4 and RTLM20e4.
12+
internal var toObjectData: ObjectData {
13+
// RTO11f4c1: Create an ObjectsMapEntry for the current value
14+
switch self {
15+
case let .primitive(primitiveValue):
16+
switch primitiveValue {
17+
case let .bool(value):
18+
.init(boolean: value)
19+
case let .data(value):
20+
.init(bytes: value)
21+
case let .number(value):
22+
.init(number: NSNumber(value: value))
23+
case let .string(value):
24+
.init(string: value)
25+
case let .jsonArray(value):
26+
.init(json: .array(value))
27+
case let .jsonObject(value):
28+
.init(json: .object(value))
29+
}
30+
case let .liveMap(liveMap):
31+
// RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object
32+
.init(objectId: liveMap.objectID)
33+
case let .liveCounter(liveCounter):
34+
// RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object
35+
.init(objectId: liveCounter.objectID)
36+
}
37+
}
38+
939
// MARK: - Convenience getters for associated values
1040

1141
/// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
internal import AblyPlugin
2+
import CryptoKit
3+
import Foundation
4+
5+
/// Helpers for creating a new LiveObject.
6+
///
7+
/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject.
8+
internal enum ObjectCreationHelpers {
9+
/// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool.
10+
internal struct CounterCreationOperation {
11+
/// The generated object ID. Needed for populating the local objects pool.
12+
///
13+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
14+
internal var objectID: String
15+
16+
/// The operation that should be merged into any created LiveCounter.
17+
///
18+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
19+
internal var operation: ObjectOperation
20+
21+
/// The ObjectMessage that must be sent in order for Realtime to create the object.
22+
internal var objectMessage: OutboundObjectMessage
23+
}
24+
25+
/// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool.
26+
internal struct MapCreationOperation {
27+
/// The generated object ID. Needed for populating the local objects pool.
28+
///
29+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
30+
internal var objectID: String
31+
32+
/// The operation that should be merged into any created LiveMap.
33+
///
34+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
35+
internal var operation: ObjectOperation
36+
37+
/// The ObjectMessage that must be sent in order for Realtime to create the object.
38+
internal var objectMessage: OutboundObjectMessage
39+
40+
/// The semantics that should be used for the created LiveMap.
41+
///
42+
/// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``.
43+
internal var semantics: ObjectsMapSemantics
44+
}
45+
46+
/// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f.
47+
///
48+
/// - Parameters:
49+
/// - count: The initial count for the new LiveCounter object
50+
/// - timestamp: The timestamp to use for the generated object ID.
51+
internal static func creationOperationForLiveCounter(
52+
count: Double,
53+
timestamp: Date,
54+
) -> CounterCreationOperation {
55+
// RTO12f2: Create initial value for the new LiveCounter
56+
let initialValue = PartialObjectOperation(
57+
action: .known(.counterCreate),
58+
counter: WireObjectsCounter(count: NSNumber(value: count)),
59+
)
60+
61+
// RTO12f3: Create an initial value JSON string as described in RTO13
62+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
63+
64+
// RTO12f4: Create a unique nonce as a random string
65+
let nonce = generateNonce()
66+
67+
// RTO12f5: Get the current server time (using the provided timestamp)
68+
let serverTime = timestamp
69+
70+
// RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14
71+
let objectId = createObjectID(
72+
type: "counter",
73+
initialValue: initialValueJSONString,
74+
nonce: nonce,
75+
timestamp: serverTime,
76+
)
77+
78+
// RTO12f7-12: Set ObjectMessage.operation fields
79+
let operation = ObjectOperation(
80+
action: .known(.counterCreate),
81+
objectId: objectId,
82+
counter: WireObjectsCounter(count: NSNumber(value: count)),
83+
nonce: nonce,
84+
initialValue: initialValueJSONString,
85+
)
86+
87+
// Create the OutboundObjectMessage
88+
let objectMessage = OutboundObjectMessage(
89+
operation: operation,
90+
)
91+
92+
return CounterCreationOperation(
93+
objectID: objectId,
94+
operation: operation,
95+
objectMessage: objectMessage,
96+
)
97+
}
98+
99+
/// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f.
100+
///
101+
/// - Parameters:
102+
/// - entries: The initial entries for the new LiveMap object
103+
/// - timestamp: The timestamp to use for the generated object ID.
104+
internal static func creationOperationForLiveMap(
105+
entries: [String: InternalLiveMapValue],
106+
timestamp: Date,
107+
) -> MapCreationOperation {
108+
// RTO11f4: Create initial value for the new LiveMap
109+
let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in
110+
ObjectsMapEntry(data: liveMapValue.toObjectData)
111+
}
112+
113+
let initialValue = PartialObjectOperation(
114+
action: .known(.mapCreate),
115+
map: ObjectsMap(
116+
semantics: .known(.lww),
117+
entries: mapEntries,
118+
),
119+
)
120+
121+
// RTO11f5: Create an initial value JSON string as described in RTO13
122+
let initialValueJSONString = createInitialValueJSONString(from: initialValue)
123+
124+
// RTO11f6: Create a unique nonce as a random string
125+
let nonce = generateNonce()
126+
127+
// RTO11f7: Get the current server time (using the provided timestamp)
128+
let serverTime = timestamp
129+
130+
// RTO11f8: Create an objectId for the new LiveMap object as described in RTO14
131+
let objectId = createObjectID(
132+
type: "map",
133+
initialValue: initialValueJSONString,
134+
nonce: nonce,
135+
timestamp: serverTime,
136+
)
137+
138+
// RTO11f9-13: Set ObjectMessage.operation fields
139+
let operation = ObjectOperation(
140+
action: .known(.mapCreate),
141+
objectId: objectId,
142+
map: ObjectsMap(
143+
semantics: .known(.lww),
144+
entries: mapEntries,
145+
),
146+
nonce: nonce,
147+
initialValue: initialValueJSONString,
148+
)
149+
150+
// Create the OutboundObjectMessage
151+
let objectMessage = OutboundObjectMessage(
152+
operation: operation,
153+
)
154+
155+
return MapCreationOperation(
156+
objectID: objectId,
157+
operation: operation,
158+
objectMessage: objectMessage,
159+
semantics: .lww,
160+
)
161+
}
162+
163+
// MARK: - Private Helper Methods
164+
165+
/// Creates an initial value JSON string from a PartialObjectOperation, per RTO13.
166+
private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String {
167+
// RTO13b: Encode the initial value using OM4 encoding
168+
let partialWireObjectOperation = initialValue.toWire(format: .json)
169+
let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in
170+
do {
171+
return try wireValue.toJSONValue
172+
} catch {
173+
// By using `format: .json` we've requested a type that should be JSON-encodable, so if it isn't then it's a programmer error. (We can't reason about it statically though because of our choice to use a general-purpose WireValue type; maybe could improve upon this in the future.)
174+
preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue")
175+
}
176+
}
177+
178+
// RTO13c
179+
return JSONObjectOrArray.object(jsonObject).toJSONString
180+
}
181+
182+
/// Creates an Object ID for a new LiveObject instance, per RTO14.
183+
private static func createObjectID(
184+
type: String,
185+
initialValue: String,
186+
nonce: String,
187+
timestamp: Date,
188+
) -> String {
189+
// RTO14b1: Generate a hash string for the Object ID
190+
let hashInput = "\(initialValue):\(nonce)"
191+
let hashData = Data(hashInput.utf8)
192+
193+
// RTO14b1: Generate a SHA-256 digest
194+
let hash = SHA256.hash(data: hashData)
195+
196+
// RTO14b2: Base64URL-encode the generated digest
197+
let base64URLHash = Data(hash).base64EncodedString()
198+
.replacingOccurrences(of: "+", with: "-")
199+
.replacingOccurrences(of: "/", with: "_")
200+
.replacingOccurrences(of: "=", with: "")
201+
202+
// RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp]
203+
let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000)
204+
return "\(type):\(base64URLHash)@\(timestampMillis)"
205+
}
206+
207+
/// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4.
208+
private static func generateNonce() -> String {
209+
// TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389
210+
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
211+
return String((0 ..< 16).map { _ in letters.randomElement()! })
212+
}
213+
}

0 commit comments

Comments
 (0)