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-4942] Send/Receive Messages Spec Complete #45

Merged
merged 1 commit into from
Oct 16, 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 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 @@ -37,6 +37,10 @@ final class MockMessagesPaginatedResult: PaginatedResult {
self.roomID = roomID
self.numberOfMockMessages = numberOfMockMessages
}

static func == (_: MockMessagesPaginatedResult, _: MockMessagesPaginatedResult) -> Bool {
fatalError("Not implemented")
maratal marked this conversation as resolved.
Show resolved Hide resolved
}
}

enum MockStrings {
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")
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved

func get(_: String) -> Channel {
fatalError("Not implemented")
}
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "db24f2979451a46f504f45d35893eb8501f27488ae70e1412340139a0e7551e2",
"originHash" : "48d264bc362ab438d94b78732ce83d1971c3a1264d089085edd3814083479fc6",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"branch" : "main",
"revision" : "ccca241a8a7f08b22a93802161460c843d9b5bf3"
"revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51"
}
},
{
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(
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
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 {
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
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