Part 3: Run AmazonSwiftStarter with the configured AWS services using the AWS iOS SDK.
In the first post I introduced the AmazonSwiftStarter app with a simulated backend. In the second post we did set up the AWS services for the app. In this post we will connect the app with the AWS services, and let everything work together smoothly.
Disclaimer: My goal is to share my AWS “adventures” to help others, and to learn from your feedback. AWS can be pretty overwhelming for beginners. Therefore I like to keep things as simple as possible. My most important goal is to research the features I need for my commercial app, and prototype them in this demo app. The demo app will definitely not be of production quality!
Tutorial overview
Part 1: Introduction to the AmazonSwiftStarter demo app and a pattern for backend abstraction
Part 2: Configure AWS Services (Cognito, DynamoDB, S3, IAM) using the AWS Console
Part 3: Run AmazonSwiftStarter with the configured AWS services using the AWS iOS SDK
Configure the app
Download the code (Swift2, Xcode7) for this blog post from Github.
Update October 4, 2016: Code for Swift3, Xcode8
The first thing to do is to let the app point to the AWS services that you configured earlier. While configuring AWS you had to write down some identifiers. This is where you need them. Open the AmazonSwiftStarter.xcworkspace in Xcode and open the file AMZConstants.swift (in group AmazonService). Replace the placeholders with your own identifiers.
1 2 3 4 5 6 7 8 9 |
class AMZConstants { static let COGNITO_REGIONTYPE = AWSRegionType.EUWest1 // Replace by your own static let COGNITO_IDENTITY_POOL_ID = "YOUR_COGNITO_IDENTITY_POOL_ID" // Replace by your own static let DEFAULT_SERVICE_REGION = AWSRegionType.EUWest1 // Replace by your own static let S3BUCKET_USERS = "YOUR_S3BUCKET_USERS" // Replace by your own static let DYNAMODB_USERS_TABLE = "YOUR_DYNAMODB_USERS_TABLE" // Replace by your own } |
Run the app
If the AmazonSwiftStarter app (version 1) is still installed on your simulator, you might better delete it. This ensures that we are starting from scratch with this new version (3) of the app.
Now build and run the app. If everything goes well you’ll see the Sign In Button, as usual. The app behaves almost the same as described in the first blog post. The biggest difference is that this version of the app uses the AWS services that you configured instead of the simulation.
There is another difference: Stop the running app and relaunch it. Now you are automatically signed in! The app is aware that you have signed in before. It shows a different welcome screen, as you can see below. I will deal with the details of this behaviour in the Code Walkthrough section.
Note that I deliberately do not use any local persistent caching of data. As an example let’s consider the user profile (name and photo). At every launch the app fetches this data from AWS on the background, and keeps it in memory without persisting. In a real app you would typically have persistent storage of this data locally. There is no need to fetch it from the server all the time.
Code Walkthrough
Let’s take a look at the project files:
The WelcomeViewController displays the welcome screen. The logic that interacts with AWS has been implemented in the files AMZRemoteService.swift and AMZUser.swift.
WelcomeViewController
As mentioned, the UI of the welcome screen depends on the signed-in status of the user. This is implemented as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
override func viewDidAppear(animated: Bool) { super.viewDidAppear(true) let service = RemoteServiceFactory.getDefaultService() if service.hasCurrentUserIdentity { state = .FetchingUserProfile service.fetchCurrentUser({ (userData, error) -> Void in if let error = error { print(error) } dispatch_async(dispatch_get_main_queue(), { () -> Void in self.state = .FetchedUserProfile }) }) } else { state = .Welcome } } |
The service has a new property: hasCurrentUserIdentity. The WelcomeViewController reads this property to determine what to do. I think the code is self descriptive. Setting the property ‘state’ of the WelcomeViewController adapts the UI of the welcome screen automatically.
AMZUser
In line 2 you see we are importing AWSDynamoDB. This module is part of the AWS-iOS-SDK. It’s time to explore the AWS API’s.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
import Foundation import AWSDynamoDB class AMZUser: AWSDynamoDBObjectModel ,AWSDynamoDBModeling, UserData { var userId: String? var name: String? // This attribute is not stored in dynamoDB, see ignoreAttributes(). We will store the image in S3. var imageData: NSData? // I discovered that when you try to save an item to DynamoDB with only a primary key and without any other attribute the item is not saved. // I think this is a bug. As an ugly workaround I have added this property :( // Might be related to this bug report on the aws-sdk-net: https://github.com/aws/aws-sdk-net/issues/106 var dum = "@" convenience init(userId: String) { self.init() self.userId = userId } static func dynamoDBTableName() -> String! { return AMZConstants.DYNAMODB_USERS_TABLE } // This is the primary key that you configured while setting up the DynamoDB service. static func hashKeyAttribute() -> String! { return "userId" } // not stored in dynamoDB static func ignoreAttributes() -> [AnyObject]! { return ["imageData"] } } |
First thing to notice is that AMZUser conforms to the UserData protocol. If you read part 1 you will understand why. What’s new here is that AMZUserData extends AWSDynamoDBObjectModel and conforms to AWSDynamoDBModeling. This enables the AWS-iOS-SDK to save our UserData to DynamoDB. We do not have to do any mapping or transformation to JSON or whatever format. It just works, the AWS SDK will take care of that.
AMZRemoteService.
This is where we used to have the backend simulation. The AWS implementation has a few more lines of code compared to the simulation 🙂 . I will walk through some code snippets first, or you can view the full code listing.
Configuration of the AWS client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
static func defaultService() -> RemoteService { if sharedInstance == nil { sharedInstance = AMZRemoteService() sharedInstance!.configure() } return sharedInstance! } func configure() { identityProvider = AWSCognitoCredentialsProvider( regionType: AMZConstants.COGNITO_REGIONTYPE, identityPoolId: AMZConstants.COGNITO_IDENTITY_POOL_ID) let configuration = AWSServiceConfiguration( region: AMZConstants.DEFAULT_SERVICE_REGION, credentialsProvider: identityProvider) AWSServiceManager.defaultServiceManager().defaultServiceConfiguration = configuration // The api I am using for uploading to and downloading from S3 (AWSS3TransferManager)can not deal with NSData directly, but uses files. // I need to create tmp directories for these files. deviceDirectoryForUploads = createLocalTmpDirectory("upload") deviceDirectoryForDownloads = createLocalTmpDirectory("download") } |
The AWS client needs some configuration in order to be able to access your services. You will recognise the constants that you have edited earlier. Note that AMZRemoteService is a singleton, that is configured when the sharedInstance is created. This guarantuees that the configuration is only done once.
Creating a user:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
func createCurrentUser(userData: UserData? , completion: ErrorResultBlock ) { precondition(currentUser == nil, "currentUser should not exist when createCurrentUser(..) is called") precondition(userData == nil || userData!.userId == nil, "You can not create a user with a given userId. UserId's are assigned automatically") precondition(persistentUserId == nil, "A persistent userId should not yet exist") guard let identityProvider = identityProvider else { preconditionFailure("No identity provider available, did you forget to call configure() before using AMZRemoteService?") } // This covers the scenario that an app was deleted and later reinstalled. The goal is to create a new identity and a new user profile for this use case. By default, Cognito stores a Cognito identity in the keychain. This identity survives app uninstalls, so there can be an identity left from a previous app install. When we detect this scenario we remove all data from the keychain, so we can start from scratch. if identityProvider.identityId != nil { identityProvider.clearKeychain() assert(identityProvider.identityId == nil) } // Create a new Cognito identity let task: AWSTask = identityProvider.getIdentityId() task.continueWithBlock { (task) -> AnyObject? in if let error = task.error { completion(error: error) } else { // The new cognito identity token is now stored in the keychain. // Create a new empty user object of type AMZUser var newUser = AMZUser() // Copy the data from the parameter userData if let userData = userData { newUser.updateWithData(userData) } // create a unique ID for the new user newUser.userId = NSUUID().UUIDString // Now save the data on AWS. This will save the image on S3, the other data in DynamoDB self.saveAMZUser(newUser) { (error) -> Void in if let error = error { completion(error: error) } else { // Here we can be certain that the user was saved on AWS, so we set the local user property self.currentUser = newUser self.persistentUserId = newUser.userId completion(error: nil) } } } return nil } } |
createCurrentUser(..) is called when the app is launched for the first time. The Cognito identityProvider requests an identity for the user (lines 17-18). The AWS-iOS-SDK will store the returned identity in the keychain. From now on the user of this app will always be associated with the same Cognito identity on AWS. The AWS Cognito Console has an identity browser where you can verify if the identity really is created.
An AMZUser object with a unique userId is created to represent the user (lines 24-30). This object is saved to AWS (starting line 32). After a successful save the currentUser property is set (line 37) and the userId is made persistent in NSUserDefaults (line 38). The function saveAMZUser(..) will be explained below.
AWSTask is a class that makes it easier to work with asynchronous operations without blocking the UI thread. You can find find more information about AWSTask in the AWS iOS developer guide.
Updating a user profile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
func updateCurrentUser(userData: UserData, completion: ErrorResultBlock) { guard var currentUser = currentUser else { preconditionFailure("currentUser should already exist when updateCurrentUser(..) is called") } precondition(userData.userId == nil || userData.userId == currentUser.userId, "Updating current user with a different userId is not allowed") precondition(persistentUserId != nil, "A persistent userId should exist") // create a new empty user var updatedUser = AMZUser() // apply the new userData updatedUser.updateWithData(userData) // restore the userId of the current user updatedUser.userId = currentUser.userId // If there are no changes, there is no need to update. if updatedUser.isEqualTo(currentUser) { completion(error: nil) return } self.saveAMZUser(updatedUser) { (error) -> Void in if let error = error { completion(error: error) } else { // Here we can be certain that the user was saved on AWS, so we update the local user instance. currentUser.updateWithData(updatedUser) completion(error: nil) } } } |
updateCurrentUser(..) is called whenever the user has edited his/her profile. The function updateCurrentUser(..) is a lot simpler than createCurrentUser(..), because we do not have to bother about identity management anymore.
Fetching a user profile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
func fetchCurrentUser(completion: UserDataResultBlock ) { precondition(persistentUserId != nil, "A persistent userId should exist") // Task to download the image let downloadImageTask: AWSTask = createDownloadImageTask(persistentUserId!) // Task to fetch the DynamoDB data let mapper = AWSDynamoDBObjectMapper.defaultDynamoDBObjectMapper() let loadFromDynamoDBTask: AWSTask = mapper.load(AMZUser.self, hashKey: persistentUserId!, rangeKey: nil) // Download the image downloadImageTask.continueWithBlock { (imageTask) -> AnyObject? in var didDownloadImage = false if let error = imageTask.error { // If there is an error we will ignore it, it's not fatal. Maybe there is no user image. print("Error downloading image: \(error)") } else { didDownloadImage = true } // Download the data from DynamoDB loadFromDynamoDBTask.continueWithBlock({ (dynamoTask) -> AnyObject? in if let error = dynamoTask.error { completion(userData: nil, error: error) } else { if let user = dynamoTask.result as? AMZUser { if didDownloadImage { let fileName = "\(self.persistentUserId!).jpg" let fileURL = self.deviceDirectoryForDownloads!.URLByAppendingPathComponent(fileName) user.imageData = NSData(contentsOfURL: fileURL) } if var currentUser = self.currentUser { currentUser.updateWithData(user) } else { self.currentUser = user } completion(userData: user, error: nil) } else { // should probably never happen assertionFailure("No userData and no error, why?") completion(userData: nil, error: nil) } } return nil }) return nil } } |
Fetching a user profile consists of 2 parts. We need to fetch the image from S3, and the other data from DynamoDB. This implementation uses a naming convention to couple the DynamoDB data with the S3 image of a user: the filename of the user image file is ‘userId’.jpg (line 27). An alternative solution might be to store the URL of the image in DynamoDB.
The function fetchCurrentUser(..) creates one AWSTask for downloading the image, and another one for downloading the other data. It uses the persistentUserId as a parameter for both tasks (line 5 and line 9). It tries to download the image first (line 12). If this succeeds the image will be saved to a temporary file. If there is an error it assumes there is no image. Then it will download the data from dynamoDB (line 21) and update the currentUser if successful (lines 31-35). If an image was downloaded earlier the temporary file is loaded and the imageData of the user is set (lines 26-30).
Implementation of saving a user profile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// This is where the saving to S3 (image) and DynamoDB (data) is done. func saveAMZUser(user: AMZUser, completion: ErrorResultBlock) { precondition(user.userId != nil, "You should provide a user object with a userId when saving a user") let mapper = AWSDynamoDBObjectMapper.defaultDynamoDBObjectMapper() // We create a task that will save the user to DynamoDB // This works because AMZUser extends AWSDynamoDBObjectModel and conforms to AWSDynamoDBModeling let saveToDynamoDBTask: AWSTask = mapper.save(user) // If there is no imageData we only have to save to DynamoDB if user.imageData == nil { saveToDynamoDBTask.continueWithBlock({ (task) -> AnyObject? in completion(error: task.error) return nil }) } else { // We have to save data to DynamoDB, and the image to S3 saveToDynamoDBTask.continueWithSuccessBlock({ (task) -> AnyObject? in // An example of the AWSTask api. We return a task and continueWithBlock is called on this task. return self.createUploadImageTask(user) }).continueWithBlock({ (task) -> AnyObject? in completion(error: task.error) return nil }) } } |
We already have seen how fetchCurrentUser(..) used two AWSTasks to retrieve data from S3 and DynamoDB. When saving a user we also have to save the image and other data separately. It’s obvious that if there is no imageData we do not need to upload an image to S3 (line 11).
The mapper (AWSDynamoDBObjectMapper line 5) is responsible for saving the user to DynamoDB (line 8 and line 18). This works because our AMZUser extends AWSDynamoDBObjectModel and conforms to AWSDynamoDBModeling.
Conclusion
We have created our first Swift app with a scalable backend. For me it was quite challenging and time consuming to figure all this out. I’ve spent la lot of time reading AWS docs and Stackoverflow posts. I tried out AWS sample projects. There have been moments where I was nearby the point of giving up and start looking for other Parse alternatives. How much time should I invest in getting a grip on the complexity of AWS?
I couldn’t find an easy step by step tutorial, so I decided to write one myself. Good for me, and hopefully good for you too. Now, having written this tutorial, and having a working app, I am starting to get more confident about AWS being a valid alternative for my app-backend. I am more familiar with the AWS docs and services, I am more experienced with the AWS console and I learned the basics of the AWS iOS SDK.
So, I will continue my research: to replace my Parse backend I will need more features. Saving data directly to DynamoDB and saving mages to S3 is not enough.
What’s next?
I am currently researching and prototyping the AWS services Lambda and APIGateway. It’s going pretty well. My goal is to extend the AmazonSwiftStarter app with an example that uses these services. Additionally, I have some other important features that I will need to address later on:
Geospatial querying: My commercial app needs to be able to find all other users of the app within a circular range. I have no clue yet how to do this using AWS. There was an easy API for this in Parse. There’s a Geo Library for DynamoDB, but it is not yet very clear to me how to apply this. I miss the Parse Geo Query.
Security: In the current version of AmazonSwiftStarter the user profiles are not secured. How can I set access rights to user data in DynamoDB? In Parse I used Object-Level Access Control (PFACL). A user has read/write access to his own profile data, and read-only access to the data of other users. Is this possible in AWS, and if so, how?
I you can give any advice or suggestion on these topics please comment below or contact me directly.
Interested in updates?
Follow my Twitter or subscribe to this blog (see the sidebar)
19 Comments on “Exploring AWS as a backend for a Swift app (3)”
Thanks a lot, Peter. I have been looking for this for about 1 month. Wish you a nice day.
Hi
Great blog. I’ve been trying to find a good guide for S3 uploads for quite a while now and i’m glad to have finally found a true guide. I’ve downloaded your v3 code and get an error ‘ No such module AWSCore’ for the import statement in the was remote service swift file. This is an error I’ve had many times with every tutorial I’ve tried and i guess it must be something wrong with my build. I’m using Xcode 8 iOS 9.2. Do you have any suggestions how to resolve the issue? i’ve tried many different methods found online but no luck.
thanks
David
Hi David, I remember that I have encountered this error as well in different contexts, but I can not remember how I fixed it.
So I tried to reproduce the error now by downloading the code associated with this blog post from github and build it from scratch. The build does not produce any errors. So I can’t find a solution. Maybe another developer stumbling in on this post might be able to help out.
On a side note: for uploading files to S3, AWS now offers the TransferUtility which is a bit easier to use because you do not need to create temp files. More info here http://docs.aws.amazon.com/mobile/sdkforios/developerguide/s3-simple-storage-service-for-ios.html#manager-or-utility
Hey. I followed this tutorial as is but I am getting so many errors of “unresolved identifiers”
Maybe the pods are not getting imported or something like that. What am I supposed to do now?
Help needed.
I have provided code downloads for Swift 2 – Xcode 7 and for Swift 3 – Xcode 8. First make sure that you are using the correct versions. The code is supposed to build without running cocoapods from the command line. When you open the project in Xcode you need to open the .xcworkspace and not the .xcodeproj.
If these suggestions do not solve your issue can you post some of the error messages that you get?
Great tutorial thanks you. Would you please have more stuff about query like User table, Photo table and Photo Comment table like social media apps? Just a simple one that would be nice?
Re: Andrew Zahra’s question at the top of this tutorial. I entered ‘userID’ instead of ‘userId’ with a lowercase ‘d’. That solved the problem. Unfortunately, I don’t believe you can edit the entry once it’s made so you have to make a new table with the correction.
Hi Peter,
First thanks for the excellent tutorial!
I don’t quite have part 3 working.
It is creating the user in the back end but failing to save with this error:
The provided key element does not match the schema
I have debugged into the saveAMZUser and the userId is definitely a string and this is how it is defined in the table.
Any idea what could be wrong?
Thanks,
Andrew
The error message suggests that there is a mismatch between the primary partition key as defined in the class AMZUser and the primary partition key that you configured in part 2 (section setUp dynamoDB).
class AMZUser:
// This is the primary key that you configured while setting up the DynamoDB service.
static func hashKeyAttribute() -> String! {
return “userId”
}
Can you check (in the DynamoDB console) that your primary partition key is correct? (in tab Overview – Table Details)
Thanks Peter, that was it. I had not noticed the capital I in userId and set it up as userid in dynamodb.
Works like a charm now!
Really nice API structure for isolating proprietary platforms. I learned a lot from reviewing it. If you are willing, can you comment on your decision to leave the decisions about what tasks should be performed asynchronously and on what queue’s to the view controllers? In an ideal world do you think this should be built into the AmazonService group or the RemoteService group, or left in the view controllers? Thanks.
Thanks Bruce. The RemoteService group is an api definition of the service layer. The AmazonService group is an implementation of this api. The idea is that the client of this api determines when to call it, and how to process the results. In this app the viewcontrollers are the clients of the api. They know best when they need the data, and they know best how to process the results. Therefore I would not built this logic into the AmazonService group or the RemoteService group, but leave it in the viewcontrollers.
Great tutorial! I have one error though:
Error downloading image: Error Domain=com.amazonaws.AWSS3ErrorDomain Code=0 “(null)” UserInfo={HostId=S0Pw6rR+WmhNKa=, Bucket=sr3bucketaccount, Endpoint=sr3bucketaccount.s3.amazonaws.com, Message=The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint., Code=PermanentRedirect, RequestId=7A59955}
Any idea why?
I found out the error, the bucket’s region was not the same. One thing I struggled with though was allowing to write on sr3bucket. In case in case it can help anyone, here is an example of policy you need to set in your srb3bucket:
{
“Version”: “2012-10-17”,
“Id”: “Policy1464711130447”,
“Statement”: [
{
“Sid”: “Stmt1464711126552”,
“Effect”: “Allow”,
“Principal”: “*”,
“Action”: [
“s3:GetObjectVersionAcl”,
“s3:PutObjectVersionAcl”,
“s3:PutObject”,
“s3:GetObjectVersionTorrent”,
“s3:PutObjectAcl”,
“s3:GetObject”,
“s3:DeleteObject”,
“s3:GetObjectAcl”,
“s3:ListMultipartUploadParts”,
“s3:DeleteObjectVersion”,
“s3:GetObjectTorrent”,
“s3:GetObjectVersion”
],
“Resource”: “arn:aws:s3:::YOUR_S3BUCKET_USERS/*”
}
]
}
Great you found the error already.
Just a remark about the policy. You can set a policy in the s3 bucket via de S3 console, but you don’t need to, to make the tutorial work. The tutorial aims to provide access to the s3 bucket only for users with a Cognito_AmazonSwiftStarterUnauth_Role. This role has the access policies for the S3 bucket. If it does not work, there might be a little syntax issue.
In the policy below (edit via IAM) take care not to add a ‘/‘ between YOUR_S3BUCKET_USERS and the ‘*’
IAM policy
{
“Action”: “s3:*”,
“Effect”: “Allow”,
“Resource”: “arn:aws:s3:::YOUR_S3BUCKET_USERS*”
}
The policy you provided will work, but it is completely detached from the Cognito_AmazonSwiftStarterUnauth_Role that you created and configured in part 2. This means even users without Cognito role can access your bucket now, e.g. via a web browser.
Hi!
Thanks for great tutorial.
I try run and have error “com.amazonaws.AWSDynamoDBErrorDomain”
here in EditProfileViewController
guard let currentUser = RemoteServiceFactory.getDefaultService().currentUser else {
preconditionFailure(“CurrentUser must be available”)
}
I correctly understand
static let DYNAMODB_USERS_TABLE = “arn:aws:..
or just
static let DYNAMODB_USERS_TABLE = “dynamodb:eu…”
You should use the exact name of the table you provided during configuration.
static let DYNAMODB_USERS_TABLE = “YourTableName”
You should not use the arn here, and no additional prefixes. Just the name as it appears on the overview of your DynamoDB tables in the AWS console
Peter,
Thanks for posting the third part!
I cannot wait to dive into it, this weekend.
Richard
I hope you will have a great weekend 🙂