Skip to content

Commit

Permalink
Spec complete for sending and receiving messages
Browse files Browse the repository at this point in the history
Based on [1] at d8ec6b1.

[1] - ably/specification#200
  • Loading branch information
umair-ably committed Oct 14, 2024
2 parents 96cdfa8 + 05836a6 commit 7cc5490
Show file tree
Hide file tree
Showing 32 changed files with 1,945 additions and 113 deletions.
4 changes: 2 additions & 2 deletions AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "6a8d15fb1d326ac6e8a40c286c152332146d6f58c73123cb8083f68d483dd728",
"originHash" : "4a28f4c041628961c5cac754904f5f76c33935bbc8417b2e036e0ab6c33b7a0a",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"branch" : "main",
"revision" : "ccca241a8a7f08b22a93802161460c843d9b5bf3"
"revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51"
}
},
{
Expand Down
4 changes: 4 additions & 0 deletions Example/AblyChatExample/Mocks/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Ably
import AblyChat

final class MockMessagesPaginatedResult: PaginatedResult {
static func == (_: MockMessagesPaginatedResult, _: MockMessagesPaginatedResult) -> Bool {
fatalError("dsgdsg")
}

typealias T = Message

let clientID: String
Expand Down
4 changes: 4 additions & 0 deletions Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
let channels = Channels()

final class Channels: RealtimeChannelsProtocol {
func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel {
fatalError("Not implemented")
}

func get(_: String) -> Channel {
fatalError("Not implemented")
}
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ let package = Package(
name: "Ably",
package: "ably-cocoa"
),
.product(
name: "AsyncAlgorithms",
package: "swift-async-algorithms"
),
]
),
.testTarget(
Expand Down
143 changes: 143 additions & 0 deletions Sources/AblyChat/ChatAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Ably

internal final class ChatAPI: Sendable {
private let realtime: RealtimeClient
private let apiVersion = "/chat/v1"

public init(realtime: RealtimeClient) {
self.realtime = realtime
}

internal func getChannel(_ name: String) -> any RealtimeChannelProtocol {
realtime.getChannel(name)
}

// (CHA-M6) Messages should be queryable from a paginated REST API.
internal func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult<Message> {
let endpoint = "\(apiVersion)/rooms/\(roomId)/messages"
return try await makePaginatedRequest(endpoint, params: params.asQueryItems())
}

internal struct SendMessageResponse: Codable {
internal let timeserial: String
internal let createdAt: Int64
}

// (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method.
// (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event).
internal func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message {
guard let clientId = realtime.clientId else {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
}

let endpoint = "\(apiVersion)/rooms/\(roomId)/messages"
var body: [String: Any] = ["text": params.text]

// (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload.
if let metadata = params.metadata {
body["metadata"] = metadata

// (CHA-M3c) metadata must not contain the key ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
if metadata.contains(where: { $0.key == "ably-chat" }) {
throw ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`")
}
}

if let headers = params.headers {
body["headers"] = headers

// (CHA-M3d) headers must not contain a key prefixed with ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
if headers.keys.contains(where: { keyString in
keyString.hasPrefix("ably-chat")
}) {
throw ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`")
}
}

let response: SendMessageResponse = try await makeRequest(endpoint, method: "POST", body: body)

// response.createdAt is in milliseconds, convert it to seconds
let createdAtInSeconds = TimeInterval(Double(response.createdAt) / 1000)

let message = Message(
timeserial: response.timeserial,
clientID: clientId,
roomID: roomId,
text: params.text,
createdAt: Date(timeIntervalSince1970: createdAtInSeconds),
metadata: params.metadata ?? [:],
headers: params.headers ?? [:]
)
return message
}

internal func getOccupancy(roomId: String) async throws -> OccupancyEvent {
let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy"
return try await makeRequest(endpoint, method: "GET")
}

// TODO: https://github.com/ably-labs/ably-chat-swift/issues/84 - Improve how we're decoding via `JSONSerialization` within the `DictionaryDecoder`
private func makeRequest<Response: Codable>(_ url: String, method: String, body: [String: Any]? = nil) async throws -> Response {
try await withCheckedThrowingContinuation { continuation in
do {
try realtime.request(method, path: url, params: [:], body: body, headers: [:]) { paginatedResponse, error in
if let error {
// (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call.
continuation.resume(throwing: ARTErrorInfo.create(from: error))
return
}

guard let firstItem = paginatedResponse?.items.first else {
continuation.resume(throwing: ChatError.noItemInResponse)
return
}

do {
let decodedResponse = try DictionaryDecoder().decode(Response.self, from: firstItem)
continuation.resume(returning: decodedResponse)
} catch {
continuation.resume(throwing: error)
}
}
} catch {
continuation.resume(throwing: error)
}
}
}

private func makePaginatedRequest<Response: Codable & Sendable & Equatable>(
_ url: String,
params: [String: String]? = nil,
body: [String: Any]? = nil
) async throws -> any PaginatedResult<Response> {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<PaginatedResultWrapper<Response>, _>) in
do {
try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in
ARTHTTPPaginatedCallbackWrapper<Response>(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation)
}
} catch {
continuation.resume(throwing: error)
}
}
}

internal enum ChatError: Error {
case noItemInResponse
}
}

internal struct DictionaryDecoder {
private let decoder = JSONDecoder()

// Function to decode from a dictionary
internal func decode<T: Decodable>(_: T.Type, from dictionary: NSDictionary) throws -> T {
let data = try JSONSerialization.data(withJSONObject: dictionary)
return try decoder.decode(T.self, from: data)
}

// Function to decode from a dictionary array
internal func decode<T: Decodable>(_: T.Type, from dictionary: [NSDictionary]) throws -> T {
let data = try JSONSerialization.data(withJSONObject: dictionary)
return try decoder.decode(T.self, from: data)
}
}
Loading

0 comments on commit 7cc5490

Please sign in to comment.