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