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 3 of 5 of this article. Click here to view the first page.

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