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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Using AWS as a Back End: The Data Store API
40 mins
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.
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
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.
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.
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:
- First, you create a new method,
identifyUserToAnalyticsService(_:)
, which takes anAuthUser
object. - 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
. - 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.
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. :]
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:
- First, set
userPostcodeState
toloading
. - Then, fetch the user from DynamoDB.
- Dispatch to the main queue, as you should always modify published vars from the main thread.
- Check for errors in the usual manner.
- If the request is successful, check to see if the user model has a postcode set. If not, set
userPostcodeState
tonil
. - If so, set
userPostcodeState
toloaded
, 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.
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:
- You call
Amplify.API.query
to request the user by ID in the usual manner. - If successful, you modify the fetched user model by setting the postcode to the value entered by the user.
- Then you call
Amplify.API.mutate
to mutate the existing model. - You handle the response. Then you switch to the main thread again and check for failures.
- 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.
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.
Select the ID of the user for whom you just added a postcode. Notice that the postcode field now appears in the record.
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:
- You define a
Thread
type. You use the@model
directive to tell AppSync to create a DynamoDB table for this model. - 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 aThread
- You add
messages
to yourThread
model.messages
contains an array ofMessage
types. You use the@connection
directive to specify a one-to-many connection between aThread
and itsMessages
. You’ll learn more about this later. - You add an
associated
field which contains an array ofUserThread
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:
- You identify a key for each side of the model. The first field in the
fields
array defines the hash key for thiskey
, and the second field is the sort key. - For each type in the connection, you specify an
ID
field to hold the join data. - 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.
Then open the DynamoDB tab in your browser, and confirm that tables also exist for each type.
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:
- First, you receive
User
andThread
models and return aFuture
. - Next, you create a
UserThread
model linking the user and thread. - You use the
Amplify.DataStore.save
API to save theUserThread
. - 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:
- You start by building a predicate for querying threads. In this case, you want to query for threads with a given location.
- 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 thequery
API takes the predicate from above as a second argument. - After the usual dance to check for errors, you inspect the value returned from the API.
- If the API didn’t return a thread, you create one, using the method you wrote earlier.
- 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:
- You receive
User
andlocation
. - You call
fetchOrCreateThreadWithLocation(location:)
, which returns athread
. - You then call
addUser(_:to:)
, which creates aUserThread
row in your data store. - Lastly, you set
userPostcocdeState
toloaded
.
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:
- First, you use the DataStore
save
API to save the user locally. - After handling errors, you check that the postcode has a valid postcode area.
- 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.
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.
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:
- You check for a logged-in user.
- You use the DataStore query API to query for the user by ID.
- After checking for errors from DataStore, you confirm that the user isn’t
nil
. - You also check that the
userThreads
array on theuser
isn’tnil
. - Finally, you set the list of threads to display. Then, you update the published
threadListState
toloaded
.
Build and run. Confirm that the Locations list still shows the correct thread.
Now it’s time to start sending messages between your users!
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:
- First, you query for the
Thread
by its ID. - After checking for errors, you retrieve the messages connected to the thread. You map them to a list of
MessageModel
s. 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. - Finally, you set
messageListState
toloaded
.
Build and run. Tap the thread to view the list of messages. Now the list is empty.
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:
- You start by checking for a logged-in user to act as the author.
- Then, you query for the thread in the data store.
- Next, you create a new message, using the values from
input
. - You set
thread
as the owner ofnewMessage
. - You save the message to the data store.
- Finally, you append the message to the view model's
messageList
and publishmessageListState
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.
In your browser, open the Message table in the DynamoDB tab. Confirm that the message has been saved to the Cloud.
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:
- You use the
publisher
API from DataStore to listen for changes fromMessage
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. - You subscribe to the publisher on the main queue.
- If successful, you decode the
Message
object from the change response. - 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.
- You set
messageListState
toupdating
, and you publish it to the UI. - You check that this message is new. If so, you append it to
messageList
. - Finally, you update
messageListState
toloaded
.
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.
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? :]
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! :]
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more