An Introduction to WebSockets

Learn about WebSockets using Swift and Vapor by building a question and answer client and server app. By Jari Koopman.

4 (22) · 6 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Challenge: Extending WebSocketSendOption

In this app, you’ll only send data to single sockets. However, as a fun challenge for yourself, try extending WebSocketSendOption to allow sending to:

  • All sockets
  • Multiple sockets based on IDs

Open the spoiler below to find the answer.

[spoiler title=”Solution”]
Add the following cases to WebSocketSendOption:

case all, ids([UUID])

And add the following to switch in send:

case .all:
  return self.sockets.values.map { $0 }
case .ids(let ids):
  return self.sockets.filter { key, _ in ids.contains(key) }.map { $1 }

[/spoiler]

With that in place, it’s time to go back to the iOS project and start sending questions.

Sending the Questions

Now that the server is ready to receive questions, it’s time to get the app ready to send them.

Return to the Conference Q&A Xcode project and open WebSocketController.swift. Locate addQuestion(_:) and replace the contents with the following:

    guard let id = self.id else { return }
    // 1
    let message = NewQuestionMessage(id: id, content: content)
    do {
      // 2
      let data = try encoder.encode(message)
      // 3
      self.socket.send(.data(data)) { (err) in
        if err != nil {
          print(err.debugDescription)
        }
      }
    } catch {
      print(error)
    }

This code will:

  1. Construct Codable with the contents of the question and the ID that the handshake message passed.
  2. Encode it to a Data instance.
  3. Send the result to the server using the binary opcode. Here, URLSession takes care of the masking for you.

Great job! Now, it’s time to try it out.

Giving Your WebSocket Connection a Test Run

With that, everything is in place to give the entire process a full on test run!

First, build and run the websocket-backend project to make sure the server is ready to accept connections.

When that’s done, build and run the Conference Q&A iOS app.

In the server log messages, you’ll see:

[ INFO ] GET /socket
[ INFO ] Sending QnAHandshake to socket(WebSocketKit.WebSocket)

The iOS app will log Shook the hand. Connection established!

Now, enter a question in the app and press Return. The server will log something like this:

[ INFO ] {"type":"newQuestion","id":"1FC4044C-7E20-4332-9349-E4FDD69A10B3","content":"Awesome question!"}

Awesome, everything’s working!

Receiving WebSocket Data

With the first connection established, there are only a few things left to do. Namely:

  • Store the asked questions in the database.
  • Update the iOS app to use the back end as the source of questions asked.
  • Add the ability to answer questions.
  • Update the iOS app when the presenter answers a question.

You’ll address these issues next.

Storing the Data

To store the data, open the websocket-backend project and open WebSocketController.swift. Above onData, add a new function called onNewQuestion and add the following code to it:

  func onNewQuestion(_ ws: WebSocket, _ id: UUID, _ message: NewQuestionMessage) {
    let q = Question(content: message.content, askedFrom: id)
    self.db.withConnection {
      // 1
      q.save(on: $0)
    }.whenComplete { res in
      let success: Bool
      let message: String
      switch res {
      case .failure(let err):
        // 2
        self.logger.report(error: err)
        success = false
        message = "Something went wrong creating the question."
      case .success:
        // 3
        self.logger.info("Got a new question!")
        success = true
        message = "Question created. We will answer it as soon as possible :]"
      }
      // 4
      try? self.send(message: NewQuestionResponse(
        success: success,
        message: message,
        id: q.requireID(),
        answered: q.answered,
        content: q.content,
        createdAt: q.createdAt
      ), to: .socket(ws))
    }
  }

Here’s what’s going on:

  1. First, you create a Question model instance and save it to the database.
  2. If saving fails, you log the error and indicate no success.
  3. On the other hand, if saving was successful, you log that a new question came in.
  4. Finally, you send a message to the client indicating you received the question, whether successfully or not.

Next, replace the log line in onData with the following:

    let decoder = JSONDecoder()
    do {
      // 1
      let sinData = try decoder.decode(QnAMessageSinData.self, from: data)
      // 2
      switch sinData.type {
      case .newQuestion:
        // 3
        let newQuestionData = try decoder.decode(NewQuestionMessage.self,
                                                 from: data)
        self.onNewQuestion(ws, sinData.id, newQuestionData)
      default:
        break
      }
    } catch {
      logger.report(error: error)
    }

This will:

  1. Decode QnAMessageSinData to see what type of message came in.
  2. Switch on the type. Currently the server only accepts one type of incoming message.
  3. Decode the full NewQuestionMessage and pass it to onNewQuestion.

These two functions will ensure you store all received questions in the database, and that you send a confirmation back to the clients.

Connecting to the Server 2: Connect Harder

Remember there was a local array in the iOS app that stored the asked questions? It’s time to get rid of that! Bonus points if you got the “Die Hard” reference. :]

Open WebSocketController.swift in the Conference Q&A Xcode project. At the top of WebSocketController, add the following:

  @Published var questions: [UUID: NewQuestionResponse]

This dictionary will hold your questions.

Next, you have to set an initial value in the init. Add this to the top:

  self.questions = [:]

Next, find handleQuestionResponse(_:) and replace the TODO with the following:

    // 1
    let response = try decoder.decode(NewQuestionResponse.self, from: data)
    DispatchQueue.main.async {
      if response.success, let id = response.id {
        // 2
        self.questions[id] = response
        let alert = Alert(title: Text("New question received!"),
                          message: Text(response.message),
                          dismissButton: .default(Text("OK")) { self.alert = nil })
        self.alert = alert
      } else {
        // 3
        let alert = Alert(title: Text("Something went wrong!"),
                          message: Text(response.message),
                          dismissButton: .default(Text("OK")) { self.alert = nil })
        self.alert = alert
      }
    }

Here’s what this code does:

  1. It decodes the full NewQuestionResponse message.
  2. If the response was successful, it stores the response in the questions dictionary and displays a positive alert.
  3. If the response fails, it displays an error alert.

Now, your next step is to connect the UI to this new data source. Open ContentView.swift and replace List inside body with the following:

      List(socket.questions.map { $1 }.sorted(), id: \.id) { q in
        VStack(alignment: .leading) {
          Text(q.content)
          Text("Status: \(q.answered ? "Answered" : "Unanswered")")
            .foregroundColor(q.answered ? .green : .red)
        }
      }

This code is mostly the same as the previous version, but it uses the socket as its data source and takes the answered property of the question into account.

Also, make sure to remove the @State var questions at the top of ContentView and the temporary self.questions.append.

With this done, build and run the server and the iOS app. The handshake will happen again, just like before.

Now, send a question. The server will no longer log the raw JSON, but instead log [ INFO ] Got a new question!. You will also see the success alert appear in the iOS app.

Awesome!