Using AWS as a Back End: The Data Store API

In this tutorial, you’ll extend the Isolation Nation app from the previous tutorial, adding analytics and real-time chat functionality using AWS Pinpoint and AWS Amplify DataStore. By Tom Elliott.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

Loading Threads

It may not feel like it, but your chat app is starting to come together! Your app now has:

  • Authenticated users
  • User locations
  • Threads with the correct users assigned
  • Data stored in the cloud, using DynamoDB

Your next step is to load the correct thread(s) for the user in the Location screen.

Open ThreadsScreenViewModel.swift. At the top of the file, import Amplify:

import Amplify

Then, at the bottom of the file, add the following extension:

// MARK: AWS Model to Model conversions

extension Thread {
  func asModel() -> ThreadModel {
    ThreadModel(id: id, name: name)
  }
}

This extension provides a method on the Amplify-generated Thread model. It returns a view model used by the view. This keeps Amplify-specific concerns out of your UI code!

Next, remove the contents of fetchThreads(), with its hard-coded thread. Replace it with this:

// 1
guard let loggedInUser = userSession.loggedInUser else {
  return
}
let userID = loggedInUser.id

// 2
Amplify.DataStore.query(User.self, byId: userID) { [self] result in
  switch result {
  case .failure(let error):
    logger?.logError("Error occurred: \(error.localizedDescription )")
    threadListState = .errored(error)
    return
  case .success(let user):
    // 3
    guard let user = user else {
      let error = IsolationNationError.unexpectedGraphQLData
      logger?.logError("Error fetching user \(userID): \(error)")
      threadListState = .errored(error)
      return
    }

    // 4
    guard let userThreads = user.threads else {
      let error = IsolationNationError.unexpectedGraphQLData
      logger?.logError("Error fetching threads for user \(userID): \(error)")
      threadListState = .errored(error)
      return
    }

    // 5
    threadList = userThreads.map { $0.thread.asModel() }
    threadListState = .loaded(threadList)
  }
}

Here’s what you’re doing:

  1. You check for a logged-in user.
  2. You use the DataStore query API to query for the user by ID.
  3. After checking for errors from DataStore, you confirm that the user isn’t nil.
  4. You also check that the userThreads array on the user isn’t nil.
  5. Finally, you set the list of threads to display. Then, you update the published threadListState to loaded.

Build and run. Confirm that the Locations list still shows the correct thread.

The Threads screen

Now it’s time to start sending messages between your users!

Note: For the rest of this tutorial, you should have two simulators running. They should have different users in the same thread.

Sending Messages

Your first tasks here are similar to the changes in ThreadsScreenViewModel, above.

Open MessagesScreenViewModel.swift. Add the Amplify import at the top of the file:

import Amplify

At the bottom of the file, add an extension to convert between the Amplify model and a view model:

// MARK: AWS Model to Model conversions

extension Message {
  func asModel() -> MessageModel {
    MessageModel(
      id: id,
      body: body,
      authorName: author.username,
      messageThreadId: thread?.id,
      createdAt: createdAt.foundationDate
    )
  }
}

Then, remove the contents of fetchMessages(). You won’t be needing these hard-coded messages once you can create real ones! Replace the contents with a proper query from DataStore:

// 1
Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
  switch threadResult {
  case .failure(let error):
    logger?
      .logError("Error fetching messages for thread \(threadID): \(error)")
    messageListState = .errored(error)
    return

  case .success(let thread):
    // 2
    messageList = thread?.messages?.sorted { $0.createdAt < $1.createdAt }
      .map({ $0.asModel() }) ?? []
    // 3
    messageListState = .loaded(messageList)
  }
}

This is what you're doing here:

  1. First, you query for the Thread by its ID.
  2. After checking for errors, you retrieve the messages connected to the thread. You map them to a list of MessageModels. It's easy to access connected objects using the DataStore API. You simply access them — the data will be lazy-loaded from the back-end store as required.
  3. Finally, you set messageListState to loaded.

Build and run. Tap the thread to view the list of messages. Now the list is empty.

Empty messages screen

At the bottom of the screen, there's a text box where users can type their requests for help. When the user taps Send, the view will call perform(action:) on the view model. This forwards the request to addMessage(input:).

Still in MessagesScreenViewModel.swift, add the following implementation to addMessage(input:):

// 1
guard let author = userSession.loggedInUser else {
  return
}

// 2
Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
  switch threadResult {
  case .failure(let error):
    logger?.logError("Error fetching thread \(threadID): \(error)")
    messageListState = .errored(error)
    return

  case .success(let thread):
    // 3
    var newMessage = Message(
      author: author, 
      body: input.body, 
      createdAt: Temporal.DateTime.now())
    // 4
    newMessage.thread = thread
    // 5
    Amplify.DataStore.save(newMessage) { saveResult in
      switch saveResult {
      case .failure(let error):
        logger?.logError("Error saving message: \(error)")
        messageListState = .errored(error)
      case .success:
        // 6
        messageList.append(newMessage.asModel())
        messageListState = .loaded(messageList)
        return
      }
    }
  }
}

This implementation is starting to look pretty familiar! This is what you're doing:

  1. You start by checking for a logged-in user to act as the author.
  2. Then, you query for the thread in the data store.
  3. Next, you create a new message, using the values from input.
  4. You set thread as the owner of newMessage.
  5. You save the message to the data store.
  6. Finally, you append the message to the view model's messageList and publish messageListState to update the API.

Build and run on both simulators and tap the Messages screen. Create a new message on one simulator and... hurrah! A message appears on the screen.

Showing a message

In your browser, open the Message table in the DynamoDB tab. Confirm that the message has been saved to the Cloud.

Viewing a message in DynamoDB

Your new message appears — but only on the simulator you used to create it. On the other simulator, tap back and then re-enter the thread. The message will now appear. This works, obviously, but it's not very real-time for a chat app!

Subscribing to Messages

Fortunately, DataStore comes with support for GraphQL Subscriptions, the perfect solution for this sort of problem.

Open MessagesScreenViewModel.swift and locate subscribe(). Just before the method, add a property to store an AnyCancellable?:

var fetchMessageSubscription: AnyCancellable?

Next, add a subscription completion handler:

private func subscriptionCompletionHandler(
  completion: Subscribers.Completion<DataStoreError>
) {
  if case .failure(let error) = completion {
    logger?.logError("Error fetching messages for thread \(threadID): \(error)")
    messageListState = .errored(error)
  }
}

This code sets the messageListState to an error state if the subscription completes with an error.

Finally, add the following implementation to subscribe():

// 1
fetchMessageSubscription = Amplify.DataStore.publisher(for: Message.self)
  // 2
  .receive(on: DispatchQueue.main)
  .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
    do {
      // 3
      let message = try changes.decodeModel(as: Message.self)

      // 4
      guard 
        let messageThreadID = message.thread?.id, 
        messageThreadID == threadID 
        else {
          return
      }

      // 5
      messageListState = .updating(messageList)
      // 6
      let isNewMessage = messageList.filter { $0.id == message.id }.isEmpty
      if isNewMessage {
        messageList.append(message.asModel())
      }
      // 7
      messageListState = .loaded(messageList)
    } catch {
      logger?.logError("\(error.localizedDescription)")
      messageListState = .errored(error)
    }
  }

Here's how you're implementing your message subscription:

  1. You use the publisher API from DataStore to listen for changes from Message models. The API will be called whenever a GraphQL subscription is received from AppSync or whenever a local change is made to your data store.
  2. You subscribe to the publisher on the main queue.
  3. If successful, you decode the Message object from the change response.
  4. You check to make sure this message is for the same thread that the app is displaying. Sadly, DataStore does not currently allow you to set up a subscription with a predicate.
  5. You set messageListState to updating, and you publish it to the UI.
  6. You check that this message is new. If so, you append it to messageList.
  7. Finally, you update messageListState to loaded.

Again, build and run on both simulators. Tap the messages list on both and send a message from one. Note how the message appears instantly on both devices.

Subscriptions in Action

Now that's a real-time chat app! :]