ACActionCable

ACActionCable is a Swift 5 client for Ruby on Rails 6's Action Cable WebSocket server. It aims to be well-tested, dependency-free, and easy to use.

MIT License

Stars
1
Committers
5

ACActionCable

ACActionCable is a Swift 5 client for Ruby on Rails 6's Action Cable WebSocket server. It is a hard fork of Action-Cable-Swift. It aims to be well-tested, dependency-free, and easy to use.

Installation

CocoaPods

If your project doesn't use CocoaPods yet, follow this guide.

Add the following line to your Podfile:

pod 'ACActionCable', '~> 2'

ACActionCable uses semantic versioning.

Usage

Implement ACWebSocketProtocol

You can use ACActionCable with any WebSocket library you'd like. Just create a class that implements ACWebSocketProtocol. If you use Starscream, you can just copy ACStarscreamWebSocket into your project.

Create a singleton class to hold an ACClient

// MyClient.swift

import ACActionCable

class MyClient {

    static let shared = MyClient()
    private let client: ACClient

    private init() {
        let socket = ACStarscreamWebSocket(stringURL: "https://myrailsapp.com/cable") // Your concrete implementation of ACWebSocketProtocol (see above)
        client = ACClient(socket: socket, connectionMonitorTimeout: 6)
    }
}

If you set a connectionMonitorTimeout and no ping is received for that many seconds, then ACConnectionMonitor will periodically attempt to reconnect. Leave connectionMonitorTimeout nil to disable connection monitoring.

Connect and disconnect

You can set custom headers based on your server's requirements

// MyClient.swift

func connect() {
    client.headers = [
        "Auth": "Token",
        "Origin": "https://myrailsapp.com",
    ]
    client.connect()
}

func disconnect() {
    client.disconnect()
}

You probably want to connect when the user's session begins and disconnect when the user logs out.

// User.swift

func onSessionCreated() {
    MyClient.shared.connect()
    // ...
}

func logOut() {
    // ...
    MyClient.shared.disconnect()
}

Subscribe and unsubscribe

// MyClient.swift

func subscribe(to channelIdentifier: ACChannelIdentifier, with messageHandler: @escaping ACMessageHandler) -> ACSubscription? {
    guard let subscription = client.subscribe(to: channelIdentifier, with: messageHandler) else {
        print("Warning: MyClient ignored attempt to double subscribe. You are already subscribed to \(channelIdentifier)")
        return nil
    }
    return subscription
}

func unsubscribe(from subscription: ACSubscription) {
    client.unsubscribe(from: subscription)
}
// ChatChannel.swift

import ACActionCable

class ChatChannel {

    private var subscription: ACSubscription?

    func subscribe(to roomId: Int) {
        guard subscription == nil else { return }
        let channelIdentifier = ACChannelIdentifier(channelName: "ChatChannel", identifier: ["room_id": roomId])!
        subscription = MyClient.shared.subscribe(to: channelIdentifier, with: handleMessage(_:))
    }

    func unsubscribe() {
        guard let subscription = subscription else { return }
        MyClient.shared.unsubscribe(from: subscription)
        self.subscription = nil
    }

    private func handleMessage(_ message: ACMessage) {
        switch message.type {
        case .confirmSubscription:
            print("ChatChannel subscribed")
        case .rejectSubscription:
            print("Server rejected ChatChannel subscription")
        default:
            break
        }
    }
}

Subscriptions are resubscribed on reconnection, so beware that .confirmSubscription may be called multiple times per subscription.

Register your Decodable messages

ACActionCable automatically decodes your models. For example, if your server broadcasts the following message:

{
  "identifier": "{\"channel\":\"ChatChannel\",\"room_id\":42}",
  "message": {
    "my_object": {
      "sender_id": 311,
      "text": "Hello, room 42!",
      "sent_at": 1600545466.294104
    }
  }
}

Then ACActionCable provides two different approaches to automatically decode it:

A) Automatically decode single key of message

// MyObject.swift

struct MyObject: Codable { // Must implement Decodable or Codable
    let senderId: Int
    let text: String
    let sentAt: Date
}

All you have to do is register the Codable struct for a single key. The name of the struct must match the name of that single key.

// MyClient.swift

private init() {
  // Decode the single object for key `my_object` within `message`
  ACMessageBodySingleObject.register(type: MyObject.self)
}
// ChatChannel.swift

private func handleMessage(_ message: ACMessage) {
    switch (message.type, message.body) {
    case (.confirmSubscription, _):
        print("ChatChannel subscribed")
    case (.rejectSubscription, _):
        print("Server rejected ChatChannel subscription")
    case (_, .object(let object)):
        switch object {
        case let myObject as MyObject:
            print("\(myObject.text.debugDescription) from Sender \(myObject.senderId) at \(myObject.sentAt)")
            // "Hello, room 42!" from Sender 311 at 2020-09-19 19:57:46 +0000
        default:
            print("Warning: ChatChannel ignored message")
        }
    default:
        break
    }
}

If the message object sent from the server contains more than a single key, you have another option for automatic decoding:

B) Automatically decode whole message object

// MessageType.swift

struct MessageType: Codable { // Must implement Decodable or Codable
    let myObject: MyObject
}

struct MyObject: Codable { // Must implement Decodable or Codable
    let senderId: Int
    let text: String
    let sentAt: Date
}

All you have to do is register the Codable struct for the whole message object.

// MyClient.swift

private init() {
  // Decode the whole `message` object
  ACMessage.register(type: MessageType.self, forChannelIdentifier: channelIdentifier)
}

The channelIdentifier must match the one the handler is subscribing to, because all incoming message objects for that channel will be decoded according to MessageType.

// ChatChannel.swift

private func handleMessage(_ message: ACMessage) {
    switch (message.type, message.body) {
    case (.confirmSubscription, _):
        print("ChatChannel subscribed")
    case (.rejectSubscription, _):
        print("Server rejected ChatChannel subscription")
    case (_, .object(let object)):
        switch object {
        case let message as MessageType:
            print("\(message.myObject.text.debugDescription) from Sender \(message.myObject.senderId) at \(message.myObject.sentAt)")
            // "Hello, room 42!" from Sender 311 at 2020-09-19 19:57:46 +0000
        default:
            print("Warning: ChatChannel ignored message")
        }
    default:
        break
    }
}

Accessing the raw message body data

ACActionCable also provides access to the raw message body data for more involved processing:

// ChatChannel.swift

private func handleMessage(_ message: ACMessage) {
    switch (message.type) {
    case (.confirmSubscription):
        print("ChatChannel subscribed")
    case (.rejectSubscription):
        print("Server rejected ChatChannel subscription")
    default:
        guard let bodyData = message.bodyData else {
            return
        }

        // do something fancy with the raw `Data`
    }
}

Send messages

ACActionCable automatically encodes your Encodable objects too:

// MyObject.swift

struct MyObject: Codable { // Must implement Encodable or Codable
    let action: String
    let senderId: Int
    let text: String
    let sentAt: Date
}
// ChatChannel.swift

func speak(_ text: String) {
    let myObject = MyObject(action: "speak", senderId: 99, text: text, sentAt: Date())
    subscription?.send(object: myObject)
}

Calling channel.speak("my message") would cause the following to be sent:

{
  "command": "message",
  "data": "{\"action\":\"speak\",\"my_object\":{\"sender_id\":99,\"sent_at\":1600545466.294104,\"text\":\"my message\"}}",
  "identifier": "{\"channel\":\"ChatChannel\",\"room_id\":42}"
}

(Optional) Modify encoder/decoder date formatting

By default, Date objects are encoded or decoded using .secondsSince1970. If you need to change to another format:

ACCommand.encoder.dateEncodingStrategy = .iso8601 // for dates like "2020-09-19T20:09:04Z"
ACMessage.decoder.dateDecodingStrategy = .iso8601

Note that .iso8601 is quite strict and doesn't allow fractional seconds. If you need them, consider using .secondsSince1970, millisecondsSince1970, .formatted, or .custom.

(Optional) Add an ACClientTap

If you need to listen to the internal state of ACClient, use ACClientTap.

// MyClient.swift

private init() {
    // ...
    let tap = ACClientTap(
        onConnected: { (headers) in
            print("Client connected with headers: \(headers.debugDescription)")
        }, onDisconnected: { (reason) in
            print("Client disconnected with reason: \(reason.debugDescription)")
        }, onText: { (text) in
            print("Client received text: \(text)")
        }, onMessage: { (message) in
            print("Client received message: \(message)")
        })
    client.add(tap)
}

Contributing

Instead of opening an issue, please fix it yourself and then create a pull request. Please add new tests for your feature or fix, and don't forget to make sure that all the tests pass!