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

In this tutorial, you’ll pick up the Isolation Nation app where you left off at the end of Part 1, Authentication & API. You’ll extend the app, using AWS Pinpoint and AWS Amplify DataStore to add analytics and real-time chat functionality.

Before you begin, log in to the AWS Console.

You can use the project you created in the first tutorial as the starting point for this next one. However, if you are using the starter project from this tutorial, you will need to pull your Amplify back-end configuration in to the starter project’s Xcode workspace.

From the Amplify console for your project, select the Backend environments tab, click the Edit backend link and then Copy the pull command.

Pulling your project configuration from AWS

Navigate to the starter project in your terminal and paste the command you just copied from the Amplify console. When prompted, select the profile you set up before, if applicable. Select no default editor, an iOS app, and answer Y to modifying the back end from the list of options in the terminal.

Next generate the Amplify configuration files by running the following command:

amplify-app --platform ios

Open the IsolationNation.xcworkspace file in Xcode and edit the amplifytools.xcconfig file to reflect the following settings:

push=true
modelgen=true
profile=default
envName=dev
Note: A quick and easy way to open the correct file is enter the command:
xed .

Finally, run the following command in Terminal:

pod update

When the update finishes, build your app. You may need to build it twice, as Amplify needs to generate the User model files the first time around.

Note: Although you’re encouraged to follow the previous tutorial first, it’s not necessary. If you’re starting afresh, download the project materials using the Download Materials button at the top or bottom of this tutorial. Before you start, you must set up AWS Amplify on your computer. And you must add a new Amplify project with both Cognito Auth and an AppSync API with a user model. See the instructions in the previous tutorial.

Getting Started

Isolation Nation is an app for people who are self-isolating due to COVID-19. It lets them request help from others in their local community. At the end of the previous tutorial, the app was using AWS Cognito to allow users to sign up and log in to the app. It used AWS AppSync to read and write public user data about the user.

Now build and run. If you already completed the previous tutorial, confirm that you can still log in with the user account(s) you created before. If you’re starting here, sign up a new user.

App Analytics

Analyzing how people use your app in real life is an important part of the process of building a great product. AWS Pinpoint is a service providing analytics and marketing capabilities to your app. In this section, you’ll learn how to record user actions for future analysis.

To begin, open a terminal at the root of your project. Use the Amplify CLI to add analytics capability to your project:

amplify add analytics

When prompted, select Amazon Pinpoint and press Enter to accept the default resource name. Type Y to accept the recommended default for authorization.

Adding analytics via the CLI

Next, open your workspace in Xcode and open Podfile. Insert the following code before the line containing end:

pod 'AmplifyPlugins/AWSPinpointAnalyticsPlugin'

This adds the AWS Pinpoint plugin as a dependency of your app. Install the plugin by switching to your terminal and running the following:

pod install --repo-update 

Back in Xcode, open AppDelegate.swift and add the Pinpoint plugin to the Amplify configuration. In application(_:didFinishLaunchingWithOptions:), add the following line directly before the call to Amplify.configure():

try Amplify.add(plugin: AWSPinpointAnalyticsPlugin())

Your app is now configured to send analytics data to AWS Pinpoint.

Tracking Users and Sessions

Tracking user sessions with Amplify is simple. In fact, it couldn’t be any easier, as you don’t have to do anything! :] Just installing the plugin will automatically record when the app opens and closes.

But, to be really useful, you should add user identification to your analytics calls. In Xcode, open AuthenticationService.swift. At the very bottom of the file, add the following extension:

// MARK: AWS Pinpoint

extension AuthenticationService {
  // 1
  func identifyUserToAnalyticsService(_ user: AuthUser) {
    // 2
    let userProfile = AnalyticsUserProfile(
      name: user.username,
      email: nil,
      plan: nil,
      location: nil,
      properties: nil
    )
    // 3
    Amplify.Analytics.identifyUser(user.userId, withProfile: userProfile)
  }
}

In this code, you’re doing several things:

  1. First, you create a new method, identifyUserToAnalyticsService(_:), which takes an AuthUser object.
  2. Then, you create an analytics user profile for the user. For analytics, you only care about the user’s name, so you set the other optional fields to nil.
  3. You finish by calling identifyUser(_:withProfile:). You pass the user’s ID and the user profile you just created. This identifies the user in AWS Pinpoint.

Next, update the method signature for setUserSessionData(_:) to accept an optional AuthUser parameter:

private func setUserSessionData(_ user: User?, authUser: AuthUser? = nil) {

Add the following to the end of the DispatchQueue block in that method:

if let authUser = authUser {
  identifyUserToAnalyticsService(authUser)
}

Now, update the call to setUserSessionData(_:authUser:) in two places. Make the same change at the end of both signIn(as:identifiedBy:) and checkAuthSession():

setUserSessionData(user, authUser: authUser)

You’re now passing the authUser into setUserSessionData. This allows it to call into identifyUserToAnalyticsService(_:).

Build and run. Log in and out with your user several times so you’ll something to see in your Pinpoint analytics.

Next, open your terminal and type the following:

amplify console analytics

This will open a Pinpoint console in your browser showing the Analytics overview for your app’s back end.

The AWS Pinpoint Console

By default, Pinpoint displays aggregated data for the past 30 days. For now, this will almost certainly average to zero. Underneath the title, select Last 30 days. Then, in the pop-up, select today’s date for both the start of the period the end of the period. Click away from the pop-up to close it, and the stats will refresh with today’s data.

In the left-hand menu, select Usage. In the boxes showing active endpoints and active users, you should see some non-zero values. Don’t worry if the counts are still zero — it can take up to 15 minutes for the data to refresh. If this is the case, carry on with the tutorial and check again at the end. :]

AWS Pinpoint Console - User metrics

That’s enough analytics for now. It’s time to get back to building the chat functionality!

Updating Data in DynamoDB

You may have noticed that all your test users have the SW1A location in the Locations list. Instead, your app needs to ask people where they live. Sadly, not everyone can live in Buckingham Palace!

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

import Amplify

HomeScreenViewModel publishes a property called userPostcodeState. This wraps an optional String in a Loading enum.

Navigate to fetchUser(). Note how userPostcodeState is set to .loaded, with a hard-coded associated value of SW1A 1AA. Replace this line with the following:

// 1
userPostcodeState = .loading(nil)
// 2
_ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
  // 3
  DispatchQueue.main.async {
    // 4
    switch event {
    case .failure(let error):
      logger?.logError(error.localizedDescription)
      userPostcodeState = .errored(error)
      return
    case .success(let result):
      switch result {
      case .failure(let resultError):
        logger?.logError(resultError.localizedDescription)
        userPostcodeState = .errored(resultError)
        return
      case .success(let user):
        // 5
        guard 
          let user = user, 
          let postcode = user.postcode 
        else {
          userPostcodeState = .loaded(nil)
          return
        }
        // 6
        userPostcodeState = .loaded(postcode)
      }
    }
  }
}

Here’s what this code is doing:

  1. First, set userPostcodeState to loading.
  2. Then, fetch the user from DynamoDB.
  3. Dispatch to the main queue, as you should always modify published vars from the main thread.
  4. Check for errors in the usual manner.
  5. If the request is successful, check to see if the user model has a postcode set. If not, set userPostcodeState to nil.
  6. If so, set userPostcodeState to loaded, with the user’s postcode as an associated value.

Build and run. This time, when your test user logs in, the app will present a screen inviting the user to enter a postcode.

Enter your Postcode

If you’re wondering how the app decided to display this screen, look in HomeScreen.swift. Notice how that view renders SetPostcodeView if the postcode is nil.

Open SetPostcodeView.swift in the Home group. This is a fairly simple view. TextField collects the user’s postcode. And Button asks the view model to perform the addPostCode action when tapped.

Now, open HomeScreenViewModel.swift again. Find addPostCode(_:) at the bottom of the file and write its implementation:

// 1
_ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
  DispatchQueue.main.async {
    switch event {
    case .failure(let error):
      logger?.logError("Error occurred: \(error.localizedDescription )")
      userPostcodeState = .errored(error)
    case .success(let result):
      switch result {
      case .failure(let resultError):
        logger?
          .logError("Error occurred: \(resultError.localizedDescription )")
        userPostcodeState = .errored(resultError)
      case .success(let user):
        guard var user = user else {
          let error = IsolationNationError.noRecordReturnedFromAPI
          userPostcodeState = .errored(error)
          return
        }

        // 2
        user.postcode = postcode
        // 3 (Replace me later)
        _ = Amplify.API.mutate(request: .update(user)) { event in
          // 4
          DispatchQueue.main.async {
            switch event {
            case .failure(let error):
              logger?
                .logError("Error occurred: \(error.localizedDescription )")
              userPostcodeState = .errored(error)
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger?.logError(
                  "Error occurred: \(resultError.localizedDescription )")
                userPostcodeState = .errored(resultError)
              case .success(let savedUser):
                // 5
                userPostcodeState = .loaded(savedUser.postcode)
              }
            }
          }
        }
      }
    }
  }
}

Again, this looks like a lot of code. But most of it is just checking whether the requests succeeded and handling errors if not:

  1. You call Amplify.API.query to request the user by ID in the usual manner.
  2. If successful, you modify the fetched user model by setting the postcode to the value entered by the user.
  3. Then you call Amplify.API.mutate to mutate the existing model.
  4. You handle the response. Then you switch to the main thread again and check for failures.
  5. If successful, you set userPostcodeState to the saved value.

Build and run again. When presented with the view to collect the user’s postcode, enter SW1A 1AA and tap Update. After a second, the app will present the Locations screen again with the SW1A thread showing in the list.

Entered postcode

Now type the following into your terminal:

amplify console api

When asked, select GraphQL. The AWS AppSync landing page will open in your browser. Select Data Sources. Click the link for the User table, and then select the Items tab.

AWS AppSync Console

Items in DynamoDB

Select the ID of the user for whom you just added a postcode. Notice that the postcode field now appears in the record.

Viewing the Postcode in the User record in DynamoDB

Open the record for your other user and note how the field is completely absent. This is an important feature of key-value databases like DynamoDB. They allow a flexible schema, which can be very useful for fast iterations of a new app. :]

In this section, you’ve added a GraphQL schema. You used AWS AppSync to generate a back end declaratively from that schema. You also used AppSync to read and write data to the underlying DynamoDB.

Designing a Chat Data Model

So far, you have an app with cloud-based login. It also reads and writes a user record to a cloud-based database. But it’s not very exciting for the user, is it? :]

It’s time to fix that! In the rest of this tutorial, you’ll be designing and building the chat features for your app.

Open schema.graphql in the AmplifyConfig group. Add the following Thread model to the bottom of the file:

# 1
type Thread
  @model
  # 2
  @key(
    fields: ["location"], 
    name: "byLocation", 
    queryField: "ThreadByLocation")
{
  id: ID!
  name: String!
  location: String!
  # 3
  messages: [Message] @connection(
    name: "ThreadMessages", 
    sortField: "createdAt")
  # 4
  associated: [UserThread] @connection(keyName: "byThread", fields: ["id"])
  createdAt: AWSDateTime!
}

Running through the model, this is what you’re doing:

  1. You define a Thread type. You use the @model directive to tell AppSync to create a DynamoDB table for this model.
  2. You add the @key directive, which adds a custom index in the DynamoDB database. In this case, you’re specifying that you want to be able to query for a Thread
  3. You add messages to your Thread model. messages contains an array of Message types. You use the @connection directive to specify a one-to-many connection between a Thread and its Messages. You’ll learn more about this later.
  4. You add an associated field which contains an array of UserThread objects. To support many-to-many connections in AppSync, you need to create a joining model. UserThread is the joining model to support the connection between users and threads.

Next, add the type definition for the Message type:

type Message
  @model
{
  id: ID!
  author: User! @connection(name: "UserMessages")
  body: String!
  thread: Thread @connection(name: "ThreadMessages")
  replies: [Reply] @connection(name: "MessageReplies", sortField: "createdAt")
  createdAt: AWSDateTime!
}

As you might expect, the Message type has a connection to the author, of type User. It also has connections to the Thread and any Replies for that Message. Note that the name for the Thread @connection matches the name provided in the thread type.

Next, add the definition for replies:

type Reply
  @model
{
  id: ID!
  author: User! @connection(name: "UserReplies")
  body: String!
  message: Message @connection(name: "MessageReplies")
  createdAt: AWSDateTime!
}

Nothing new here! This is similar to Message, above.

Now add the model for our UserThread type:

type UserThread
  @model
  # 1
  @key(name: "byUser", fields: ["userThreadUserId", "userThreadThreadId"])
  @key(name: "byThread", fields: ["userThreadThreadId", "userThreadUserId"])
{
  id: ID!
  # 2
  userThreadUserId: ID!
  userThreadThreadId: ID!
  # 3
  user: User! @connection(fields: ["userThreadUserId"])
  thread: Thread! @connection(fields: ["userThreadThreadId"])
  createdAt: AWSDateTime!
}

When creating a many-to-many connection with AppSync, you don’t create the connection on the types directly. Instead, you create a joining model. For your joining model to work, you must provide several things:

  1. You identify a key for each side of the model. The first field in the fields array defines the hash key for this key, and the second field is the sort key.
  2. For each type in the connection, you specify an ID field to hold the join data.
  3. You also provide a field of each type. This field uses the @connection directive to specify that the ID field from above is to be used to connect to the type.

Finally, add the following connections to the User type after the postcode so your users will have access to their data:

threads: [UserThread] @connection(keyName: "byUser", fields: ["id"])
messages: [Message] @connection(name: "UserMessages")
replies: [Reply] @connection(name: "UserReplies")

Build and run. This will take some time, as the Amplify Tools plugin is doing a lot of work:

  • It notices all the new GraphQL types.
  • It generates Swift models for you.
  • And it updates AppSync and DynamoDB in the cloud.

When the build is complete, look at your AmplifyModels group. It now contains model files for all the new types.

New generated models in Swift

Then open the DynamoDB tab in your browser, and confirm that tables also exist for each type.

New tables in DynamoDB

You now have a data model, and it’s reflected both in your code and in the cloud!

Amplify DataStore

Earlier, you learned how to use the Amplify API to read and write data via AppSync. Amplify also offers DataStore. DataStore is a more sophisticated solution for syncing data with the cloud.

The primary benefit of Amplify DateStore is that it creates and manages a local database on the mobile device. DataStore stores all the model data fetched from the cloud, right on your phone!

This lets you query and mutate data without an internet connection. DataStore syncs the changes when your device comes back online. Not only does this allow offline access, but it also means your app feels snappier to the user. This is because you don’t have to wait for a round trip to the server before displaying updates in your UI.

The programming model for interacting with DataStore is a little different from the one for the Amplify API. When using the API, you can be sure that any results returned are the latest stored in the DynamoDB. By comparison, DataStore will return local results immediately! It then fires off a request to update its local cache in the background. If you want to display the latest information, your code must either subscribe to updates or query the cache a second time.

This makes the Amplify API a better solution if you want to make a decision based on the presence or absence of some data. For example, should I display the postcode input screen or not? But DataStore is a better abstraction for providing a rich user experience. For this reason, the chat functionality in your app will use DataStore.

To get started with DataStore, open Podfile and add the dependency:

pod 'AmplifyPlugins/AWSDataStorePlugin'

Then, in your terminal, install it in the normal manner:

pod install --repo-update

Next, open AppDelegate.swift and locate application(_:didFinishLaunchingWithOptions:). Add the following configuration code before the call to Amplify.configure():

try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: AmplifyModels()))

You now have DataStore installed in your app! Next, you’ll use it to store data locally.

Writing Data to DataStore

Isolation Nation allows people who live near each other to request assistance. When a user changes postcode, the app needs to check if a Thread already exists for that postcode area. If not, it must create one. Then, it must add the User to the Thread.

Open HomeScreenViewModel.swift. At the bottom of the file, inside the closing brace for the class, add the following method:

// MARK: - Private functions

// 1
private func addUser(_ user: User, to thread: Thread) -> Future<String, Error> {
  return Future { promise in
    // 2
    let userThread = UserThread(
      user: user, 
      thread: thread, 
      createdAt: Temporal.DateTime.now())
    // 3
    Amplify.DataStore.save(userThread) { result in
      // 4
      switch result {
      case .failure(let error):
        promise(.failure(error))
      case .success(let userThread):
        promise(.success(userThread.id))
      }
    }
  }
}

In this method, you use the DataStore API to save a new UserThread record:

  1. First, you receive User and Thread models and return a Future.
  2. Next, you create a UserThread model linking the user and thread.
  3. You use the Amplify.DataStore.save API to save the UserThread.
  4. Finally, you complete the promise with success or failure, as appropriate.

Underneath, add another method to create a new thread in the DataStore:

private func createThread(_ location: String) -> Future<Thread, Error> {
  return Future { promise in
    let thread = Thread(
      name: location, 
      location: location, 
      createdAt: Temporal.DateTime.now())
    Amplify.DataStore.save(thread) { result in
      switch result {
      case .failure(let error):
        promise(.failure(error))
      case .success(let thread):
        promise(.success(thread))
      }
    }
  }
}

This is very similar to the previous example.

Next, create a method to fetch or create the thread, based on location:

private func fetchOrCreateThreadWithLocation(
  location: String
) -> Future<Thread, Error> {
  return Future { promise in
    // 1
    let threadHasLocation = Thread.keys.location == location
    // 2
    _ = Amplify.API.query(
      request: .list(Thread.self, where: threadHasLocation)
    ) { [self] event in
      switch event {
      case .failure(let error):
        logger?.logError("Error occurred: \(error.localizedDescription )")
        promise(.failure(error))
      case .success(let result):
        switch result {
        case .failure(let resultError):
          logger?.logError(
            "Error occurred: \(resultError.localizedDescription )")
          promise(.failure(resultError))
        case .success(let threads):
          // 3
          guard let thread = threads.first else {
            // Need to create the Thread
            // 4
            _ = createThread(location).sink(
              receiveCompletion: { completion in
                switch completion {
                case .failure(let error): promise(.failure(error))
                case .finished:
                  break
                }
              },
              receiveValue: { thread in
                promise(.success(thread))
              }
            )
            return
          }
          // 5
          promise(.success(thread))
        }
      }
    }
  }
}

Here’s what this code does:

  1. You start by building a predicate for querying threads. In this case, you want to query for threads with a given location.
  2. Then you use Amplify.API to query for a thread with the location provided. You’re using the Amplify API here, not DataStore. This is because you want to know immediately whether the thread already exists. Note that this form of the query API takes the predicate from above as a second argument.
  3. After the usual dance to check for errors, you inspect the value returned from the API.
  4. If the API didn’t return a thread, you create one, using the method you wrote earlier.
  5. Otherwise, you return the thread received from the API query.

And now, add one final method:

// 1
private func addUser(_ user: User, to location: String) {
  // 2
  cancellable = fetchOrCreateThreadWithLocation(location: location)
    .flatMap { thread in
      // 3
      return self.addUser(user, to: thread)
    }
    .receive(on: DispatchQueue.main)
    .sink(
      receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
          self.userPostcodeState = .errored(error)
        case .finished:
          break
        }
    },
      receiveValue: { _ in
        // 4
        self.userPostcodeState = .loaded(user.postcode)
    }
    )
}

Here you orchestrate calls to the methods you just created:

  1. You receive User and location.
  2. You call fetchOrCreateThreadWithLocation(location:), which returns a thread.
  3. You then call addUser(_:to:), which creates a UserThread row in your data store.
  4. Lastly, you set userPostcocdeState to loaded.

Finally, you need to update addPostCode() to extract the location from the postcode and use it to call addUser(_:to:). Find the // 3 (Replace me later) comment. Remove the mutate call, and replace it with this:

// 1
Amplify.DataStore.save(user) { [self] result in
  DispatchQueue.main.async {
    switch result {
    case .failure(let error):
      logger?.logError("Error occurred: \(error.localizedDescription )")
      userPostcodeState = .errored(error)
    case .success:
      // Now we have a user, check to see if there is a Thread already created
      // for their postcode. If not, create it.
      // 2
      guard let location = postcode.postcodeArea() else {
        logger?.logError("""
          Could not find location within postcode \
          '\(String(describing: postcode))'. Aborting.
          """
        )
        userPostcodeState = .errored(
          IsolationNationError.invalidPostcode
        )
        return
      }
      // 3
      addUser(user, to: location)
    }
  }
}

Here’s what you’re doing:

  1. First, you use the DataStore save API to save the user locally.
  2. After handling errors, you check that the postcode has a valid postcode area.
  3. Then you add the user to that location, using the method you wrote earlier.

Before running your app, open the DynamoDB tab in your browser. Find the postcode you set earlier for the test user. Since you didn’t create a thread at the time, that data is now dangerous! To remove it, click the gray plus icon to the left of the field. Then click Remove.

Removing the Postcode from the DynamoDB record

Build and run. Because you removed the postcode, the app displays the “enter postcode” screen. Enter the same postcode as before, SW1A 1AA, and tap Update.

You’ll see the Locations screen, with the correct location displayed at the top of the list.

In your browser, go to your DynamoDB tab and open the User table. Refresh the page. Click the link for your user and confirm that the postcode has indeed been set. Open the Thread and UserThread tables and confirm that records are present there as well.

Now build and run on your other simulator. When prompted, enter the same postcode as before, SW1A 1AA. Head back to your browser and confirm that the postcode has been set for your other User. You should also see another UserThread record, but without a new Thread.

Viewing user threads in Dynamo DB

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! :]

Replying to Messages

The changes needed for replying to messages are almost identical to those for sending messages. If you want to build a fully-functional chat app, then read on! You'll cover the ground quickly, since it's so similar to the coding above. But if you're more interested in learning, feel free to skip this section.

Open RepliesScreenViewModel.swift and import Amplify at the top of the file:

import Amplify

Next, add the model conversion code as an extension at the bottom:

// MARK: AWS Model to Model conversions

extension Reply {
  func asModel() -> ReplyModel {
    return ReplyModel(
      id: id,
      body: body,
      authorName: author.username,
      messageId: message?.id,
      createdAt: createdAt.foundationDate
    )
  }
}

Replace the stub implementation in fetchReplies() with a DataStore query:

Amplify.DataStore
  .query(Message.self, byId: messageID) { [self] messageResult in
  switch messageResult {
  case .failure(let error):
    logger?.
      logError("Error fetching replies for message \(messageID): \(error)")
    replyListState = .errored(error)
    return

  case .success(let message):
    self.message = message?.asModel()
    replyList = message?.replies?.sorted { $0.createdAt < $1.createdAt }
      .map({ $0.asModel() }) ?? []
    replyListState = .loaded(replyList)
  }
}

In addReply(), add an implementation to create a reply:

guard let author = userSession.loggedInUser else {
  return
}

Amplify.DataStore.query(Message.self, byId: messageID) { [self] messageResult in
  switch messageResult {
  case .failure(let error):
    logger?.logError("Error fetching message \(messageID): \(error)")
    replyListState = .errored(error)
    return

  case .success(let message):
    var newReply = Reply(
      author: author, 
      body: input.body, 
      createdAt: Temporal.DateTime.now())
    newReply.message = message
    Amplify.DataStore.save(newReply) { saveResult in
      switch saveResult {
      case .failure(let error):
        logger?.logError("Error saving reply: \(error)")
        replyListState = .errored(error)
      case .success:
        replyList.append(newReply.asModel())
        replyListState = .loaded(replyList)
        return
      }
    }
  }
}

Add the scaffolding for handling subscriptions:

var fetchReplySubscription: AnyCancellable?

private func subscriptionCompletionHandler(
  completion: Subscribers.Completion<DataStoreError>
) {
  if case .failure(let error) = completion {
    logger?.logError("Error fetching replies for message \(messageID): \(error)")
    replyListState = .errored(error)
  }
}

Finally, implement subscribe():

fetchReplySubscription = Amplify.DataStore.publisher(for: Reply.self)
  .receive(on: DispatchQueue.main)
  .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
    do {
      let reply = try changes.decodeModel(as: Reply.self)

      guard 
        let replyMessageID = reply.message?.id, 
        replyMessageID == messageID 
      else {
        return
      }

      replyListState = .updating(replyList)
      let isNewReply = replyList.filter { $0.id == reply.id }.isEmpty
      if isNewReply {
        replyList.append(reply.asModel())
      }
      replyListState = .loaded(replyList)
    } catch {
      logger?.logError("\(error.localizedDescription)")
      replyListState = .errored(error)
    }
  }

Whoa, that was speedy!

Build and run on both simulators. Tap the thread to see the messages, then tap a message to view the replies. Send some replies back and forth between your users. Isn't it lovely how well they get along? :]

Replies with subscriptions in action

Congratulations! You have a working chat app!

Where to Go From Here?

In this two-part tutorial series, you've created a fully-functioning chat app using AWS Amplify as a back end. Here are some links to documentation that will help you lock down the knowledge you've gained in this tutorial:

If you'd like to review the complete code, download the final project using the Download Materials button at the top or bottom of this article.

You can learn more about Amplify from the Amplify Docs. These include libraries for the web and Android. If you want to add extra functionality to your app, you could look into using S3 to save static data like user images. Or you could use the @auth GraphQL directive to add object-level or field-level authentication to your model data.

AWS contains a bewildering number of services and products to help you build your dream app, and you've only just scratched the surface. Good luck! :]