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