iOS Apps with REST APIs Building Web-Driven Apps in Swift Christina Moulton
©2015 Teak Mobile Inc. All rights reserved. Except for the use in any review, the reproduction or utilization of this work in whole or in part in any form by any electronic, mechanical or other means is forbidden without the express permission of the author.
Tweet This Book! Please help Christina Moulton by spreading the word about this book on Twitter! The suggested hashtag for this book is #SwiftRestAppsBook. Find out what other people are saying about the book by clicking on this link to search for this hashtag on Twitter: https://twitter.com/search?q=#SwiftRestAppsBook
Contents Thanks
i
1. From JSON API to Swift App 1.1 What Will You Be Able to Do? 1.2 Who Is This Book For? 1.3 Who Is This Book Not For? 1.4 Using This Book 1.5 What We Mean By Web Services / APIs / REST / CRUD 1.6 JSON 1.7 Versions 1.8 Source Code 1.9 Disclaimer 1.10 Trademarks
1 1 2 2 2 3 3 3 4 4 4
2. Our App’s Requirements 2.1 Match Tasks to Endpoints 2.2 User Interface 2.3 API Requirements 2.4 Make a Plan
6 7 9 10 11
3. Swift JSON Parsing & Networking Calls 101 3.1 Simple REST API Calls with Swift 3.2 REST API Calls with Alamofire & SwiftyJSON 3.3 Alamofire Router 3.4 Strongly Typed GET and POST Calls with Alamofire 3.5 And That’s All
13 13 22 27 33 42
4. Why I Use Libraries Like Alamofire
43
5. Hooking Up a REST API to a Table View 5.1 Our Swift Project 5.2 Analyzing the API JSON Response 5.3 Setting Up the Table View 5.4 Getting & Processing the API Response 5.5 And That’s All
44 44 47 52 60 66
CONTENTS
6. Custom Headers 6.1 Session Headers 6.2 Per Request Headers 6.3 Headers in URLRequestConvertible 6.4 And That’s All For Headers
68 68 69 69 70
7. Loading UITableViewCell Images from an API 7.1 Loading UIImages from URLs 7.2 UITableViewCell Images from URLs 7.3 Enhancements 7.4 Caching Images 7.5 A Better Cache: PINRemoteImage 7.6 And That’s All
71 72 72 75 76 80 81
8. Pagination, a.k.a., Load More on Scroll 8.1 Where is the Next Page? 8.2 Fetching and Appending 8.3 Integrating with the View Controller 8.4 When to Load More Gists? 8.5 And That’s All for Pagination
82 82 85 87 89 91
9. Pull to Refresh 9.1 Adding Pull to Refresh 9.2 Showing the Last Refreshed Time 9.3 And That’s All
92 92 94 96
10. Authentication 10.1 The Docs 10.2 Basic Auth: Username/Password 10.3 HTTP Header Authentication 10.4 Alamofire Validation 10.5 OAuth 2.0 10.6 Displaying the Results 10.7 Unauthorized Responses: 404 vs 401 10.8 And That’s All
97 97 97 102 104 106 137 149 151
11. Switching Lists 11.1 Setting Up the UI 11.2 Sharing a Completion Handler 11.3 And That’s All
152 152 156 160
12. Switching Between View Controllers and More JSON Parsing 12.1 JSON Parsing: Arrays & Dates 12.2 Parsing Dates in JSON
161 161 164
CONTENTS
12.3 12.4 12.5 12.6 12.7
Configuring the Detail View Controller Passing Data in a Segue Adding a Table View Displaying Gist File Content And That’s All
166 171 172 178 179
13. Adding More API Calls - Starring 13.1 Is the Gist Starred? 13.2 Starred Status in the Table View 13.3 PUT and DELETE Calls to Star and Unstar Gists 13.4 Authorization Check 13.5 And That’s All
180 180 181 184 186 190
14. Deleting Gists 14.1 DELETE API Call 14.2 User Interface: Table View Delete Features 14.3 And That’s All
191 191 192 195
15. Creating Gists and Clearing the Cache 15.1 POST API Call with Nested JSON Parameters 15.2 Creating an Input Form with Validation 15.3 And That’s All
196 196 200 208
16. What if They’re Offline? 16.1 How Do We Know? 16.2 Save a Local Copy 16.3 Databases
209 209 221 227
17. What Next? 17.1 User Interface 17.2 Test the User Experience 17.3 Suggested Exercises 17.4 Did I Miss Anything?
229 229 229 229 230
A Brief Introduction to CocoaPods Adding a CocoaPod to a Project What Does the Podfile Mean? Other Options Dependencies CocoaPods Version Numbers Updating CocoaPods
231 231 232 233 233 233 234
Thanks Without a few key people this book wouldn’t have happened. Most of all, thanks to Jeff Moulton for putting up with my excessive focus on coding & writing, even while living on a 34’ sailboat. Jeff also took the cover photo. Thanks also to: • • • • •
My Twitter peeps for support & fav’s @BugKrusha¹ & the iOS Developers² community GitHub LeanPub Everyone who gave feedback or asked questions about the book or GrokSwift.com³. Every little bit helps make the book better for you.
¹http://twitter.com/BugKrusha ²http://ios-developers.io ³https://grokswift.com
i
1. From JSON API to Swift App You need to build an iOS app around your team’s API or integrate a third party API. You need a quick, clear guide to demystify Xcode and Swift. No esoteric details about Core Anything or mathematical analysis of flatMap. Only the nitty gritty that you need to get real work done now: pulling data from your web services into an iOS app, without tossing your MacBook or Mac Mini through a window. You just need the bare facts on how to get CRUD done on iOS. That’s what this book will do for you.
1.1 What Will You Be Able to Do? After reading this book you’ll be able to: • Analyze a JSON response from a web service call and write Swift code to parse it into model objects • Display those model objects in a table view so that when the user launches the app they have a nice list to scroll through • Add authentication to use web service calls that require OAuth 2.0, a username/password, or a token • Transition from the main table view to a detail view for each object, possibly making another web service call to get more info about the object • Let users add, modify and delete objects (as long as your web service supports it) • Hook in to more web service calls to extend you app, like adding user profiles or letting users submit comments or attach photos to objects To achieve those goals we’ll build out an app based on the GitHub API, focusing on gists. (If you’re not familiar with gists, they’re basically just text snippets, often code written a GitHub user.) Your model objects might be bus routes, customers, chat messages, or whatever kind of object is core to your app. We’ll start by figuring out how to make API calls in Swift then we’ll start building out our app one feature at a time: • • • • •
Show a list of all public gists in a table view Load more results when the user scrolls down Let them pull to refresh to get the latest public gists Load images from URLs into table view cells Use OAuth 2.0 for authentication to get lists of private and starred gists 1
From JSON API to Swift App
2
• Have a detail view for each gist showing the text • Allow users to add new gists, star and unstar gists, and delete gists • Handle not having an internet connection with warnings to the user and saving the gists on the device
1.2 Who Is This Book For? • • • •
Software developers getting started with iOS but experienced in other languages Front-end devs looking to implement native UIs for iOS apps (no CSS, oh noes!) Back-end devs tasked with getting the data into the user’s hands on iOS Android, Windows Phone, Blackberry, Tizen, Symbian & Palm OS devs looking to expand their web service backed apps to iOS • Anyone whose boss is standing over their shoulder asking why the API data isn’t showing up in the table view yet
1.3 Who Is This Book Not For? • Complete newcomers to programming, you should have a decent grasp of at least one objectoriented programming language or have completed several intro to iOS tutorials • Designers, managers, UX pros, … It’s a programming book. All the monospace font inserts will probably drive you crazy. • Cross-platform developers dedicated to their tools (including HTML5 & Xamarin), this is all Swift & native UI, all the time • Programmers building apps that have little or no web service interaction • Game devs, unless you’re tying in a REST-like API
1.4 Using This Book This book is mostly written as a tutorial in implementing the gists app. Depending on how you learn best and how urgently you need to implement your own app, there are two different approaches you might take: 1. Work through the tutorials as written, creating an app for GitHub Gists. You’ll understand how that app works and later be able to apply it to your own apps. 2. Read through the tutorials but implement them for your own app and API. Throughout the text I’ll point out where you’ll need to analyze your own requirements and API to help you figure out how to modify the example code to work with your API. Those tips will look like this:
From JSON API to Swift App
3
List the tasks or user stories for your app. Compare them to the list for the gists app, focusing on the number of different objects (like stars, users, and gists) and the types of action taken (like viewing a list, viewing an object’s details, adding, deleting, etc.).
We’ll start with that task in the next chapter. We’ll analyze our requirements and figure out just what we’re going to build. Then we’ll start building the gists app, right after an introduction to making network calls and parsing JSON in Swift.
1.5 What We Mean By Web Services / APIs / REST / CRUD Like anything in tech there are plenty of buzzwords around web services. For a while it was really trendy to say your web services were RESTful. If you want to read the theory behind it, head over to Wikipedia¹. For our purposes in this book, all we mean by “REST web service” or even when we say “web service” or “API” is that we can send an HTTP request and we get back some data in a format that’s easy to use in our app. Usually the response will be in JSON. Web services are wonderful since they let you use existing systems in your own apps. There’s always a bit of a learning curve with any web service that you’re using for the first time since every one has its own quirks. Most of the integration is similar enough that we can generalize how to integrate them into our iOS apps. If you want an argument about whether or not a web service is really RESTful you’re not going to find it here. We’ve got work that just needs to get done.
1.6 JSON In this book we’re going to deal with web services that return JSON. JSON is hugely common these days so it’s probably what you’ll be dealing with. Of course, there are other return types out there, like XML. This book won’t cover responses in anything but JSON but it will encapsulate the JSON parsing so that you can replace it with whatever you need to without having to touch a ton of code. If you are dealing with XML response you should look at NSXMLParser².
1.7 Versions This is version 1.1.1 of this book. It uses Swift 2.0, iOS 9, and Xcode 7.1. When we use libraries we’ll explicitly list the versions used. The most commonly used ones are Alamofire 3.1 and SwiftyJSON 2.3. ¹https://en.wikipedia.org/wiki/Representational_state_transfer ²https://developer.apple.com/library/mac/documentation/Cocoa/Reference/Foundation/Classes/NSXMLParser_Class/
From JSON API to Swift App
4
Version 1.0 of this book used Alamofire 2.0 and SwiftyJSON 2.2. Changes to the code between versions 1.0 and this version can be found on GitHub³.
1.8 Source Code All sample code is available on GitHub⁴ under the MIT license⁵. Links are provided throughout the text. Each chapter has a tag allowing you to check out the code in progress up to the end of that chapter. Individuals are welcome to use code for commercial and open-source projects. As a courtesy, please provide attribution to “Teak Mobile Inc.” or “Christina Moulton”. For more information, review the complete license agreement in the GitHub repo⁶.
1.9 Disclaimer The information provided within this eBook is for general informational purposes only. The author has made every effort to ensure the accuracy of the information within this book was correct at time of publication. Teak Mobile Inc. and/or Christina Moulton do not assume and hereby disclaims any liability to any party for any loss, damage, or disruption caused by errors or omissions, whether such errors or omissions result from accident, negligence, or any other cause. Teak Mobile Inc. and/or Christina Moulton shall in no event be liable for any loss of profit or any other commercial damage, including but not limited to special, incidental, consequential, or other damages. Any use of this information is at your own risk.
1.10 Trademarks This book identifies product names and services known to be trademarks, registered trademarks, or service marks of their respective holders. They are used throughout this book in an editorial fashion only. In addition, terms suspected of being trademarks, registered trademarks, or service marks have been appropriately capitalized, although Teak Mobile Inc. and Christina Moulton cannot attest to the accuracy of this information. Use of a term in this book should not be regarded as affecting the validity of any trademark, registered trademark, or service mark. Teak Mobile Inc. and/or Christina Moulton are not associated with any product or vendor mentioned in this book. Apple, Xcode, App Store, Cocoa, Cocoa Touch, Interface Builder, iOS, iPad, iPhone, Mac, OS X, Swift, and Xcode are trademarks of Apple, Inc., registered in the United States and other countries. ³https://github.com/cmoulton/grokSwiftREST/compare/Alamofire3 ⁴https://github.com/cmoulton/grokSwiftREST_v1.1 ⁵https://opensource.org/licenses/MIT ⁶https://github.com/cmoulton/grokSwiftREST_v1.1/blob/master/grokSwiftREST/LICENSE.txt
From JSON API to Swift App
GitHub is a trademark of GitHub, Inc. registered in the United States. Mashape is a trademark of Mashape, Inc. registered in the United States.
5
2. Our App’s Requirements It’s always tempting to jump right into coding but it usually goes a lot smoother if we plan it out in advance. At the least we need some idea of what we’re building. Let’s lay that out for the gists app and you can modify it to suit your app. The first thing to do is to figure out what screens or views our app will have. There are a few ways to do this task but I prefer to make a list of things that users will want to do with your app then design the screens to make those things easy. So what do people do with gists? Gists are snippets of text, often bits of code that are easily shared. So people might: 1. 2. 3. 4. 5.
Look at a list of public gists to see what’s new Search for interesting gists, maybe by programming language Star a gist so they can find it later Look at a list of gists they’ve starred Look at a list of their own gists to grab code they commonly use but don’t want to retype all the time 6. Look at details for a gist in a list (public, their own, or starred) 7. Create a new gist 8. Delete one of their gists
List the tasks or user stories for your app. Compare them to the list for the gists app, focusing on the number of different objects (like stars, users, and gists) and the types of action taken (like viewing a list, viewing an object’s details, adding, deleting, etc.).
You might end up with a really long list. Consider each item and whether it’s really necessary for the first version of your app. Maybe it can be part of the next release if the first one gets some traction? Evaluate each task on your list. Decide which ones will form v1.0 of your app. You might even want to design v2.0 now so you’re not tempted to put everything in the first version. A good shipped app is far better than a perfect app that’s indefinitely delayed.
6
Our App’s Requirements
7
2.1 Match Tasks to Endpoints Next look at each of those tasks and figure out how you can use the API to accomplish them or to get the data you’ll need to display. We’ll check the documentation for the GitHub gists API¹ to find the endpoint for each task. We’ll make notes of anything special that we need to do, like authentication or pagination.
2.1.1 List Public Gists GET /gists/public
No authentication required. Will be paginated so we’ll have to load more results if they want to see more than 20 or so.
2.1.2 Search Gists Hmm, there isn’t an API for searching gists. Is our app still useful without search? I think so, so we don’t need to abandon the project.
2.1.3 Star/Unstar a Gist PUT /gists/:id/star DELETE /gists/:id/star
Requires authentication.
2.1.4 List Starred Gists GET /gists/starred
Requires authentication.
2.1.5 List my Gists There are two ways to get a list of a user’s gists: GET /users/:username/gists
Or, if authenticated in: ¹https://developer.github.com/v3/gists/
Our App’s Requirements
8
GET /gists
2.1.6 View Gist Details We’ll probably be able to pass the data from the list of gists to the detail view but if we can’t then we can get a single gist’s details: GET /gists/:id
If we want to display whether a gist is starred then we can use: GET /gists/:id/star
2.1.7 Create Gist POST /gists
Requires authentication to create a gist owned by a user. Otherwise the gist is created anonymously. The JSON to send to create a gist looks like: { "description": "the description for this gist", "public": true, "files": { "file1.txt": { "content": "String file content" } } }
2.1.8 Delete Gists DELETE /gists/:id
Requires authentication. Those are the endpoints for our tasks. Other than not being able to build our search feature, we shouldn’t have any trouble building our demo app around this API. Analyze each action and list the API endpoint or iOS feature that will be needed for it. Make sure that everything is possible using the API that’s available. If not and the API is being built by your team then request what you need now so there’s plenty of time to get it implemented.
Our App’s Requirements
9
2.2 User Interface Now we have to figure out how we’re going to make the app usable by the users. Let’s look at each task and figure out how we’d like it to work. I’ve reordered the tasks below a bit to group together bits that will share parts of the interface.
2.2.1 Authentication Flow Since there isn’t much they can do in the app without being logged in, we’ll check at launch if they’re authenticated. If not we’ll start the login process right away. If your API provides lots of functionality without authentication then you might want to delay requiring the user to log in. If that’s the case you can add the authentication checks before making the API calls that require authentication.
2.2.2 List Public Gists On launch the user sees a list (table view) with the public gists.
2.2.3 List Starred Gists From the public gists the user can switch to a similar list of my starred gists.
2.2.4 List My Gists From the public or starred gists the user can switch to a similar list of their own gists. Sounds like we’ll be able to use a single table view and have a selector so the user can pick which of the 3 lists of gists they want to view.
2.2.5 View Gist Details When they tap on a gist in one of the lists we’ll transition to a different view. That view will list details about the gist (description and filenames) and let them view the text of the files. It’ll also show whether we’ve starred the gist.
2.2.6 Star/Unstar a Gist Within a gist’s detail view we’ll show the starred status. They will be able to tap to star or unstar a gist in that view.
Our App’s Requirements
10
2.2.7 Create Gist On the list of My Gists we’ll have a + button in the upper right corner. It’ll display a form where they can enter the info for the new gist: • • • •
Description: text isPublic: Boolean Filename: text File content: text
To keep it simple we’ll only allow a single file in gists created in the app in v1.0.
2.2.8 Delete Gists We’ll allow swipe to delete on the list of My Gists. Go through your tasks and figure out the user interface that people will use to accomplish those tasks.
2.3 API Requirements We’ll have some requirements to interact with the API that aren’t obvious when we consider the user’s tasks. But reading through the documentation carefully can help us make a list.
2.3.1 Authentication You can read public gists and create them for anonymous users without a token; however, to read or write gists on a user’s behalf the gist OAuth scope is required. GitHub Gists API docs² So we’ll need to set up authentication, preferably OAuth 2.0, including the gist scope. The API will work with a username/password but then we’d have to worry about securing that data. With OAuth 2.0 we never see the username & password, only the token for our app. We will store the OAuth token securely. Check your APIs authentication requirements. In the auth chapter we’ll cover how to implement OAuth 2.0, token-based authentication, and basic auth with username/password.
²https://developer.github.com/v3/gists/#authentication
Our App’s Requirements
11
2.3.2 Set the Accept Header There’s a note in the GitHub API docs³ that we should set the accept header like: Accept: application/vnd.github.v3+json. Should is often code for “will break things later if you don’t” so we’ll do that. Check your APIs documentation for any required headers.
In iOS 9 Apple introduced Apple’s App Transport Security⁴. ATS requires SSL to be used for transferring data and it’s pretty picky about just how it’s implemented. Sadly this means that a lot of servers out there don’t meet ATS’s requirements. GitHub’s gist API complies with the ATS requirements so we won’t have to add an exception. If you find that you get SSL errors when calling your API from iOS 9 then you’ll probably need to add an exception to ATS. See the Networking 101 chapter for details on adding that exception. You can use the code in that chapter to try some simple API calls to your server to see if you get SSL errors.
2.4 Make a Plan Now that we know what we need to do we can figure out how we’re going to do it. We’ll build the app up incrementally, feature by feature: • • • • • • • • • •
Set up the app with a table view displaying the public gists Add custom headers Load images in table view cells Load more gists when they scroll down Add pull to refresh Add authentication and let them switch to displaying My Gist and Starred Gists Create a detail view for the gists Add starring & unstarring gists in the detail view Add deleting and creating gists Handle not having an internet connection
³https://developer.github.com/v3/ ⁴https://developer.apple.com/library/prerelease/ios/technotes/App-Transport-Security-Technote/index.html#//apple_ref/doc/uid/TP40016240
Our App’s Requirements
12
Put your views and tasks in order to implement them. Try to match up roughly with the order for the gists app. If you don’t have an API call to start with that doesn’t require authentication you might need to jump ahead to the auth chapter before starting on the table view chapter. If your API requires custom headers to be sent with all requests then you’ll want to start with the headers chapter then come back to the table view chapter.
Now that we’ve sorted out the basic requirements for our app we know where to start. First we’ll spend a little time looking at how to make web requests and parse JSON in Swift so we don’t get bogged down with those details later.
3. Swift JSON Parsing & Networking Calls 101 I was all ready to jump right in to some useful code for you then Apple introduced App Transport Security¹ in iOS 9. While ATS should be a great feature for securing the data being sent to and from your iPhone, it’s a bit of a pain as a developer right now. ATS requires SSL to be used for transferring data and it is pretty picky about how it’s implemented. Sadly this means that a lot of servers out there don’t meet the ATS requirements. So what can we do if we need to work with one of these servers? Well, we’ll deal with that right now because the Networking 101 code below uses a server that requires it. We’ll have to add an exception to App Transport Security for that server. While we could just disable ATS it’s much more secure to create an exception only for the one server that we need to access. The API that we’ll be using in this chapter is at http://jsonplaceholder.typicode.com/² so that’s what we’ll create the exception for. To create the exception we’ll need to add some keys to the info.plist in our project. We’ll add an NSAppTransportSecurity dictionary. It’ll contain an NSExceptionDomains dictionary with a dictionary for the server: jsonplaceholder.typicode.com (note: no trailing slashes and no http or https prefix). Within the jsonplaceholder.typicode.com dictionary we’ll have a boolean entry NSThirdPartyExceptionAllowsInsecureHTTPLoads set to YES:
ATS exception settings
Ok, now we can actually get into the networking code.
3.1 Simple REST API Calls with Swift Pretty much every app these days consumes or creates content through an API. In this book we’ll mostly use Alamofire³, a rich networking library, but you can also use NSURLSessions’s asynchronous data task requests for quick and dirty REST calls. ¹https://developer.apple.com/library/prerelease/ios/technotes/App-Transport-Security-Technote/index.html#//apple_ref/doc/uid/TP40016240 ²http://jsonplaceholder.typicode.com/ ³http://nshipster.com/alamofire/
13
Swift JSON Parsing & Networking Calls 101
14
The function to use to make an async URL request is: public func dataTaskWithRequest(request: NSURLRequest, completionHandler: (NSData?, NSURLResponse?, NSError?) -> Void) -> NSURLSessionDataTask
It takes a request which contains the URL then goes off and sends the request. Once it gets a response (or has an error to report), the completion handler gets called. The completion handler is where we can work with the results of the call: error checking, saving the data locally, updating the UI, whatever. We’ll talk about completion handlers a bit more in a minute when we implement one for dataTaskWithRequest
The simplest case is a GET request. Of course, we need an API to hit. Fortunately there’s super handy JSONPlaceholder⁴: “JSONPlaceholder is a fake online REST API for testing and prototyping. It’s like image placeholders but for web developers.” JSONPlaceholder has a handful of resources similar to what you’ll find in a lot of apps: users, posts, photos, albums, … We’ll stick with posts. First let’s print out the title of the first post. To get a single post, we need to make a GET call to the posts endpoint with an ID number. Checking out http://jsonplaceholder.typicode.com/posts/⁵ we can see that the id for the first post is 1. So let’s grab it: First, set up the URL request: let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" guard let url = NSURL(string: postEndpoint) else { print("Error: cannot create URL") return } let urlRequest = NSURLRequest(URL: url)
The guard statement lets us check that the URL we’ve provided is valid. Then we need an NSURLSession to use to send the request: let config = NSURLSessionConfiguration.defaultSessionConfiguration() let session = NSURLSession(configuration: config)
Then create the data task: ⁴https://github.com/typicode/jsonplaceholder ⁵http://jsonplaceholder.typicode.com/posts/
Swift JSON Parsing & Networking Calls 101
15
let task = session.dataTaskWithRequest(urlRequest, completionHandler: nil)
And finally send it (yes, this is an oddly named function): task.resume()
Calling this now will hit the URL (from the urlRequest) and obtain the results (using a GET request since that’s the default). To actually get the results to do anything useful we need to implement the completion handler. Completion handlers can be a bit confusing the first time you run in to them. On the one hand, they’re a variable or argument but, on the other hand, they’re a chunk of code. Weird if you’re not used to that kind of thing (a.k.a., blocks or closures). Completion handlers are super convenient when your app is doing something that might take a little while, like making an API call, and you need to do something when that task is done, like updating the UI to show the data. You’ll see completion handlers in Apple’s APIs like dataTaskWithRequest and later on we’ll add some of our own completion handlers when we’re building out our API calls. In dataTaskWithRequest the completion handler argument has a signature like this: completionHandler: (NSData?, NSURLResponse?, NSError?) -> Void
So it’s a code block (it must be if it has a return type which is what -> tells us). It has 3 arguments: (NSData?, NSURLResponse?, NSError?) and returns nothing: Void. To specify a completion handler we can write the code block inline like this: let task = session.dataTaskWithRequest(urlRequest, completionHandler: { (data, response, error) in // this is where the completion handler code goes }) task.resume()
The block is the bit between the curly brackets. Notice that the 3 arguments in the block (data, response, error) match the arguments in the completion handler declaration: (NSData?, NSURLResponse?, NSError?). You can specify the types explicitly when you create your block but it’s not necessary because the compiler can figure it out. Sometimes it’s good to remember that people read code, not just computers, so it doesn’t hurt to be explicit:
Swift JSON Parsing & Networking Calls 101
16
let task = session.dataTaskWithRequest(urlRequest, completionHandler: { (data: NSData?, response: NSURLResponse?, error: NSError?) in // this is where the completion handler code goes print(response) print(error) }) task.resume()
Somewhat confusingly, you can actually drop the completionHandler: bit and just tack the code block on at the end of the function call. This is totally equivalent to the code above and a pretty common thing you’ll see in Swift code: let task = session.dataTaskWithRequest(urlRequest) { (data, response, error) in // this is where the completion handler code goes print(response) print(error) } task.resume()
If you want to ignore some arguments you can tell the compiler that you don’t want them by replacing them with _: let task = session.dataTaskWithRequest(urlRequest) { (data, _, error) in // can't do print(response) since we don't have response print(error) } task.resume()
We can also declare the code block as a variable then pass it in when we call dataTaskWithRequest. That’s handy if we want to use the same completion handler for multiple tasks. We will use this technique when implementing an OAuth 2.0 login flow, since it has lots of steps but we will want to handle any of them failing similarly. Here’s how you can use a variable for a completion handler:
Swift JSON Parsing & Networking Calls 101
17
let myCompletionHandler: (NSData?, NSURLResponse?, NSError?) -> Void = { (data, response, error) in // this is where the completion handler code goes print(response) print(error) } let task = session.dataTaskWithRequest(urlRequest, completionHandler: myCompletionHandler) task.resume()
What’ll happen to our little code block? Well, it won’t get called right away when we call dataTaskWithRequest. But somewhere in Apple’s implementation of dataTaskWithRequest it will get called like this: public func dataTaskWithRequest(request: NSURLRequest, completionHandler: (NSData?, NSURLResponse?, NSError?) -> Void) -> NSURLSessionDataTask { // make an URL request // wait for results // check for errors and stuff completionHandler(data, response, error) // return the data task }
You don’t need to write that in your own code, it’s already implemented in dataTaskWithRequest. In fact, there are probably a few calls like that for handling success and error cases. The completion handler will just sit around waiting to be called whenever dataTaskWithRequest is done. So what’s the point of completion handlers? Well, we can use them to take action when something is done. Like here we could set up a completion handler to print out the results and any potential errors so we can make sure our API call worked. Let’s go back to our dataTaskWithRequest example and implement a useful completion handler. Here’s where the code will go: let task = session.dataTaskWithRequest(urlRequest, completionHandler: { (data, response, error) in // do stuff with response, data & error here }) task.resume()
Now we have access to 3 arguments: the URL response, the data returned by the request and an error (if one occurred). So let’s check for errors and figure out how to get at the data that we want: the first post’s title. We need to: 1. Make sure we got data and no error
Swift JSON Parsing & Networking Calls 101
18
2. Try to transform the data into JSON (since that’s the format returned by the API) 3. Access the post object in the JSON and print out the title You’ll need to add import Foundation at the top of your file to have access to NSJSONSerialization. let task = session.dataTaskWithRequest(urlRequest, completionHandler: { (data, response, error) in guard let responseData = data else { print("Error: did not receive data") return } guard error == nil else { print("error calling GET on /posts/1") print(error) return } // parse the result as JSON, since that's what the API provides let post: NSDictionary do { post = try NSJSONSerialization.JSONObjectWithData(responseData, options: []) as! NSDictionary } catch { print("error trying to convert data to JSON") return } // now we have the post, let's just print it to prove we can access it print("The post is: " + post.description) // // // if
the post object is a dictionary so we just access the title using the "title" key so check for a title and print it if we have one let postTitle = post["title"] as? String { print("The title is: " + postTitle)
} }) task.resume()
Which prints out:
Swift JSON Parsing & Networking Calls 101
19
The post is: { body = "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderi\ t molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"; id = 1; title = "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"; userId = 1; } The title is: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
It’s a little verbose but if you just need a quick GET call to an API without authentication, that’ll do it. If you need a method type other than GET then you’ll need to use a mutable NSURLRequest so you can set the method type: let postsEndpoint: String = "http://jsonplaceholder.typicode.com/posts" let postsUrlRequest = NSMutableURLRequest(URL: NSURL(string: postsEndpoint)!) postsUrlRequest.HTTPMethod = "POST"
Then we can set the new post as the HTTPBody for the request: let newPost: NSDictionary = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1]; do { let jsonPost = try NSJSONSerialization.dataWithJSONObject(newPost, options: []) postsUrlRequest.HTTPBody = jsonPost } catch { print("Error: cannot create JSON from post") }
Now we can execute the request (assuming we’re keeping the session that we created earlier around): let task = session.dataTaskWithRequest(postsUrlRequest, completionHandler: nil) task.resume()
If it’s working correctly then we should get our post back as a response along with the id number assigned to it. Since it’s just for testing, JSONPlaceholder will let you do all sorts of REST requests (GET, POST, PUT, PATCH, DELETE and OPTIONS) but it won’t actually change the data based on your requests. So when we send this POST request, we’ll get a response with an ID to confirm that we did it right but it won’t actually be kept in the database so we can’t access it on subsequent calls.
Swift JSON Parsing & Networking Calls 101 let newPost: NSDictionary = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1]; do { let jsonPost = try NSJSONSerialization.dataWithJSONObject(newPost, options: []) postsUrlRequest.HTTPBody = jsonPost let config = NSURLSessionConfiguration.defaultSessionConfiguration() let session = NSURLSession(configuration: config) let task = session.dataTaskWithRequest(postsUrlRequest, completionHandler: { (data, response, error) in guard let responseData = data else { print("Error: did not receive data") return } guard error == nil else { print("error calling GET on /posts/1") print(error) return } // parse the result as JSON, since that's what the API provides let post: NSDictionary do { post = try NSJSONSerialization.JSONObjectWithData(responseData, options: []) as! NSDictionary } catch { print("error parsing response from POST on /posts") return } // now we have the post, let's just print it to prove we can access it print("The post is: " + post.description) // the post object is a dictionary // so we just access the title using the "title" key // so check for a title and print it if we have one if let postID = post["id"] as? Int { print("The ID is: \(postID)") } }) task.resume() }
Deleting is pretty similar (minus creating the JSON post):
20
Swift JSON Parsing & Networking Calls 101
21
let firstPostEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" let firstPostUrlRequest = NSMutableURLRequest(URL: NSURL(string: firstPostEndpoint)!) firstPostUrlRequest.HTTPMethod = "DELETE" let config = NSURLSessionConfiguration.defaultSessionConfiguration() let session = NSURLSession(configuration: config) let task = session.dataTaskWithRequest(firstPostUrlRequest, completionHandler: { (data, response, error) in guard let _ = data else { print("error calling DELETE on /posts/1") return } }) task.resume()
So that’s the quick & dirty way to call a REST API from Swift. There are a couple of gotchas though: We’re assuming we’ll get results and they’ll be in the format we expect. This can cause definitely cause problems, e.g., we’ll get a crash if our post isn’t a dictionary here: post = try NSJSONSerialization.JSONObjectWithData(responseData, options: []) as! NSDictionary
Either check the format to make sure it’s not nil and it’s actually a dictionary (which gets verbose fast) or use SwiftyJSON⁶ to replace all that boilerplate: // parse the result as JSON, since that's what the API provides let post = JSON(data: responseData) if let postID = post["id"].int { print("The post ID is \(postID)") }
SwiftyJSON will check for optionals at each step, so if post is nil or post["id"] is nil, then postID will be nil. .int returns an optional like Int?. If you’re sure the value won’t be nil then use .intValue instead to get a non optional value. SwiftyJSON doesn’t just handle integers. Here’s how to parse strings, doubles and boolean values:
⁶https://github.com/SwiftyJSON/SwiftyJSON
Swift JSON Parsing & Networking Calls 101
22
let title = myJSON["title"].string let cost = myJSON["cost"].double let isPurchased = myJSON["purchased"].bool
If your JSON has an array of elements (e.g., a list of all of the posts) then you can get at each element by index and access its properties in a single statement: let thirdPostTitle = posts[3]["title"].string
So far the code to make the calls themselves is pretty verbose and the level of abstraction is low: you’re thinking about posts but having to code in terms of HTTP requests and data tasks. Alamofire⁷ looks like a nice step up: Alamofire.request(.GET, postEndpoint) .responseJSON { response in // get errors print(response.result.error) // get serialized data (i.e., JSON) print(response.result.value) // get raw data print(response.data) // get NSHTTPURLResponse print(response.response) }
Grab the code on GitHub: REST gists⁸
3.2 REST API Calls with Alamofire & SwiftyJSON Last section we looked at the quick & dirty way to get access REST APIs in iOS. dataTaskWithRequest works just fine for simple cases, like a URL shortener. But these days lots of apps have tons of web service calls that are just begging for better handling: a higher level of abstraction, concise syntax, simpler streaming, pause/resume, progress indicators, … In Objective-C, this was a job for AFNetworking⁹. In Swift, Alamofire¹⁰ is our option for elegance. While we’re harping on elegance, that JSON parsing was pretty ugly. Optional binding (as of Swift 1.2) helps fix that but SwiftyJSON¹¹ will really help clean up that boilerplate. ⁷https://github.com/Alamofire/Alamofire ⁸https://gist.github.com/cmoulton/01fdd4fe2c2e9c8195e1 ⁹https://github.com/afnetworking/afnetworking ¹⁰https://github.com/Alamofire/Alamofire ¹¹https://github.com/SwiftyJSON/SwiftyJSON
Swift JSON Parsing & Networking Calls 101
23
Just like in previous section, we’ll use the super handy JSONPlaceholder¹² as our API. Here’s our quick & dirty GET request from last section where we grabbed the first post and printed out its title. (You’ll need to add import Foundation at the top of your file to have access to NSJSONSerialization.): let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" guard let url = NSURL(string: postEndpoint) else { print("Error: cannot create URL") return } let urlRequest = NSURLRequest(URL: url) let config = NSURLSessionConfiguration.defaultSessionConfiguration() let session = NSURLSession(configuration: config) let task = session.dataTaskWithRequest(urlRequest, completionHandler: { (data, response, e\ rror) in guard let responseData = data else { print("Error: did not receive data") return } guard error == nil else { print("error calling GET on /posts/1") print(error) return } // parse the result as JSON, since that's what the API provides let post: NSDictionary do { post = try NSJSONSerialization.JSONObjectWithData(responseData, options: []) as! NSDictionary } catch { print("error trying to convert data to JSON") return } // now we have the post, let's just print it to prove we can access it print("The post is: " + post.description) // // // if
the post object is a dictionary so we just access the title using the "title" key so check for a title and print it if we have one let postTitle = post["title"] as? String { print("The title is: " + postTitle)
¹²https://github.com/typicode/jsonplaceholder
Swift JSON Parsing & Networking Calls 101
24
} }) task.resume()
Which is an awful lot of code for what we’re doing (but far less than back in the dark ages when thousands of lines of code generated from WSDL web services would crash Xcode just by scrolling the file). There’s no authentication and just enough error checking to get by. Let’s see how this looks with Alamofire library that I keep talking up. First add Alamofire v3.1 to your project using CocoaPods (See A Brief Introduction to CocoaPods if you’re not sure how). Then set up the request: let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.GET, postEndpoint) .responseJSON { response in // ... }
Looks more readable to me so far. We’re telling Alamofire to set up & send an asynchronous request to postEndpoint (without the ugly call to NSURL to wrap up the string). We explicitly say it’s a GET request (instead of NSURLRequest assuming it). .GET is a member of the Alamofire.Method enum, which also includes .POST, .PATCH, .OPTIONS, .DELETE, etc. Then we get the data (asynchronously) as JSON in the .responseJSON. We could also use .response (for an NSHTTPURLResponse), .responsePropertyList, or .responseString (for a string). We could even chain multiple .responseX methods for debugging: Alamofire.request(.GET, postEndpoint) .responseJSON { response in // handle JSON } .responseString{ response in // print response as string for debugging, testing, etc. print(response.result.value) // check for errors print(response.result.error) }
That’s neat but right now we just want to get the post’s title from the JSON. We’ll make the request then handle it with .responseJSON. Like last time we need to do some error checking: 1. 2. 3. 4.
Check for an error returned by the API call If no error, see if we got any JSON results Check for an error in the JSON transformation If no error, access the post object in the JSON and print out the title
In SwiftyJSON, instead of
Swift JSON Parsing & Networking Calls 101
25
post["title"] as? String
we can use the cleaner post["title"].string
It doesn’t make a huge difference if we’re unwrapping a single level but for multiple levels unwrapping with nested if-lets would look like this: if let postsArray = data as? NSArray { if let firstPost = postsArray[0] as? NSDictionary { if let title = firstPost["title"] as? String { // ... } } }
As of Swift 1.2 we can unwrap multiple optionals in a single if-let statement but it’s still kinda tough to read: if let postsArray = data as? NSArray, firstPost = postsArray[0] as? NSDictionary, title = firstPost["title"] as? String { // ... }
Compared to the following with SwiftyJSON: if let title = postsArray[0]["title"].string // ... }
Ok, so all together now:
Swift JSON Parsing & Networking Calls 101
26
let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.GET, postEndpoint) .responseJSON { response in guard response.result.error == nil else { // got an error in getting the data, need to handle it print("error calling GET on /posts/1") print(response.result.error!) return } if let value: AnyObject = response.result.value { // handle the results as JSON, without a bunch of nested if loops let post = JSON(value) // now we have the results, let's just print them // though a table view would definitely be better UI: print("The post is: " + post.description) if let title = post["title"].string { // to access a field: print("The title is: " + title) } else { print("error parsing /posts/1") } } }
So that’s a little cleaner: we check that the web service did return data then we use let post = JSON(value) to create the post object. Much cleaner than the previous let post = NSJSONSerialization.JSONObjectWithData(data, options: [], error: &jsonError) as! NSDictionary
(which crashed if the data wasn’t a dictionary). To POST, we just need to change the HTTP method and provide the post data: let postsEndpoint: String = "http://jsonplaceholder.typicode.com/posts" let newPost = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1] Alamofire.request(.POST, postsEndpoint, parameters: newPost, encoding: .JSON) .responseJSON { response in guard response.result.error == nil else { // got an error in getting the data, need to handle it print("error calling GET on /posts/1") print(response.result.error!) return } if let value: AnyObject = response.result.value { // handle the results as JSON, without a bunch of nested if loops
Swift JSON Parsing & Networking Calls 101
27
let post = JSON(value) print("The post is: " + post.description) } }
And DELETE is nice and compact: let firstPostEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.DELETE, firstPostEndpoint) .responseJSON { response in if let error = response.result.error { // got an error while deleting, need to handle it print("error calling DELETE on /posts/1") print(error) } }
Grab the example code on GitHub¹³. So that’s one step better on our journey to nice, clean REST API calls. But we’re still interacting with untyped JSON which can easily lead to errors. Next we’ll take another step towards a class for our post objects using Alamofire with a custom response serializer.
3.3 Alamofire Router Previously we set up some REST API Calls With Alamofire & SwiftyJSON. While it’s a bit of overkill for those simple calls we can improve our code by using an Alamofire router. The router will compose the URL requests for us which will avoid having URL strings throughout our code. A router can also be used to apply headers, e.g., for including an OAuth token or other authorization header. Using a router with Alamofire is good practice since it helps keep our code organized. The router is responsible for creating the URL requests so that our API manager (or whatever makes the API calls) doesn’t need to do that along with all of the other responsibilities that it has. Our previous simple examples included calls to get, create, and delete posts using JSONPlaceholder¹⁴. JSONPlaceholder is a fake online REST API for testing and prototyping. It’s like image placeholders but for web developers. Here’s our previous code:
¹³https://gist.github.com/cmoulton/01fdd4fe2c2e9c8195e1 ¹⁴https://github.com/typicode/jsonplaceholder
Swift JSON Parsing & Networking Calls 101 // Get first post let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.GET, postEndpoint) .responseJSON { response in guard response.result.error == nil else { // got an error in getting the data, need to handle it print("error calling GET on /posts/1") print(response.result.error!) return } if let value: AnyObject = response.result.value { // handle the results as JSON, without a bunch of nested if loops let post = JSON(value) // now we have the results, let's just print them // though a table view would definitely be better UI: print("The post is: " + post.description) if let title = post["title"].string { // to access a field: print("The title is: " + title) } else { print("error parsing /posts/1") } } } // Create new post let postsEndpoint: String = "http://jsonplaceholder.typicode.com/posts" let newPost = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1] Alamofire.request(.POST, postsEndpoint, parameters: newPost, encoding: .JSON) .responseJSON { response in guard response.result.error == nil else { // got an error in getting the data, need to handle it print("error calling GET on /posts/1") print(response.result.error!) return } if let value: AnyObject = response.result.value { // handle the results as JSON, without a bunch of nested if loops let post = JSON(value) print("The post is: " + post.description) } }
28
Swift JSON Parsing & Networking Calls 101
29
// Delete first post let firstPostEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.DELETE, firstPostEndpoint) .responseJSON { response in if let error = response.result.error { // got an error while deleting, need to handle it print("error calling DELETE on /posts/1") print(error) } }
The bits that we’ll be changing look like Alamofire.request(...). Currently we’re providing the URL as a string, like http://jsonplaceholder.typicode.com/posts/1, and the HTTP method, like .GET. Instead of these two parameters Alamofire.request(...) can also take a URLRequestConvertible object such as an NSMutableURLRequest. That’s what we’ll take advantage of to create our router.
3.3.1 Using an Alamofire Router To start we’ll declare a router. It’ll be an enum with a case for each type of call we want to make. A convenient feature of Swift enums is that the cases can have arguments. For example, our .Get case can have an Int argument so we can pass in the ID number of the post that we want to get. We’ll also need the base URL for our API. We can use a computed property to generate the NSMutableURLRequest, which is another nice feature of Swift enums: enum Router: URLRequestConvertible { static let baseURLString = "http://jsonplaceholder.typicode.com/" case Get(Int) case Create([String: AnyObject]) case Delete(Int) var URLRequest: NSMutableURLRequest { ... // TODO: implement } }
We’ll come back and implement the URLRequest computed property in a bit. First let’s see how we need to change our existing calls to use the router. For the GET call all we need to do is to change:
Swift JSON Parsing & Networking Calls 101
30
Alamofire.request(.GET, postEndpoint)
to Alamofire.request(Router.Get(1))
We can also delete this line since all of the URL string handling is now done within the Router: let postEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1"
The .POST call is similar. Change: let postsEndpoint: String = "http://jsonplaceholder.typicode.com/posts" let newPost = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1] Alamofire.request(.POST, postsEndpoint, parameters: newPost, encoding: .JSON)
to let newPost = ["title": "Frist Psot", "body": "I iz fisrt", "userId": 1] Alamofire.request(Router.Create(newPost))
You can see there that the router has abstracted away the encoding as well as the endpoint from this function. And for the .DELETE call, change: let firstPostEndpoint: String = "http://jsonplaceholder.typicode.com/posts/1" Alamofire.request(.DELETE, firstPostEndpoint)
to Alamofire.request(Router.Delete(1))
Now our calls are a bit easier to read. We could make them even clearer by naming the Router cases more descriptively, like Router.DeletePostWithID(1).
3.3.2 Generating the URL Requests The code in this section is pretty Swift-y so if it looks a little odd at first just keep reading. Within the router we need a computed property so that our calls like Router.Delete(1) give us an NSMutableURLRequest that Alamofire.Request() knows how to use. We’ve defined the Router as an enum with a case for each of our 3 calls. So within our URLRequest computed property we can use those 3 cases. For example, we can use a switch statement to define the HTTP methods for each case:
Swift JSON Parsing & Networking Calls 101
31
var method: Alamofire.Method { switch self { case .Get: return .GET case .Create: return .POST case .Delete: return .DELETE } }
And similarly we can create the NSMutableURLRequest in a switch statement: let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .Get(let postNumber): return ("posts/\(postNumber)", nil) case .Create(let newPost): return ("posts", newPost) case .Delete(let postNumber): return ("posts/\(postNumber)", nil) } }()
This switch statement has a few parameters (path: String, parameters: [String: AnyObject]?). That’s the format of the output tuples it will return, like ("posts", newPost) for the .Create case. It also uses the arguments for each case, like the number for the .Get case: case .Get(let postNumber)\. So we can put together those bits to generate the URL request. First generating an NSURL from the base URL: let URL = NSURL(string: Router.baseURLString)!
Appending the path components from the result switch statement: let URLRequest = NSURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
Then creating a URL request including the encoded parameters: (encoding.encode(...) handles nil parameters just fine so we don’t need to check for that):
Swift JSON Parsing & Networking Calls 101 let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters)
Setting the HTTP method: encodedRequest.HTTPMethod = method.rawValue
And finally returning the URL request: return encodedRequest
All together: var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .Get: return .GET case .Create: return .POST case .Delete: return .DELETE } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .Get(let postNumber): return ("posts/\(postNumber)", nil) case .Create(let newPost): return ("posts", newPost) case .Delete(let postNumber): return ("posts/\(postNumber)", nil) } }() let URL = NSURL(string: Router.baseURLString)! let URLRequest = NSURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest }
32
Swift JSON Parsing & Networking Calls 101
33
Save and test out our code. The console log should display the same post titles and lack of errors that we had in the previous section. We’ve created a simple Alamofire router that you can adapt to your API calls. Here’s the example code on GitHub¹⁵.
3.4 Strongly Typed GET and POST Calls with Alamofire We’ve used Alamofire¹⁶ to make some REST requests to a web service. Now let’s clean that up by building a higher layer of abstraction by mapping the JSON to a strongly typed class. That’ll keep our code better organized so we aren’t struggling to keep too many details in our minds at once. First, we’ll need a class to represent the Post objects we’re dealing with. Create a new class in its own file to represent the Post objects. It will have a few properties, an initializer to create new Post objects, and a description function to print out all of the properties, which is handy for debugging: class var var var var
Post { title:String? body:String? id:Int? userId:Int?
required init?(aTitle: String?, aBody: String?, anId: Int?, aUserId: Int?) { self.title = aTitle self.body = aBody self.id = anId self.userId = aUserId } func description() -> String { return "ID: \(self.id)" + "User ID: \(self.userId)" + "Title: \(self.title)\n" + "Body: \(self.body)\n" } }
We’ll be using our router to handle creating the URL requests. It assembles the requests including the HTTP method and the URL, plus any parameters or headers. We don’t need to make any changes since the router still works in terms of url requests and JSON. It doesn’t need to know anything about our Post objects. Create a new file PostRouter.swift for our router: ¹⁵https://github.com/cmoulton/grokRouter ¹⁶https://github.com/Alamofire/Alamofire
Swift JSON Parsing & Networking Calls 101
34
import Alamofire enum PostRouter: URLRequestConvertible { static let baseURLString = "http://jsonplaceholder.typicode.com/" case Get(Int) case Create([String: AnyObject]) case Delete(Int) var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .Get: return .GET case .Create: return .POST case .Delete: return .DELETE } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .Get(let postNumber): return ("posts/\(postNumber)", nil) case .Create(let newPost): return ("posts", newPost) case .Delete(let postNumber): return ("posts/\(postNumber)", nil) } }() let URL = NSURL(string: PostRouter.baseURLString)! let URLRequest = NSURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
When setting up API calls I like to work backwards. Starting with the calls we’d like to make then
Swift JSON Parsing & Networking Calls 101
35
figuring out how to make them work. First, we want to be able to GET a post from an ID number. We can do this in the View Controller’s viewWillAppear function: override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) // MARK: Get Post #1 Post.postByID(1, completionHandler: { result in if let error = result.error { // got an error in getting the data, need to handle it print("error calling POST on /posts") print(error) return } guard let post = result.value else { print("error calling POST on /posts: result is nil") return } // success! print(post.description()) print(post.title) }) }
postByID will take a completion handler. Unlike previous code that we’ve written, we’re using a
completion handler in a function that we’ve written/ We’re not just providing a completion handler to one of Apple or Alamofire’s functions. We’ll see how that works when we implement postByID and how we’ll call the completion handler when we want this function to deal with the results. We’re using a completion handler so we can make the API calls asynchronously. Notice that there are no references to URLs or requests or JSON in the code above. It deals entirely with Posts, not the underlying levels of abstraction. We’ll also want to be able to create Posts by sending them to the server. We’re using the trailing block syntax for newPost.save so we dropped the completionHandler: label from the function call:
Swift JSON Parsing & Networking Calls 101
36
// MARK: POST // Create new post guard let newPost = Post(aTitle: "Frist Psot", aBody: "I iz fisrt", anId: nil, aUserId: 1) else { print("error: newPost isn't a Post") return } newPost.save { result in if let error = result.error { // got an error in getting the data, need to handle it print("error calling POST on /posts") print(error) return } guard let post = result.value else { print("error calling POST on /posts: result is nil") return } // success! print(post.description()) print(post.title) }
We’ve separated creating a new Post object locally from saving it on the server (Post(...) vs newPost.save(...)). We’re leaving the ID number blank on creation since that will be assigned by the server. Let’s set up some Alamofire requests and see how we can interface them to those Post calls. First the GET request (using our handy-dandy router that created URL requests for us): Alamofire.request(PostRouter.Get(1)) .responseJSON { response in // ... }
Wouldn’t it be nice if we could use .responseObject to get a Post object instead of .responseJSON? Turns out we can: Alamofire lets us define custom response serializers that we can use to turn the results of our API call to whatever that we want. A handy-dandy serializer that you’ll want to keep around is for returning a single object from JSON. A response serializer takes the results of the URL request and turns them in to a form that we want to work with. By default, the URL request gets the results as NSData but we’d rather work with a more convenient format like JSON or an object. To create that serializer, we’ll have to extend Alamofire.Request. We’ll create a new file for it called AlamofireRequest+JSONSerializable.swift. Here’s what it’ll look like, we’ll step through how it works shortly:
Swift JSON Parsing & Networking Calls 101
37
import Foundation import Alamofire import SwiftyJSON extension Alamofire.Request { public func responseObject
(completionHandler: Response -> Void) -> Self { let serializer = ResponseSerializer { request, response, data, error in guard error == nil else { return .Failure(error!) } guard let responseData = data else { let failureReason = "Object could not be serialized because input data was nil." let error = Error.errorWithCode(.DataSerializationFailed, failureReason: failureReason) return .Failure(error) } let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments) let result = JSONResponseSerializer.serializeResponse(request, response, responseData, error) switch result { case .Success(let value): let json = SwiftyJSON.JSON(value) if let object = T(json: json) { return .Success(object) } else { let failureReason = "Object could not be created from JSON." let error = Error.errorWithCode(.JSONSerializationFailed, failureReason: failureReason) return .Failure(error) } case .Failure(let error): return .Failure(error) } } return response(responseSerializer: serializer, completionHandler: completionHandler) } }
responseObject<...>(...) declares a new .responseX handling function on Alamofire.Request. The only difference from the standard response function is that it’s using our new responseSerializer which is a custom response serializer. So we’ll be able to call it like:
Swift JSON Parsing & Networking Calls 101
38
Alamofire.request(PostRouter.Get(id)) .responseObject { (response: Response) in // Do stuff with post }
We’ll walk through the responseObject function one layer at a time. Starting with the function declaration: extension Alamofire.Request { public func responseObject(completionHandler: Response -> Void) -> Self { ... } }
It defines a function called responseObject. The bit means this is a generic method: it can work with different types of objects. means that those types must implement the ResponseJSONObjectSerializable protocol (which we’ll have to define). We need that protocol so we can guarantee that any type of object that we pass in will have an init function that takes JSON. The responseObject function takes a single argument called completionHandler. As you might guess, that’s the method we’ll call when we’re done parsing the JSON and creating the object (i.e., to handle the completion of this function). It’s used so we can call this method asynchronously: the caller doesn’t need to wait around for a response, it’ll get notified when we’ve got results for it. The completion handler has a single argument: Response. Alamofire 3 defines the Response struct. It’s a handy way to pack up a bunch of bits where we used to have to use a big tuple like (NSURLRequest?, NSHTTPURLResponse?, Result), with the Result struct packing up the result (our T object and/or an error). Think of the Response and Result structs as little packages of data that make up what we get from fetching the response and serializing it into the format that we want. It’s kind of like when you buy something in person. You hand over your payment and you get back a few things: your purchase, your change, a receipt, or maybe an error message like “card declined” or “you’re short 8 cents”. All of these things make up the response to your purchase. You could also think of the purchase and/or error message as the result of your transaction, whether it’s a success or a failure. Alamofire’s structs are similar: Result has .Success and .Failure cases and might have what you asked for or an error. Response is higher level: it packs up the Result along with all of the other info from the transaction like your original request and the raw response. And the responseObject function returns an Alamofire.Request object. -> Self specifies the return type. Now, we can look at the structure of the responseObject function:
Swift JSON Parsing & Networking Calls 101
39
public func responseObject(completionHandler: Response -> Void) -> Self { let serializer = ResponseSerializer { (request, response, data, error) in // ... } return response(responseSerializer: serializer, completionHandler: completionHandler) }
Within responseObject we create a response serializer that will work with our generic T type and an NSError. The serializer will take in the results of the URL request (request, response, data, error) and use the Result type defined by Alamofire to return success (with the object) or failure (with an error). Our responseObject function just returns the responseSerializer that gets created and allows passing the completion handler where it needs to go. And finally we can look at how we’ll implement responseSerializer: let serializer = ResponseSerializer { request, response, data, error in guard error == nil else { return .Failure(error!) } guard let responseData = data else { let failureReason = "Object could not be serialized because input data was nil." let error = Error.errorWithCode(.DataSerializationFailed, failureReason: failureReason) return .Failure(error) } let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments) let result = JSONResponseSerializer.serializeResponse(request, response, responseData, error) switch result { case .Success(let value): let json = SwiftyJSON.JSON(value) if let object = T(json: json) { return .Success(object) } else { let failureReason = "Object could not be created from JSON." let error = Error.errorWithCode(.JSONSerializationFailed, failureReason: failureReason) return .Failure(error) } case .Failure(let error): return .Failure(error) } }
Swift JSON Parsing & Networking Calls 101
40
The custom response serializer first checks that it has valid data using guard. Then it turns the data in to JSON and parses it using SwiftyJSON. Then it creates a new copy of the type of class we called it with from the JSON: let newObject = T(json: json)
If we can’t create the object from the JSON we return a relevant error. In order for that serializer to work we need to set up the ResponseJSONObjectSerializable protocol: public protocol ResponseJSONObjectSerializable { init?(json: SwiftyJSON.JSON) }
Which just defines that conforming types need to have an init method that works with JSON. It’s what tells our generic objects what they need to be able to do. In this case, they just need to be able to be created from JSON. Let’s implement that for our Post class: final var var var var
class Post: ResponseJSONObjectSerializable { title:String? body:String? id:Int? userId:Int?
... required init?(json: SwiftyJSON.JSON) { self.title = json["title"].string self.body = json["body"].string self.id = json["id"].int self.userId = json["userId"].int } ... }
Using SwiftyJSON it’s easy to parse out the contents of the JSON into the properties of the Post object. Now we need to mate up the Post.postById() call to the GET call using the custom serializer:
Swift JSON Parsing & Networking Calls 101
41
class Post { ... // MARK: API Calls class func postByID(id: Int, completionHandler: (Result) -> Void) { Alamofire.request(PostRouter.Get(id)) .responseObject { (response: Response) in completionHandler(response.result) } } }
And that’s it for the GET call. We can run our nice pretty Post.postByID(1) call now. But, of course, there are always more requirements. We said we’d implement the POST call to save new Posts too. In this case, there isn’t a special class in Alamofire to customize. We just have to get the Post into the correct format to send to the API. In our Post class, we’ll need a method to turn a Post into a Dictionary with String keys (which we’ll call json for convenience): func toJSON() -> Dictionary<String, AnyObject> { var json = Dictionary<String, AnyObject>() if let title = title { json["title"] = title } if let body = body { json["body"] = body } if let id = id { json["id"] = id } if let userId = userId { json["userId"] = userId } return json }
We’re using Dictionary instead of NSDictionary because that’s what an Alamofire.Request takes. To finish implementing the save() function for Posts:
Swift JSON Parsing & Networking Calls 101
42
// POST / Create func save(completionHandler: (Result) -> Void) { guard let fields:Dictionary<String, AnyObject> = self.toJSON() else { print("error: error converting newPost fields to JSON") return } Alamofire.request(PostRouter.Create(fields)) .responseObject { (response: Response) in completionHandler(response.result) } }
3.5 And That’s All And that’s it! Now we can run our nice pretty calls to retrieve and save posts that we set up at the start. Even better, the caller of those functions no longer has any knowledge of how the posts are getting retrieved & saved. We could completely remove Alamofire to switch to RESTKit¹⁷ or a completely different implementation¹⁸ for the API calls without having to touch the view controller code at all. Here’s the demo code for strongly typed API calls with an Alamofire Router¹⁹. Don’t worry if you have more complex JSON to parse. Start with some simple String, numeric and boolean fields for now. Later we’ll do some more complex JSON parsing like handling arrays and dates. Set up one more two simple API calls with custom response serializers or reuse the generic serializers from this chapter.
In the following chapters we’ll build out our gists app using Alamofire. We’ll set up the API calls that we need and tie the results into our user interface. Our UI will include a table view, transitions to detail views for individual gists, a form to create new gists, pull to refresh and swipe to delete. Then we’ll discuss what we can do if we don’t have an internet connection. ¹⁷https://github.com/RestKit/RestKit ¹⁸http://en.wikipedia.org/wiki/IP_over_Avian_Carriers ¹⁹https://github.com/cmoulton/grokRouterAndStrongTypes
4. Why I Use Libraries Like Alamofire There are a bunch of jokes about that 2 hardest things in programming. Some say it’s naming things, estimating and off-by-one errors. Some say it’s estimating & getting paid. I think it’s nailing down your requirements so you know what needs to get done and keeping your code at a single level of abstraction. What do I mean by a single level of abstraction? Well, consider some crufty old Objective-C code: NSArray *myGists = [[NSArray alloc] initWithObjects: [NSString stringWithString:@"text of gist 1"], [NSString stringWithString:@"text of gist 2"], nil]; // do stuff with myGists [myArray release];
That code does something with an array of Gists. But instead of being able to just think about gists, the programmer working on this code also has to think about managing the memory that holds those gists (in alloc and release). So they’ve got to keep 2 different levels of abstraction in their mind. Those objects aren’t just gists to them, they’re also hunks of memory. Sure, under it all the gists are really hunks of memory. And there’s got to be code somewhere that’s aware of that. But it doesn’t have to be in the same place as gist-y operations like starring a gist or editing the text. And that’s how it is with code to hook in to web services: • Somewhere your code has to know about the low level network stuff • Somewhere it has to handle JSON • Somewhere it has to work with gists (or whatever your model objects are) Those three levels of abstraction don’t need to (and shouldn’t) overlap. It’s much less exhausting to work on code at a single level without having to keep flipping your understanding of the code up & down to higher and lower abstractions. You don’t need to use libraries like SwiftyJSON and Alamofire. But they’re often a really good wrapper around lower levels of abstraction. And if they’re open source so you can tweak the code later if you need to, what have you go to lose?
43
5. Hooking Up a REST API to a Table View The UITableView is the bread & butter of tons of iOS apps. Combined with access to a web service, it’s the core of apps from Mailbox to Twitter to Facebook, even Apple’s Notes & App Store apps. We’re going to set up an Xcode project that will get data from the GitHub gists API. We’ll set up a table view that displays the public gists. We’ll need to make a GET API call, parse the returned JSON, and set up a table view to display the results. This chapter doesn’t explain the basics of adding a UITableView to a Swift app. If you’re fuzzy on the details of implementing a UITableView, take a read through Apple’s docs or a nice quick tutorial. This chapter is all about tying a table view to an API call returning an array of items. https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/TableView_iPhone/ CreateConfigureTableView/CreateConfigureTableView.html https://www.weheartswift.com/how-to-make-a-simple-table-view-with-ios-8-and-swift/
. If you don’t want to type along, grab the code from GitHub (tagged “tableview”)¹.
5.1 Our Swift Project We’re going to start working on our GitHub Gists app now, so first we need to create a project in Xcode: Fire up Xcode. Create a new master-detail Swift project (make it universal if you want to). Make sure you select Swift for the language and don’t include Core Data. Add Alamofire 3.1² & SwiftyJSON 2.3³ using CocoaPods (not sure how? Check out the appendix). Open the .xcworkspace file. We’re not going to use most of the boilerplate that Xcode has generated for now, so just ignore it until later. Notice that Xcode created 2 view controllers: a MasterViewController that’s a table view ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/tableview ²https://github.com/Alamofire/Alamofire ³https://github.com/SwiftyJSON/SwiftyJSON
44
Hooking Up a REST API to a Table View
45
controller and a detailViewController. Those will work nicely for our list of gists and detailed gist view. We’ll mostly be working with the MasterViewController for the next few chapters. Create a new file: GitHubAPIManager.swift. This class with be responsible for our API interactions. It’ll help us keep our code organized so our view controllers don’t end up as monstrously huge files. We’ll also be able to more easily share code between the different view controllers. At the top of the new file, import Alamofire and SwiftyJSON: import Foundation import Alamofire import SwiftyJSON class GitHubAPIManager { }
If you’re working with a different API you probably want to name this file something more relevant than GitHubAPIManager.
While working with an API you’ll often end up with a bunch of code that isn’t specific to an object. You might have to set custom headers, keep track of OAuth tokens, handle secrets & IDs, and handle authorization or other general errors. To keep this code from being spread out in a bunch of places like the App Delegate and our model objects, we’ll use our GitHubAPIManager class to handle it. Since there’s only one GitHub API that we’re interacting with, it makes sense to only have a single API manager in our app. So let’s set up this class to have a sharedInstance that we’ll access to get our single GitHubAPIManager object: import Foundation import Alamofire import SwiftyJSON class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() }
Now we can start setting up the API call to get the public gists which doesn’t require authentication. To keep things simple while we get the API call working we’ll just print out the results now. Then we’ll integrate them with the table view. So let’s declare our simple method:
Hooking Up a REST API to a Table View
46
class GitHubAPIManager { ... func printPublicGists() -> Void { // TODO: implement } }
While we’re at it, let’s create a Router too. In GistRouter.swift. The router will be responsible for creating the URL requests and stop our API manager from getting unweildy. It’s pretty similar to the one we set up in the 101 chapter except we’re starting with just one case to make a GET call for public gists: import Foundation import Alamofire enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" case GetPublic() // GET https://api.github.com/gists/public var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .GetPublic: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .GetPublic: return ("/gists/public", nil) } }() let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest
Hooking Up a REST API to a Table View
47
} }
Then to get the public gists: func printPublicGists() -> Void { Alamofire.request(GistRouter.GetPublic()) .responseString { response in if let receivedString = response.result.value { print(receivedString) } } }
To test this code out, go to the MasterViewController and add a viewDidAppear function. It’ll get run each time the main view is shown, like right after launch: override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) // TEST GitHubAPIManager.sharedInstance.printPublicGists() // END TEST }
Now save & run your project. You should see an empty table view in the simulator or on your iPhone. But if your API call succeeds then at the bottom of the screen (in the console) you should see a print out of a bunch of JSON: "[{\"url\":\"https://api.github.com/gists/35877917945abf44fc7a\",\"forks_url\":\"https://a\ pi.github.com/gists/35877917945abf44fc7a/forks\",\"commits_url\":\"https://api.github.com/\ gists/35877917945abf44fc7a/commits\",\"id\":\"35877917945abf44fc7a\",\"git_pull_url\":\"ht\ tps://gist.github.com/35877917945abf44fc7a.git\",\"git_push_url\":\"https://gist.github.co\ m/35877917945abf44fc7a.git\",\"html_url\":\ ...
Add an API calling function like printPublicGists to your API manager. It should retrieve an array of objects and print them to the console.
5.2 Analyzing the API JSON Response The API call we’ll be using to get a list of gists will return a great big hunk of JSON containing an array of gists. When we check the API docs for gists⁴ we can see that the JSON representing a gist has tons of data, mostly about the gists’s owner, the files it contains, and its history: ⁴https://developer.github.com/v3/gists/
Hooking Up a REST API to a Table View
48
{ "url": "https://api.github.com/gists/aa5a315d61ae9438b18d", "forks_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/forks", "commits_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/commits", "id": "aa5a315d61ae9438b18d", "description": "description of gist", "public": true, "owner": { "login": "octocat", "id": 1, "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "user": null, "files": { "ring.erl": { "size": 932, "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2a\ c0ff512853564e/ring.erl", "type": "text/plain", "language": "Erlang", "truncated": false, "content": "contents of gist" } }, "comments": 0, "comments_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/comments/", "html_url": "https://gist.github.com/aa5a315d61ae9438b18d", "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", "git_push_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", "created_at": "2010-04-14T02:15:15Z", "updated_at": "2011-06-20T11:34:15Z",
Hooking Up a REST API to a Table View
49
"forks": [ { "user": { "login": "octocat", "id": 1, "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "url": "https://api.github.com/gists/dee9c42e4998ce2ea439", "id": "dee9c42e4998ce2ea439", "created_at": "2011-04-14T16:00:49Z", "updated_at": "2011-04-14T16:00:49Z" } ], "history": [ { "url": "https://api.github.com/gists/aa5a315d61ae9438b18d/57a7f021a713b1c5a6a199b54c\ c514735d2d462f", "version": "57a7f021a713b1c5a6a199b54cc514735d2d462f", "user": { "login": "octocat", "id": 1, "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs",
Hooking Up a REST API to a Table View
50
"repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "change_status": { "deletions": 0, "additions": 180, "total": 180 }, "committed_at": "2010-04-14T02:15:15Z" } ] }
We’ll convert that JSON into Swift objects. We’ll create a new Gist class that will represent the gists in our code. So add a new Swift file to your Xcode project and name it Gist. In that file, define a Gist class: import Foundation class Gist { }
Look at your API and create a model object class to represent the objects you’ll want to display in your table view.
Now we need to decide what bits of the JSON we want. We could extract all of that info but since we aren’t going to display all of it that would be a lot of work for nothing. We can always come back and parse more fields later. What do we want to display? Well, we’ll have a table view cell with a title, subtitle and image so we need to fill those in. Let’s use the gist’s description, the author’s GitHub ID and the author’s avatar. It might also be handy to have the unique ID for each gist and the url where it’s located. So we need to parse those from the JSON for each gist and use them to create a new gist object. First add those items as properties to the Gist class:
Hooking Up a REST API to a Table View class var var var var var }
51
Gist { id: String? description: String? ownerLogin: String? ownerAvatarURL: String? url: String?
For your model object determine which properties you need to extract from the JSON for display. Create a model class like Gist above with your properties.
We’ll want to create new gists from JSON so we can add an initializer for that class that takes in JSON to create a gist object. We’ll need to import SwiftyJSON. We’ll also create a simple init function that we can use to create Gists without pulling them down from the GitHub API: import SwiftyJSON class var var var var var
Gist { id: String? description: String? ownerLogin: String? ownerAvatarURL: String? url: String?
required init(json: JSON) { self.description = json["description"].string self.id = json["id"].string self.ownerLogin = json["owner"]["login"].string self.ownerAvatarURL = json["owner"]["avatar_url"].string self.url = json["url"].string } required init() { } }
Set up your init function to take one of the entries in your JSON and create an instance of your model object class. If some of your properties aren’t strings then refer to the 101 chapter to parse numbers and boolean values. If you have arrays of items (like the Files for a gist) or dates we’ll cover those when we create a detail view.
Hooking Up a REST API to a Table View
52
5.3 Setting Up the Table View Now, we can get into the code. Since we let Xcode create a Master-Detail project it has filled in a bunch of code for us. Let’s quickly step through the MasterViewController so we know what’s already done for us. Starting at the top: class MasterViewController: UITableViewController { var detailViewController: DetailViewController? = nil var objects = [AnyObject]()
There’s a MasterViewController with a connection to a DetailViewController (to show a detail view when we tap on a row). And there’s an array of objects. We’ll want to change those to gists and declare them as an array of Gists so we know what we’re working with: class MasterViewController: UITableViewController { var detailViewController: DetailViewController? = nil var gists = [Gist]()
Name that array and the model object class so they’re relevant to your app.
Next: override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. self.navigationItem.leftBarButtonItem = self.editButtonItem() let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "insertNewObject:") self.navigationItem.rightBarButtonItem = addButton if let split = self.splitViewController { let controllers = split.viewControllers self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController } }
Hooking Up a REST API to a Table View
53
In viewDidLoad a few buttons get added to the navigation bar: an edit button on the left and an add button on the right. A connection to the detailViewController gets hooked up so we can use it later tell the detailViewController to display the details for the selected gist. Then: override func viewWillAppear(animated: Bool) { self.clearsSelectionOnViewWillAppear = self.splitViewController!.collapsed super.viewWillAppear(animated) }
Just before the view appears there’s some tweaking to a display setting: clearsSelectionOnViewWillAppear means that rows can stay selected even if we go to another screen. That makes sense for the iPad split view but not for the iPhone view that only shows the table view. We’ll need to load the data when this view gets shown, so let’s add that now. We’ll do it in the viewDidAppear function: func loadGists() { GitHubAPIManager.sharedInstance.printPublicGists() } override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) loadGists() }
We’d normally do data loading in viewWillAppear so it’s on the screen as soon as possible. But later we’re going to want to pop up a login view if they haven’t logged in already and we can’t present a new view controller until the current view controller has finished appearing. So we’re using viewDidAppear.
.
Set up a function like loadGists to retrieve your model objects.
Later we’ll need to replace the call to printPublicGists in loadGists with a function that gets us the array of Gists in this class so we can display them.
Hooking Up a REST API to a Table View
54
override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. }
If we have significant assets (like large images) or anything that can easily be recreated then getting rid of them in didReceiveMemoryWarning lets our app handle warnings about low memory gracefully. func insertNewObject(sender: AnyObject) { objects.insert(NSDate(), atIndex: 0) let indexPath = NSIndexPath(forRow: 0, inSection: 0) self.tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic) }
insertNewObject was hooked up to the add button in viewDidLoad. It creates a new object and adds
it to the table view. It’s going to be a while before we get around to implementing creating a new gist so let’s just add an alert about this button not working yet: func insertNewObject(sender: AnyObject) { let alert = UIAlertController(title: "Not Implemented", message: "Can't create new gists yet, will implement later", preferredStyle: UIAlertControllerStyle.Alert) alert.addAction(UIAlertAction(title: "OK", style: UIAlertActionStyle.Default, handler: nil)) self.presentViewController(alert, animated: true, completion: nil) }
Next we have prepareForSegue which sets up for a transition to the detail view: // MARK: - Segues override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if segue.identifier == "showDetail" { if let indexPath = self.tableView.indexPathForSelectedRow { let object = objects[indexPath.row] as! NSDate let controller = (segue.destinationViewController as! UINavigationController).topViewController as! DetailViewController controller.detailItem = object controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem() controller.navigationItem.leftItemsSupplementBackButton = true
Hooking Up a REST API to a Table View
55
} } }
Again we need to convert it to use our gists instead of generic objects. We’ll also have it check that the destination is a DetailViewController instead of just assuming that’s what we’re getting: // MARK: - Segues override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if segue.identifier == "showDetail" { if let indexPath = self.tableView.indexPathForSelectedRow { let gist = gists[indexPath.row] as Gist if let detailViewController = (segue.destinationViewController as! UINavigationController).topViewController as? DetailViewController { detailViewController.detailItem = gist detailViewController.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem() detailViewController.navigationItem.leftItemsSupplementBackButton = true } } } }
Later we’ll set up the detail view controller to display gists. Next come the methods for telling the table view what to display: // MARK: - Table View override func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 1 } override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return objects.count } override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
56
Hooking Up a REST API to a Table View let object = objects[indexPath.row] as! NSDate cell.textLabel!.text = object.description return cell }
Again, we need to switch to gists and change tableView:cellForRowAtIndexPath:indexPath: to use our new properties to display the description and owner ID. We’ll come back and set the owner’s avatar image later since that’s a bit of work and we don’t want to get off track. First make an adjustment to the table view cells in the storyboard so they have 2 lines of text in them: 1. Open the main storyboard and select the table view in the master view controller 2. Select the prototype table view cell and change its style to Subtitle so we can have 2 lines of text in it
Select table view
57
Hooking Up a REST API to a Table View
Change cell style to subtitle
Then we can update the code to show our gists: // MARK: - Table View override func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 1 } override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return gists.count } override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) let gist = gists[indexPath.row] cell.textLabel!.text = gist.description cell.detailTextLabel!.text = gist.ownerLogin // TODO: set cell.imageView to display image at gist.ownerAvatarURL return cell }
The next bit refers to editing the gists: deleting and creating them. We’ll just tweak it to not allow editing for now:
Hooking Up a REST API to a Table View
58
override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool { // Return false if you do not want the specified item to be editable. return true } override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { objects.removeAtIndex(indexPath.row) tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) } else if editingStyle == .Insert { // Create a new instance of the appropriate class, insert it into the array, // and add a new row to the table view. } }
Becomes: override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool { // Return false if you do not want the specified item to be editable. return false } override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { gists.removeAtIndex(indexPath.row) tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) } else if editingStyle == .Insert { // Create a new instance of the appropriate class, insert it into the array, // and add a new row to the table view. } }
You should now be able to run the app without errors, though it’ll just display an empty table view. To test our table view code we can create a few fake local Gists instead of loading them from GitHub. To do so change loadGists() to create a few gists in the gists array:
59
Hooking Up a REST API to a Table View func loadGists() { let gist1 = Gist() gist1.description = "The first gist" gist1.ownerLogin = "gist1Owner" let gist2 = Gist() gist2.description = "The second gist" gist2.ownerLogin = "gist2Owner" let gist3 = Gist() gist3.description = "The third gist" gist3.ownerLogin = "gist3Owner" gists = [gist1, gist2, gist3] // Tell the table view to reload self.tableView.reloadData() }
Save & run to make sure that works. It should look like this:
Showing fake gists
And you should get an alert when you tap the add button:
60
Hooking Up a REST API to a Table View
Not yet implemented error
Follow through the previous section to make sure that you can create your model objects and display them in the table view.
Once you’re sure that works, restore the loadGists() to the way it was: func loadGists() { GitHubAPIManager.sharedInstance.printPublicGists() }
5.4 Getting & Processing the API Response Back in the 101 chapter we created a handy extension to Alamofire.Request public func responseObject
That extension let us handle a response from Alamofire that returned an object as JSON and create a Swift object (as long as the Swift class had the right kind of initializer, as per the ResponseJSONObjectSerializable protocol). Now we need something similar but for a whole array of objects: We want to take a JSON array and turn it into an array of Swift objects. We’ll keep our protocol so we need to add to our project. Add a new ResponseJSONObjectSerializable.swift file and add the protocol to it. You’ll need to import SwiftyJSON:
Hooking Up a REST API to a Table View
61
import Foundation import SwiftyJSON public protocol ResponseJSONObjectSerializable { init?(json: SwiftyJSON.JSON) }
And tell the compiler that our Gist class implements that protocol (which it already does since it has an initializer that takes JSON): class Gist: ResponseJSONObjectSerializable { ... }
We’ll also copy over the responseObject function since we might want it later. We’ll create a new file for it named AlamofireRequest+JSONSerializable.swift, since it’s an extension to Alamofire.Request that adds JSON serialization: public func responseObject(completionHandler: Response -> Void) -> Self { let serializer = ResponseSerializer { request, response, data, error in guard error == nil else { return .Failure(error!) } guard let responseData = data else { let failureReason = "Object could not be serialized because input data was nil." let error = Error.errorWithCode(.DataSerializationFailed, failureReason: failureReason) return .Failure(error) } let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments) let result = JSONResponseSerializer.serializeResponse(request, response, responseData, error) switch result { case .Success(let value): let json = SwiftyJSON.JSON(value) if let object = T(json: json) { return .Success(object) } else { let failureReason = "Object could not be created from JSON." let error = Error.errorWithCode(.JSONSerializationFailed, failureReason: failureReason)
Hooking Up a REST API to a Table View
62
return .Failure(error) } case .Failure(let error): return .Failure(error) } } return response(responseSerializer: serializer, completionHandler: completionHandler) }
Now we need something similar but for an array so it returns an array of objects: [T] instead of just an object: T: extension Alamofire.Request { public func responseObject(completionHandler: Response -> Void) -> Self { let serializer = ResponseSerializer { // ... } return response(responseSerializer: serializer, completionHandler: completionHandler) } public func responseArray(completionHandler: Response<[T], NSError> -> Void) -> Self { let serializer = ResponseSerializer<[T], NSError> { // ... } return response(responseSerializer: serializer, completionHandler: completionHandler) } }
Which can be implemented pretty similarly:
Hooking Up a REST API to a Table View
63
public func responseArray( completionHandler: Response<[T], NSError> -> Void) -> Self { let serializer = ResponseSerializer<[T], NSError> { request, response, data, error in guard error == nil else { return .Failure(error!) } guard let responseData = data else { let failureReason = "Array could not be serialized because input data was nil." let error = Error.errorWithCode(.DataSerializationFailed, failureReason: failureReason) return .Failure(error) } let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments) let result = JSONResponseSerializer.serializeResponse(request, response, responseData, error) switch result { case .Success(let value): let json = SwiftyJSON.JSON(value) var objects: [T] = [] for (_, item) in json { if let object = T(json: item) { objects.append(object) } } return .Success(objects) case .Failure(let error): return .Failure(error) } } return response(responseSerializer: serializer, completionHandler: completionHandler) }
The big difference is that we iterate through the elements in the json: for (_, item) in json and create an object out of each one: let object = T(json: item), adding them to the array if the creation succeeds. Now we need to: 1. Set up a function that GETs the public gists, parses them into an array and returns them 2. Hook up that function and its returned array into the table view The getPublicGists function will look a lot like our printPublicGists function:
Hooking Up a REST API to a Table View
64
func printPublicGists() -> Void { Alamofire.request(GistRouter.GetPublic()) .responseString { response in if let receivedString = response.result.value { print(receivedString) } } }
The big difference is that it needs to return the array instead of printing it. So we’ll replace responseString with our new generic responseArray response serializer. We can add this function to our GitHubAPIManager: func getPublicGists() -> Void { Alamofire.request(GistRouter.GetPublic()) .responseArray { ... } }
That might look a bit odd, we just said we’re going to return an array but getPublicGists() has a return type of Void. That’s because making the request to the API is an asynchronous process: we fire off the request and get notified when it’s done. We can set up the code with a completion handler. That lets us add a block of code to be called when the method is done. Our completion handler needs to handle 2 possibilities: we might return an array of Gists and we might return an error. So the signature for the completion handler is (Result<[T], NSError>). That’s a special type that Alamofire has created to allow us to return either a .Success case with the array of gists or a .Failure case with an error. So we’ll fire off our request then apply the responseArray response serializer that we set up above. That’ll hand us back the gists if it managed to get and parse them or an error if one occurred: func getPublicGists(completionHandler: (Result<[Gist], NSError>) -> Void) { Alamofire.request(.GET, "https://api.github.com/gists/public") .responseArray { (response:Response<[Gist], NSError>) in ... } }
Since the responseArray completion handler has a generic type for the array of objects returned we need to explicitly declare the type when we make this call using result:Response<[Gist],
Hooking Up a REST API to a Table View
65
NSError>. Otherwise when we get to the point of creating objects from the JSON our app would
have no idea what type of object to create. The completion handler for getPublicGists matches the one for responseArray and there aren’t any special errors we want to handle right there. So we can just call the completion handler in the .responseArray block. It’ll be the job of whoever called getPublicGists to handle any errors: func getPublicGists(completionHandler: (Result<[Gist], NSError>) -> Void) { Alamofire.request(GistRouter.GetPublic()) .responseArray { (response:Response<[Gist], NSError>) in completionHandler(response.result) } }
Create a function like getPublicGists in your API manager that will return your array of objects.
Ok, so now we need to figure out when to call getPublicGists. Let’s look at our MasterViewController and see how we were firing off our API call earlier: func loadGists() { GitHubAPIManager.sharedInstance.printPublicGists() } override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) loadGists() }
It looks like it’ll make sense to replace the call to printPublicGists() with a call to getPublicGists. That’s easily done: func loadGists() { GitHubAPIManager.sharedInstance.getPublicGists() { result in guard result.error == nil else { print(result.error) // TODO: display error return } if let fetchedGists = result.value { self.gists = fetchedGists
66
Hooking Up a REST API to a Table View } self.tableView.reloadData() } }
So we fire off the async call to get the gists. If it succeeds then we save the gists in the local array variable and tell the table view to refresh itself with the new data. Nice & easy. Set up your function like loadGists to call your function like getPublicGists and save the resulting array of objects in your MasterViewController so they can be displayed in the table view.
Ok, so now our API call and our table view code should be fully integrated. Save & run and see what happens.
Displaying public gists
5.5 And That’s All Now we have a core app to build on that displays our list of gists. We’ll keep working on it to add:
Hooking Up a REST API to a Table View
• • • • • •
Displaying images in table view cells Loading more gists as you scroll down Pull to refresh Detail views for gists Deleting gists Creating new gists
If you got tired of typing grab the code from GitHub (tagged “tableview”)⁵. ⁵https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/tableview
67
6. Custom Headers There are three ways to include custom headers in Alamofire requests: for the session (through the manager’s configuration), for a single call through the headers argument, or through URLRequestConvertible. We’ll show one of each.
6.1 Session Headers Some headers are commonly send with every request to an API, like an API key or the Accept header. So it makes sense to set those headers once for the whole session instead of needing to set it for each request. We can do this through Alamofire’s manager: let manager = Alamofire.Manager.sharedInstance manager.session.configuration.HTTPAdditionalHeaders = ["Accept": "application/json"]
Then we just need to make requests using the manager, instead of using the Alamofire class methods, and the API key header will be passed in each request: let manager = Alamofire.Manager.sharedInstance manager.request(...)
instead of Alamofire.request(...)
So we can reuse the Alamofire manager, we’ll add one to GitHubAPIManager:
68
Custom Headers
69
class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() var alamofireManager:Alamofire.Manager let clientID: String = "1234567890" let clientSecret: String = "abcdefghijkl" init () { let configuration = NSURLSessionConfiguration.defaultSessionConfiguration() alamofireManager = Alamofire.Manager(configuration: configuration) } ... }
Add an Alamofire manager instance to your API manager class.
6.2 Per Request Headers What if we want to set a header for a single request? Alamofire now provides an easy way to do this (as of Alamofire 1.3). First we create a dictionary for our custom headers: let headers = ["Accept": "application/json"]
Then we pass it in our call to manager.request (or Alamofire.request if we don’t want to include the session headers we set above): let urlString = "https://api.github.com/gists" manager.request(.GET, urlString, headers: headers).responseJSON(...)
or: let urlString = "https://api.github.com/gists" Alamofire.request(.GET, urlString, headers: headers).responseJSON(...)
6.3 Headers in URLRequestConvertible Since we’re using an Alamofire Router to construct our URL requests, we can add headers to the NSMutableURLRequests. For example:
Custom Headers
70
let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) URLRequest.setValue("application/json", forHTTPHeaderField: "Accept")
Then when that URL request gets used the headers are included in the call. We’ll use this approach later when setting the header for our OAuth token.
6.4 And That’s All For Headers Headers are used for numerous functions including caching and handling cookies. All three auth methods that we’ll implement a bit later use headers so we’ll get some practice with them. Check your documentation for required headers and set them in your API manager if needed. Don’t worry about authentication headers yet, we’ll handle those in the authentication chapter.
Before we dig in to authentication we’ll work on improving the basic table view that we’ve already set up. We’ll add the gist owner’s avatar image to each cell, let users get more gists when they scroll down, and add pull to refresh so they can easily get the latest gists.
7. Loading UITableViewCell Images from an API So far we’ve set up a Swift app that fetched gists from the GitHub API. To do so it: • • • •
Pulls public gists data from the GitHub gists API Uses an Alamofire response serializer to process the JSON into an array of Swift objects Parses some fields in the web service JSON Displays the results in a table view
In this chapter we’ll add a new feature: displaying images of the gist owner’s avatar in each row in the table view. We’ll get the URLs from the web-based API then load the images asynchronously from the URLs. We’ll have to handle table view cells getting reused while we’re trying to retrieve the images and we’ll set up an image cache so we don’t have to pull down the images every time a cell gets reused. If your API has images for your main objects then follow along with this section. Otherwise you might want to skip it and come back when you do need to load images from URLs, even if they’re not in a table view.
Here’s what it’ll look like when we’re done:
Final cell images
If you haven’t been following along, you might want to grab the code that’ll be our starting point from GitHub (tagged “headers”)¹. ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/headers
71
Loading UITableViewCell Images from an API
72
Or if you’d rather not type, grab the completed code from this chapter (tagged “cellImages”)².
7.1 Loading UIImages from URLs We’ve already set up our Gist class to parse out the gist owner’s avatar URL in the init(json:) function: self.ownerAvatarURL = json["owner"]["avatar_url"].string
So we’ve got everything set up to retrieve the image URLs. Now we need to get the actual image data from that URL. Adding to our GitHubAPIManager again: class GitHubAPIManager { ... func imageFromURLString(imageURLString: String, completionHandler: (UIImage?, NSError?) -> Void) { alamofireManager.request(.GET, imageURLString) .response { (request, response, data, error) in // use the generic response serializer that returns NSData if data == nil { completionHandler(nil, nil) return } let image = UIImage(data: data! as NSData) completionHandler(image, nil) } } }
So we take the imageURLString and use it to make a GET request. When we get the results (as NSData since we’re using .response) we check that there is data and if so try to turn it in to an image. If we get an image, we pass that back in the completion handler. If that fails (or there isn’t an image for our search string), we kick out the error to the completion handler and return.
7.2 UITableViewCell Images from URLs Now we can hook up displaying images. In MasterViewController’s cellForRowAtIndexPath: ²https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/cellImages
Loading UITableViewCell Images from an API
73
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) let gist = gists[indexPath.row] cell.textLabel!.text = gist.description cell.detailTextLabel!.text = gist.ownerLogin if let urlString = gist.ownerAvatarURL { GitHubAPIManager.sharedInstance.imageFromURLString(urlString, completionHandler: { (image, error) in if let error = error { print(error) } if let cellToUpdate = self.tableView?.cellForRowAtIndexPath(indexPath) { cellToUpdate.imageView?.image = image // will work fine even if image is nil // need to reload the view, which won't happen otherwise // since this is in an async call cellToUpdate.setNeedsLayout() } }) } else { cell.imageView?.image = nil } return cell }
We check that we have a URL string for the image: if let urlString = gist.ownerAvatarURL. If so we fire off the method that we just wrote. If we don’t then we clear out the image for the cell: GitHubAPIManager.sharedInstance.imageFromURLString(urlString, completionHandler: { ... } else { cell.imageView?.image = nil }
In the completion handler we first check for errors, just printing them if we have one. We don’t use guard here because we should blank out any previous existing image in this case and guard forces us to return:
Loading UITableViewCell Images from an API
74
completionHandler: { (image, error) in if let error = error { print(error) } if let cellToUpdate = self.tableView?.cellForRowAtIndexPath(indexPath) { cellToUpdate.imageView?.image = image // will work fine even if image is nil // need to reload the view, which won't happen otherwise // since this is in an async call cellToUpdate.setNeedsLayout() } })
And if we don’t have an error we set the image that we received as the cell’s image. This is a bit complicated by the fact that our imageFromURLString is asynchronous and by how UITableView reuses cells. Since we’re using dequeueReusableCellWithIdentifier the table view will reuse cells that have scrolled off of the screen. For example, if we have 20 gists but can only see 10 cells on the screen at once the table view will only create about 12-14 cells. If our cell has scrolled off of the screen then we shouldn’t set the image, since it’ll be for the wrong gist. We can work around this problem by using the index path once we’ve got the image, asking the table view for the cell for the index path (row and section): if let cellToUpdate = self.tableview?.cellForRowAtIndexPath(indexPath)`
Then we can set the image on that cell. If the cell is currently being shown, that’ll give us the cell that’s on the screen for our index path. Since we’re all async here we also need to tell the cell that we’ve changed part of its view and it needs to redraw itself using cellToUpdate.setNeedsLayout(). Note that we don’t need to check if our image is nil before setting it to the cell’s imageView. If the image is nil for some reason then we’ll blank out the cell’s image when we do cellToUpdate.imageView?.image = result.value, which is really the best we can do in that case. Save and run. You should now see avatars for each gist, if the user has set one:
75
Loading UITableViewCell Images from an API
Avatars
If your API has images, set them up to load as we did in this section.
7.3 Enhancements We’re firing off requests to get the images for each cell but sometimes by the time we get the result we don’t need it anymore. An optimization would be to be cancel the Alamofire requests if the cell scrolls off of the screen. If you’re dealing with lots of images you’d want to do that (you’ll know you need it if the scrolling isn’t smooth). Our scrolling seems pretty smooth and we’re going to add a cache, so this concern isn’t one that needs to be addressed right now. An optimization that is worthwhile even for this small app is caching the images so we don’t have to grab them from the web every time they’re shown. We’ll do a quick and easy single-run-of-the-app cache to see how it will work. Then we’ll replace our cache with PINRemoteImage which will give us a smarter persistent cache.
Loading UITableViewCell Images from an API
76
7.4 Caching Images In our MasterViewController we can add a dictionary to hold the images indexed by their URL: var imageCache: Dictionary<String, UIImage?> = Dictionary<String, UIImage?>()
Now we can save the images when we get them (in our cellForRowAtIndexPath function): if let urlString = gist.ownerAvatarURL { GitHubAPIManager.sharedInstance.imageFromURLString(urlString, completionHandler: { (image, error) in if let error = error { print(error) } // Save the image so we won't have to keep fetching it if they scroll self.imageCache[urlString] = image if let cellToUpdate = self.tableView?.cellForRowAtIndexPath(indexPath) { cellToUpdate.imageView?.image = image // will work fine even if image is nil // need to reload the view, which won't happen otherwise // since this is in an async call cellToUpdate.setNeedsLayout() } }) } else { cell.imageView?.image = nil }
And then before retrieving the image, we’ll check the cache to see if we already have it: if let urlString = gist.ownerAvatarURL { if let cachedImage = imageCache[urlString] { cell.imageView?.image = cachedImage // will work fine even if image is nil } else { GitHubAPIManager.sharedInstance.imageFromURLString(urlString, completionHandler: { ... }) } } else { cell.imageView?.image = nil }
So finally:
Loading UITableViewCell Images from an API
77
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) let gist = gists[indexPath.row] cell.textLabel!.text = gist.description cell.detailTextLabel!.text = gist.ownerLogin // set cell.imageView to display image at gist.ownerAvatarURL if let urlString = gist.ownerAvatarURL { if let cachedImage = imageCache[urlString] { cell.imageView?.image = cachedImage // will work fine even if image is nil } else { GitHubAPIManager.sharedInstance.imageFromURLString(urlString, completionHandler: { (image, error) in if let error = error { print(error) } // Save the image so we won't have to keep fetching it if they scroll self.imageCache[urlString] = image if let cellToUpdate = self.tableView?.cellForRowAtIndexPath(indexPath) { cellToUpdate.imageView?.image = image // will work fine even if image is nil // need to reload the view, which won't happen otherwise // since this is in an async call cellToUpdate.setNeedsLayout() } }) } } else { cell.imageView?.image = nil } return cell }
To test out this code you’ll have to set a breakpoint and step through to see which lines get executed. To add a breakpoint, click on the line number next to the code where you want the code execution to stop:
78
Loading UITableViewCell Images from an API
Adding a breakpoint
Then run the app. When it gets to the breakpoint the code will stop running. At that point you can use the panel at the bottom of Xcode to examine variables and step through the code:
Stopped at breakpoint
Click on the “step over” button to go to the next line of code:
Step over button
Watch which code path gets taken by seeing which line is highlighted. If there is an avatarURL it’ll try to load it:
79
Loading UITableViewCell Images from an API
Load image
If there isn’t one then it’ll set the cell image to nil:
No image
To resume the program until the next time the breakpoint is hit you can click the continue button:
Continue button
When you run the app all of the images will get loaded from their URLs the first time that they’re displayed because we’re not persisting the image cache between runs of the app. We’ll fix that in the next session. Since table view cells get reused our cache will kick in when we scroll cells on and off of the screen. The easiest way to see when the cache gets used is to move the breakpoint to where we’re loading the image from the cache:
Loading image from cache
Loading UITableViewCell Images from an API
80
After the initial cells load scroll up and down. You should hit the breakpoint and see that line getting executed.
7.5 A Better Cache: PINRemoteImage We’ve set up our Alamofire calls to parse an image URL out of JSON as a string, then to load the image from the URL. We then used those images in UITableViewCells, handling the asynchronous loading even though the cells might have been reused. We set up a primitive cache for a single run of the app that helps us avoid constantly reloading the images. Before using this code in a released app it would be worth setting up a persistent cache that’s used for multiple runs of the app. PINRemoteImage³ is a great new persistent image caching library so let’s use that. Using this cache will make sure that images are only loaded once and it’s simpler to integrate than our non persistent cache. Add PINRemoteImage v1.2 to your project using CocoaPods. Import it into the MasterViewController file and delete our simple cache: import UIKit import PINRemoteImage class MasterViewController: UITableViewController { ... var imageCache: Dictionary<String, UIImage?> = Dictionary<String, UIImage?>() ... }
PINRemoteImage works best with a placeholder image and it’s a nice touch too. So toss a small square image into your app (just drag & drop it in then select the option to copy it into your project). Rename the image to “placeholder.png”. Then we can remove the imageCache above and simplify the cell code. We’ll also display the placeholder for users who don’t have an avatar set:
³https://github.com/pinterest/PINRemoteImage
81
Loading UITableViewCell Images from an API override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) let gist = gists[indexPath.row] cell.textLabel!.text = gist.description cell.detailTextLabel!.text = gist.ownerLogin cell.imageView?.image = nil // set cell.imageView to display image at gist.ownerAvatarURL if let urlString = gist.ownerAvatarURL, url = NSURL(string: urlString) { cell.imageView?.pin_setImageFromURL(url, placeholderImage: UIImage(named: "placeholder.png")) } else { cell.imageView?.image = UIImage(named: "placeholder.png") } return cell }
7.6 And That’s All Save and run to test that out. You should end up with some avatars loaded and your placeholder for those who don’t have avatars:
Cell images
If you got tired of typing, here’s the code with PINRemoteImage: (tagged “cellImages”)⁴. ⁴https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/cellImages
8. Pagination, a.k.a., Load More on Scroll When GitHub gives us data it only sends a limited amount at a time. For example, if we ask for all of the public gists that exist GitHub won’t actually give us all of them. It’ll give us the 16 or so most recent gists. If we want to show more than those gists then we need to hit the API again to get more.
8.1 Where is the Next Page? First we need to figure out how GitHub provides more of the results. Fortunately they’ve got some solid documentation on their Pagination¹. The short & simple explanation is that you add ?page=2 to the API endpoint, then ?page=3, then ?page=4, … The more correct answer is that you should check the headers in the response, specifically the link header. For our request for public gists the link header looks like this: ; rel="next", ; rel="last"
So to get the next set of results we call https://api.github.com/gists/public?page=2. If we make that call then the link header gets more complicated: ; rel="next", ; rel="last", ; rel="first", ; rel="prev"
For our load more function we only need the next url, so let’s extract that:
¹https://developer.github.com/guides/traversing-with-pagination/
82
Pagination, a.k.a., Load More on Scroll
83
private func getNextPageFromHeaders(response: NSHTTPURLResponse?) -> String? { if let linkHeader = response?.allHeaderFields["Link"] as? String { /* looks like: ; rel="next", ; rel="last" */ // so split on "," then on ";" let components = linkHeader.characters.split {$0 == ","}.map { String($0) } // now we have 2 lines like // '; rel="next"' // So let's get the URL out of there: for item in components { // see if it's "next" let rangeOfNext = item.rangeOfString("rel=\"next\"", options: []) if rangeOfNext != nil { let rangeOfPaddedURL = item.rangeOfString("<(.*)>;", options: .RegularExpressionSearch) if let range = rangeOfPaddedURL { let nextURL = item.substringWithRange(range) // strip off the < and >; let startIndex = nextURL.startIndex.advancedBy(1) let endIndex = nextURL.endIndex.advancedBy(-2) let urlRange = startIndex..<endIndex return nextURL.substringWithRange(urlRange) } } } } return nil }
Ok, that looks a bit complicated. Let’s start at the top and walk through it. First we’re declaring a function that’ll take the NSHTTPURLResponse and extract the next page header as a String: private func getNextPageFromHeaders(response: NSHTTPURLResponse?) -> String? {
First we get the headers from the response to our request: if let linkHeader = response?.allHeaderFields["Link"] as? String { ... }
That header is made up of components like ; rel="type", separated by commas. So first we’ll split them by the commas to get those components into an array that we can loop through:
Pagination, a.k.a., Load More on Scroll
84
// so split on "," then on ";" let components = linkHeader.characters.split {$0 == ","}.map { String($0) } for item in components { ... }
Then as we loop through the components, we’ll find the one with the next url by checking if it has rel="next" in it: for item in components { // see if it's "next" let rangeOfNext = item.rangeOfString("rel=\"next\"", options: []) if rangeOfNext != nil { // found the component with the next URL ... } }
Then we need to parse that component to draw out the next URL. We can do that using regular expressions to match the pattern that we expect. Regular expressions² are special string patterns that describe how to search through a string. For example, our URL is wrapped in a few characters like <(.*)>;, where (.*) is the URL. <(.*)>; is the regular expression that describes how to find the URL in the text. So we can search for that pattern and we’ll find our URL. Then we’ll have to remove those few characters that aren’t part of the URL < and >;: let rangeOfNext = item.rangeOfString("rel=\"next\"", options: []) if rangeOfNext != nil { let rangeOfPaddedURL = item.rangeOfString("<(.*)>;", options: .RegularExpressionSearch) if let range = rangeOfPaddedURL { let nextURL = item.substringWithRange(range) // strip off the < and >; let startIndex = nextURL.startIndex.advancedBy(1) let endIndex = nextURL.endIndex.advancedBy(-2) let urlRange = startIndex..<endIndex return nextURL.substringWithRange(urlRange) } }
²https://en.wikipedia.org/wiki/Regular_expression
Pagination, a.k.a., Load More on Scroll
85
8.2 Fetching and Appending Now we know the URL to call to get more results when the user scrolls down. But when should we be calling it? We’ll need to make the URL available to whoever is calling this function, so we need to add it to the completion handler. That will extend the completion handler to also return a string (the next url): func getPublicGists(completionHandler: (Result<[Gist], NSError>, String?) -> Void) { alamofireManager.request(GistRouter.GetPublic()) .validate() .responseArray { (response:Response<[Gist], NSError>) in guard response.result.error == nil, let gists = response.result.value else { print(response.result.error) completionHandler(response.result, nil) return } // need to figure out if this is the last page // check the link header, if present let next = self.getNextPageFromHeaders(response.response) completionHandler(.Success(gists), next) } }
Now we need to be able to load the later pages of gists. That requires passing in a URL and loading gists from it. We can generalize the code in getGists and getPublicGists to do that: func getGists(urlRequest: URLRequestConvertible, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { alamofireManager.request(urlRequest) .validate() .responseArray { (response:Response<[Gist], NSError>) in guard response.result.error == nil, let gists = response.result.value else { print(response.result.error) completionHandler(response.result, nil) return } // need to figure out if this is the last page // check the link header, if present let next = self.getNextPageFromHeaders(response.response)
Pagination, a.k.a., Load More on Scroll
86
completionHandler(.Success(gists), next) } } func getPublicGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetPublic(), completionHandler: completionHandler) } }
We’ll also need to extend the Router to generate the URL requests for GetAtPath: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" case GetPublic() // GET https://api.github.com/gists/public case GetAtPath(String) // GET at given path var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .GetPublic: return .GET case .GetAtPath: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .GetPublic: return ("/gists/public", nil) case .GetAtPath(let path): let URL = NSURL(string: path) let relativePath = URL!.relativePath! return (relativePath, nil) } }() let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
Pagination, a.k.a., Load More on Scroll
87
let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
Getting the path for GetAtPath is a little tricky since we already have the full URL. Fortunately NSURL lets us grab the relative path. Alternatively we could have modified the let URL = ... code to use the full path that was passed. If you need to, figure out how you can get more results from your API. Modify your existing functions like getGists and getPublicGists in your API manager to handle it. Instead of a next URL you might need to pass in the page number explicitly, the number of objects you’ve already loaded, or the ID of the last object that you’ve loaded. Pagination is one of those features that can vary a lot between APIs but you should still be able to use the framework presented here.
8.3 Integrating with the View Controller That should do it for the GitHubAPIManager. Now switch over to our MasterViewController. We’ll have to update all of those calls to get gists and save the next URL if we get one. We’ll also need to able to pass in a URL to load from: class MasterViewController: UITableViewController { ... var nextPageURLString: String? ... }
Then we pass through the url to load when we call loadGists, even if it’s nil:
Pagination, a.k.a., Load More on Scroll
88
func loadGists(urlToLoad: String?) { GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.nextPageURLString = nextPage guard result.error == nil else { print(result.error) // TODO: display error return } if let fetchedGists = result.value { self.gists = fetchedGists } self.tableView.reloadData() } }
See any problems with that? How about this bit? if let fetchedGists = result.value { self.gists = theGists }
What if we’re trying to get the second page of gists? This code would replace the existing ones instead of appending the new ones. Let’s fix that: if let fetchedGists = result.value { if self.nextPageURLString != nil { self.gists += fetchedGists } else { self.gists = fetchedGists } }
That’s better. We also need to pass in nil in viewDidAppear so the first page of gists gets loaded:
Pagination, a.k.a., Load More on Scroll
89
override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) loadGists(nil) }
8.4 When to Load More Gists? So that’s all nicely set up but when do we actually call the function to get more gists? Let’s set it up so we’ll load more gists if they’re scrolled down far enough that there are only 5 more rows to the bottom: override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) let gist = gists[indexPath.row] cell.textLabel!.text = gist.description cell.detailTextLabel!.text = gist.ownerLogin cell.imageView?.image = nil // set cell.imageView to display image at gist.ownerAvatarURL if let urlString = gist.ownerAvatarURL, url = NSURL(string: urlString) { cell.imageView?.pin_setImageFromURL(url, placeholderImage: UIImage(named: "placeholder.png")) } else { cell.imageView?.image = UIImage(named: "placeholder.png") } // See if we need to load more gists let rowsToLoadFromBottom = 5; let rowsLoaded = gists.count if let nextPage = nextPageURLString { if (!isLoading && (indexPath.row >= (rowsLoaded - rowsToLoadFromBottom))) { self.loadGists(nextPage) } } return cell }
Here’s the meat of that:
Pagination, a.k.a., Load More on Scroll
90
let rowsToLoadFromBottom = 5; let rowsLoaded = gists.count if let nextPage = nextPageURLString { if (!isLoading && (indexPath.row >= (rowsLoaded - rowsToLoadFromBottom))) { self.loadGists(nextPage) } }
If they’re within 5 rows of the bottom and we have a URL for the next page of results, then we should load more results (unless we’re already loading gists, then just hold off). To load more results we call loadGists(nextPage) with the URL that we want to fetch from. The isLoading variable needs to be added so that we don’t fire off loading more rows while we’re already loading more rows: class MasterViewController: UITableViewController { var var var var ...
detailViewController: DetailViewController? = nil gists = [Gist]() nextPageURLString: String? isLoading = false
}
And set it true while we load gists: func loadGists(urlToLoad: String?) { self.isLoading = true GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage guard result.error == nil else { print(result.error) // TODO: display error return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else { self.gists = fetchedGists }
91
Pagination, a.k.a., Load More on Scroll } self.tableView.reloadData() } }
Modify your table view to load more objects as the user scrolls down close to the bottom of the list. You’ll have to keep track of whatever parameter your API uses for pagination, e.g., a next URL, a page number, the number of objects loaded, the ID of the last object loaded, …
8.5 And That’s All for Pagination While it was a bit of work to hook in to our API calls, that’s all it takes to set up loading more on scroll for an API-backed table view. Save and run. You should be able to scroll down and see more gists than just the ones that originally load:
Lots of Gists
If you got tired of typing, here’s the code: (tagged “pagination”)³. ³https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/pagination
9. Pull to Refresh Adding pull to refresh to a UITableView sounds like a lot more work than it actually is. The iOS UIRefreshControl makes it a quick and easy feature to implement. In this chapter we’ll add pull to refresh to get updates to our lists of gists. When we’re done it’ll look like this:
Pull to refresh with date
9.1 Adding Pull to Refresh In iOS UITableView and UIRefreshControl are designed for each other. In fact, UITableViewController already has a refreshControl property, it’s just not initialized by default. So we can just create a refresh control and assign it to that property in our MasterViewController. We’ll also set it to call a function called refresh when the user activates it:
92
Pull to Refresh
93
override func viewWillAppear(animated: Bool) { self.clearsSelectionOnViewWillAppear = self.splitViewController!.collapsed // add refresh control for pull to refresh if (self.refreshControl == nil) { self.refreshControl = UIRefreshControl() self.refreshControl?.addTarget(self, action: "refresh:", forControlEvents: UIControlEvents.ValueChanged) } super.viewWillAppear(animated) }
And add our refresh function: // MARK: - Pull to Refresh func refresh(sender:AnyObject) { nextPageURLString = nil // so it doesn't try to append the results loadGists(nil) }
If you save and run now it’ll work but the refresh control won’t never go away. So we need to tell it to: func loadGists(urlToLoad: String?) { self.isLoading = true GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) // TODO: display error return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists
94
Pull to Refresh } else { self.gists = fetchedGists } } self.tableView.reloadData() } }
Save and run to test. If you’re happy with the refresh control as it is then you’re done:
Pull to refresh with image
9.2 Showing the Last Refreshed Time It’s more useful if the refresh control shows when you last refreshed as you pull it. So let’s add that. We’ll need to use a date formatter to display the last refresh time. NSDateFormatter is an expensive class to create (or to change the formatting) so we’ll create a single one and keep using it:
Pull to Refresh class MasterViewController: UITableViewController { var dateFormatter = NSDateFormatter() // ... override func viewWillAppear(animated: Bool) { self.clearsSelectionOnViewWillAppear = self.splitViewController!.collapsed super.viewWillAppear(animated) // add refresh control for pull to refresh if (self.refreshControl == nil) { self.refreshControl = UIRefreshControl() self.refreshControl?.attributedTitle = NSAttributedString(string: "Pull to refresh") self.refreshControl?.addTarget(self, action: "refresh:", forControlEvents: UIControlEvents.ValueChanged) self.dateFormatter.dateStyle = NSDateFormatterStyle.ShortStyle self.dateFormatter.timeStyle = NSDateFormatterStyle.LongStyle } } }
Then we just need to set the text for the refresh control’s label each time we load new data: func loadGists(urlToLoad: String?) { self.isLoading = true self.nextPageURLString = nextPage GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.isLoading = false // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) self.nextPageURLString = nil return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else { self.gists = fetchedGists }
95
96
Pull to Refresh } // update "last updated" title for refresh control let now = NSDate() let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) self.refreshControl?.attributedTitle = NSAttributedString(string: updateString) self.tableView.reloadData() } }
9.3 And That’s All Save and run. You’ll see your refresh control display the last refresh time and it’ll update each time you refresh:
Pull to refresh with date
If you got tired of typing, here’s the code: (tagged “pull_to_refresh”)¹. ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/pull_to_refresh
10. Authentication If you’re building an app based on a REST API you’re probably going to need to authenticate at some point. You might even need to authenticate to make any REST calls at all. Using the GitHub API, we’ll figure out how to set up two types of authentication: basic auth and OAuth 2.0. Our gists app will eventually use the OAuth 2.0 authentication. We’ll also cover how to use header/token auth using Mashape¹.
10.1 The Docs If you likes to dig into documentation, read up on the GitHub API docs for authentication². If we check out the docs we’ll find a few key points: • The GitHub API can be used with Basic Auth or OAuth 2.0 • If we try to authenticate with invalid credentials we’ll get a 401 Unauthorized response • Requests that require authentication will return 404 Not Found, instead of 403 Forbidden, in some places.
Check your documentation for authentication requirements. Implement the section of this chapter that matches your API. Also implement the integrating the authentication in your API manager as shown near the end of this chapter.
10.2 Basic Auth: Username/Password We’ll eventually be adding OAuth 2.0 but we’ll cover how to use basic auth first. For now we’ll just print the API results to confirm that we’ve succeeded, similar to when we set up printPublicGists. We’ll set up returning the arrays of gists for the table view to display when we implement OAuth 2.0 later in this chapter. Let’s add a declaration for a function that will make an API call using basic auth:
¹https://www.mashape.com/ ²https://developer.github.com/v3/#authentication
97
Authentication
98
class GitHubAPIManager { // MARK: - Basic Auth func printMyStarredGistsWithBasicAuth() -> Void { // TODO: implement } }
We’ll need to add the call to get starred gists to our router: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" case GetPublic() // GET https://api.github.com/gists/public case GetMyStarred() // GET https://api.github.com/gists/starred case GetAtPath(String) // GET at given path var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .GetPublic: return .GET case .GetMyStarred: return .GET case .GetAtPath: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .GetPublic: return ("/gists/public", nil) case .GetMyStarred: return ("/gists/starred", nil) case .GetAtPath(let path): let URL = NSURL(string: path) let relativePath = URL!.relativePath! return (relativePath, nil) } }() let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
Authentication
99
let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
Let’s fill that in like we did for getting public gists (without any authentication) and see what happens. In GitHubAPIController: func printMyStarredGistsWithBasicAuth() -> Void { Alamofire.request(GistRouter.GetMyStarred()) .responseString { response in if let receivedString = response.result.value { print(receivedString) } } }
And in the MasterViewController change viewDidAppear to test this function: override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) loadGists(nil) // TEST GitHubAPIManager.sharedInstance.printMyStarredGistsWithBasicAuth() // END TEST }
If we save & run that we get an error in the console: { "message":"Requires authentication", "documentation_url":"https://developer.github.com/v3/#authentication" }
So, as the docs said, we need to add authentication. The easiest way is to supply our username and password, i.e., using Basic Authentication³. Basic auth requires setting an Authorization header that contains our username:password with base64 encoding. ³https://en.wikipedia.org/wiki/Basic_access_authentication
Authentication
100
Base64 encoding is a fairly simple way of encoding binary data to send it as text. This type of encoding doesn’t provide additional security since it’s an encoding, not an encryption. So why is it used in basic auth when the username and password are already text? Mostly to encode any odd characters that might cause issues with the HTTP request.
. Let’s start by Base64 encoding our credentials, then we’ll add the Authorization HTTP header to our request. Make sure to use your GitHub username and password. We’ll set up the authentication in the router where we’re creating the NSMutableURLRequest: let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) let username = "myUsername" let password = "myPassword" let credentialData = "\(username):\(password)".dataUsingEncoding(NSUTF8StringEncoding)! let base64Credentials = credentialData.base64EncodedStringWithOptions([]) ...
So first we set up the string we want to encode: "\(username):\(password)". Then we turn it into NSData using the common NSUTF8StringEncoding string encoding. That just converts our string to binary data so we can apply the Base64 encoding. To actually perform the Base64 encoding, we call the base64EncodedStringWithOptions function on our data. So how do we send the header? Check back in the headers chapter for details. ... URLRequest.setValue("Basic \(base64Credentials)", forHTTPHeaderField: "Authorization") let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest
Build & run and you should get back a list of the Gists you’ve starred (probably a good idea to go star some Gists⁴ before testing that code). You can see a list of your starred gists at https://gist.github.com/*username*/starred: ⁴https://gist.github.com/search?utf8=%E2%9C%93&q=swift&ref=searchresults
Authentication
101
[{"url":"https://api.github.com/gists/3bbe842793ac3f692775","forks_url":"https://api.githu\ b.com/gists/3bbe842793ac3f692775/forks","commits_url":"https://api.github.com/gists/3bbe84\ 2793ac3f692775/commits","id":"3bbe842793ac3f692775","git_pull_url":"https://gist.github.co\ m/3bbe842793ac3f692775.git",...
Depending on your API, sometimes you can use the Alamofire .authenticate function to make sending basic auth credentials even simpler. Sadly this doesn’t work with GitHub’s API but we can use the HTTPBin test API⁵ to try it out. To do so, chain an extra function into your Alamofire request: .authenticate(user: username, password: password). That function call will pack up the inputs into the appropriate header for us and handle auth challenges: func doGetWithBasicAuth() -> Void { let username = "myUsername" let password = "myPassword" Alamofire.request(.GET, "https://httpbin.org/basic-auth/\(username)/\(password)") .authenticate(user: username, password: password) .responseString { response in if let receivedString = response.result.value { print(receivedString) } } }
Normally you wouldn’t need to put your username and password into the URL in the request, it’s only done here to use the HTTPBin⁶ testing function. Alternatively, you can pack up the username and password in an NSURLCredential: func doGetWithBasicAuthCredential() -> Void { let username = "myUsername" let password = "myPassword" let credential = NSURLCredential(user: username, password: password, persistence: NSURLCredentialPersistence.ForSession) Alamofire.request(.GET, "https://httpbin.org/basic-auth/\(username)/\(password)") .authenticate(usingCredential: credential) .responseString { response in if let receivedString = response.result.value { print(receivedString) } } }
⁵https://httpbin.org/ ⁶https://httpbin.org/
102
Authentication
Using a credential lets you specify how long the authentication should live (one call, one session or forever) as NSURLCredentialPersistence. It also works with certificate-based authentication, if you’re headed down that road. If your API uses basic authentication, set it up using a credential that you can store. The section on OAuth later in this chapter shows how to use Locksmith⁷ to save sensitive information in the iOS Keychain. You’ll also need to add a view that pops up to collect the username and password. Check out the gist creation form to see how to add a simple form. You’ll want it to pop up when you launch the app or make an API call and don’t have a credential saved. We’ll do something similar in the OAuth 2.0 code so check how we’re starting the login process there.
10.3 HTTP Header Authentication Some APIs use HTTP headers for authentication. For example, Mashape⁸ is a great marketplace to find APIs to use to test your skills. For example, you can access the Urban Dictionary API through Mashape⁹. When you sign up for a Mashape account you’ll get 2 headers you need to pass with your API calls instead of using Basic Authentication: • X-Mashape-Key: MY_API_KEY • Accept: application/json This curl statement works: curl --get --include \ 'https://mashape-community-urban-dictionary.p.mashape.com/define?term=hipster' \ -H 'X-Mashape-Key: MY_API_KEY' \ -H 'Accept: application/json'
So how can we make this call work with Alamofire? Remember the headers chapter? It has all the details that we need. Those headers should be included with the whole session so we’ll do that. Often, if an API key is needed it then it needs to be provided with all of the calls to an API. So it makes sense to set it once for the whole session instead of needing to set it for each request. So far we’ve been mostly making requests using Alamofire.request(...). Behind the scenes Alamofire uses a single manager to handle these calls. It would do exactly the same thing if we call that manager explicitly like this: ⁷https://github.com/matthewpalmer/Locksmith ⁸https://www.mashape.com/ ⁹https://www.mashape.com/community/urban-dictionary
Authentication
103
let manager = Alamofire.Manager.sharedInstance manager.request(...)
And that lets us work with the manager directly so we can do things like setting HTTP headers for the whole session, like we did with the GitHub accept header. Now if we set up a manager to work with Mashape: class MashapeAPIManager { static let sharedInstance = MashapeAPIManager() var alamofireManager:Alamofire.Manager init () { let configuration = NSURLSessionConfiguration.defaultSessionConfiguration() configuration.HTTPAdditionalHeaders = Manager.defaultHTTPHeaders alamofireManager = Alamofire.Manager(configuration: configuration) } }
We can add the headers that we need: init () { let configuration = NSURLSessionConfiguration.defaultSessionConfiguration() var headers = Manager.defaultHTTPHeaders headers["X-Mashape-Key"] = "MY_API_KEY" headers["Accept"] = "application/json" configuration.HTTPAdditionalHeaders = headers alamofireManager = Alamofire.Manager(configuration: configuration) }
Then when we use: let manager = MashapeAPIManager.sharedInstance.alamofireManager manager.request(.GET, "https://mashape-community-urban-dictionary.p.mashape.com/define?term=hipster")
Instead of:
104
Authentication Alamofire.request(.GET, "https://mashape-community-urban-dictionary.p.mashape.com/define?term=hipster")
The "X-Mashape-Key": "MY_API_KEY" header and the JSON accept header will get passed with every request. If your API uses token/header based auth then set up your session headers in your API manager class.
If you’re looking for APIs to play with to develop your skills, check our Mashape. They have tons of free APIs for your coding pleasure. https://www.mashape.com/explore?page=1&price=free
.
10.4 Alamofire Validation Alamofire has a .validate() method that can be chained into your requests like so: alamofireManager.request(GistRouter.GetMyStarred()) .validate() .responseString { response in guard response.result.error == nil else { print(response.result.error!) } if let receivedString = response.result.value { print(receivedString) } }
If you don’t include the call to .validate then Alamofire will assume that the call succeeded. The .validate() call only fails if the content-type of the response isn’t what we expect or the status code isn’t 200-299. In older versions of Alamofire validate could hide more descriptive errors since your custom serializers wouldn’t get called if there was already an error. As of v3.0.0 that’s no longer the case. Let’s see how we can get the most useful error messages from the GitHub gists API. print(response.result.error!) prints:
105
Authentication Error Domain=com.alamofire.error Code=-1 "The operation couldn't be completed. (com.alamofire.error error -1.)"
Then print(receivedString) prints the more informative error message: { "message":"Bad credentials", "documentation_url":"https://developer.github.com/v3" }
With this API we’re best off checking for errors in the JSON before returning the error from .validate(). Let’s do that: alamofireManager.request(GistRouter.GetMyStarred()) .validate() .responseString { response in if let receivedString = response.result.value { print(receivedString) let json = SwiftyJSON.JSON(receivedString) // Check for error in JSON if let message = json["message"].string { let error = Error.errorWithCode(.DataSerializationFailed, failureReason: message) // TODO: bubble up error } // Do other stuff with JSON } if error = response.result.error else { print(error) // TODO: bubble up error } }
Now using .validate() won’t hide any errors that come in the JSON response. With some APIs you might be more informative errors in your JSON than from .validate(). Test your API by printing out the errors from your JSON and from .validate() to see whether you should use a guard statement to return from errors immediately in your response serializer or whether you should parse the JSON and return the message from it. You might find issues like missing headers returning “The operation couldn’t be completed” instead of “unauthorized” (e.g., the Parse REST API¹⁰). Certainly if we can get a more descriptive error we should display that instead of the generic networking error.
¹⁰http://parse.com
Authentication
106
10.5 OAuth 2.0 OAuth 2.0 is super common for authentication these days, especially since it lets you login without giving your password to every app you use or creating new accounts for every app. If you’re not familiar with OAuth check out the great explanation on RayWenderlich.com¹¹. Here’s a simple outline of how it works: If you wanted to let an iOS app have some access to your Twitter account, then the OAuth 2.0 authentication flow would be: 1. The app sends you to Twitter to login 2. You login to Twitter to authorize the app (possibly giving it specific limited permissions) 3. Twitter sends you back to the app with a token for that app to use The flow can be a little confusing (in fact, there’s an extra step that we’ll add later) but it means that the iOS app never knows your password. It also lets you revoke its permission later without changing your Twitter password. When building any app around an API with OAuth 2.0 authentication the first thing you need to do is setting up that login flow to get a token. So that’s what we’ll do now. We’re working with the GitHub gists API call to get a list of our starred gists. The endpoint for that call is https://api.github.com/gists/starred¹². Without authentication we get this error as the response: { "message":"Bad credentials", "documentation_url":"https://developer.github.com/v3" }
That error tells us that we need to get an OAuth token to send along with that request. So let’s do that. We’ll set up the request to print our starred gists, just like we did for basic auth. Then we’ll implement the OAuth login flow and the authenticated call to get a list of gists. Fair warning: This section is kinda long. You might want to go to the washroom first or grab a snack. Here’s the API call without authentication:
¹¹http://www.raywenderlich.com/99431/OAuth-2-with-swift-tutorial ¹²https://api.github.com/gists/starred
Authentication
107
// MARK: - OAuth 2.0 func printMyStarredGistsWithOAuth2() -> Void { alamofireManager.request(GistRouter.GetMyStarred()) .responseString { response in guard response.result.error == nil else { print(response.result.error!) return } if let receivedString = response.result.value { print(receivedString) } } }
If you added the basic auth bits to your Router earlier in this chapter, remove them now. We’ll be replacing them with an OAuth token shortly. Here’s what our router looks like before adding any authentication: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" case GetPublic() // GET https://api.github.com/gists/public case GetMyStarred() // GET https://api.github.com/gists/starred case GetAtPath(String) // GET at given path var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .GetPublic: return .GET case .GetMyStarred: return .GET case .GetAtPath: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .GetPublic: return ("/gists/public", nil) case .GetMyStarred: return ("/gists/starred", nil) case .GetAtPath(let path): let URL = NSURL(string: path)
Authentication
108
let relativePath = URL!.relativePath! return (relativePath, nil) } }() let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
10.5.1 Get the OAuth Token Before Printing Gists When the app starts up we’ll need to get an OAuth token, if we don’t already have one. So before calling printMyStarredGistsWithOAuth2 we’ll need to check whether we already have an OAuth token and get one if we don’t. So in the MasterViewController, we’ll add a method to do the initial data loading. It’ll grab an OAuth token if we need it and print the starred gists if we already have a token. Later we’ll be able to swap out printMyStarredGistsWithOAuth2 to with our loadGists function but first we want to make sure that OAuth works: override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) loadInitialData() } func loadInitialData() { if (!GitHubAPIManager.sharedInstance.hasOAuthToken()) { showOAuthLoginView() } else { GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2() } }
It’ll be the GitHubAPIManager’s job to keep track of whether we have an OAuth token, so we’ll be adding a method to check for one there: GitHubAPIManager.sharedInstance.hasOAuthToken().
Authentication
109
If we don’t have a token, then we’ll need to kick off the OAuth flow. We’ll do that by showing a view that lets the user tap on a button to start logging in: showOAuthLoginView(). When they tap on the login button we’ll call a function called URLToStartOAuth2Login() so the MasterViewController can get the URL to start the login flow. We’ll need to create that view and implement those 2 functions. And if we do already have a token, we can print the gists: GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2()
This code needs a few more things to work: 1. 2. 3. 4.
Let us check if we have a token with hasOAuthToken Create a login view Start up the OAuth authorization flow with with startOAuth2Login Make an authorized request and print out our starred gists once we have an OAuth token
We can fill in a structure for those items so we don’t forget about them later: import Foundation import Alamofire class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() ... func hasOAuthToken() -> Bool { // TODO: implement return false } // MARK: - OAuth flow func URLToStartOAuth2Login() -> NSURL? { // TODO: implement // TODO: get and print starred gists } func printMyStarredGistsWithOAuth2() -> Void { alamofireManager.request(GistRouter.GetMyStarred()) .responseString { response in
Authentication
110
guard response.result.error == nil else { print(response.result.error!) return } if let receivedString = response.result.value { print(receivedString) } } } }
10.5.2 Login View It’s always best to keep the user aware of what’s happening. So instead of just kicking them over to GitHub let’s pop up a view where they can confirm that they want to login. Open up the storyboard and drag in a new view controller:
Add a button to the new view controller:
Authentication
Set its title to Login to GitHub:
Make it wide enough for the text:
111
Authentication
112
Select the button buttons and add a constraints to center it horizontally and vertically in the view:
To add some code to that button we need to create a new Swift file with a class to represent this view controller. Create a new Swift file and name it LoginViewController.swift. In your new login view controller code file we’ll need to add an IBAction to hook up to the button:
Authentication
113
import UIKit class LoginViewController: UIViewController { @IBAction func tappedLoginButton() { // TODO: implement } }
Then we can switch back to the storyboard. Set the storyboard ID and the class of the new view controller to LoginViewController:
And hook up the touch up inside event for the button to the IBAction that we just added to the code:
Authentication
114
Now we can set up the login view controller to be shown when they launch the app and they don’t have an OAuth token yet. In MasterViewController: func loadInitialData() { if (!GitHubAPIManager.sharedInstance.hasOAuthToken()) { showOAuthLoginView() } else { GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2() } } func showOAuthLoginView() { let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) if let loginVC = storyboard.instantiateViewControllerWithIdentifier( "LoginViewController") as? LoginViewController { self.presentViewController(loginVC, animated: true, completion: nil) } }
To display the view we need to get the storyboard to create an instance for us (using the storyboard ID: LoginViewController). Then we can use the navigation controller that was created way back when we first created this master-detail project to show push that view controller onto the view stack. So that will take care of showing the login view controller. But how does it go away? If they tap the login button we should start the OAuth login flow and hide the login view. But our IBAction is in the login view controller and we want to go back to the main view then start the OAuth process. Fortunately there’s a common pattern used for this kind of code called delegation. We’ll define a protocol that says what a delegate for the login view needs to do then we’ll just tell the delegate to take action when the login button is pressed. We can add the protocol right in the LoginViewController, we’ll call it LoginViewDelegate:
Authentication
115
import UIKit protocol LoginViewDelegate: class { func didTapLoginButton() } class LoginViewController: UIViewController { weak var delegate: LoginViewDelegate? @IBAction func tappedLoginButton() { if let delegate = self.delegate { delegate.didTapLoginButton() } } }
So when the button is tapped we’ll just check that we have a delegate and, if we have one, we’ll tell it what happened. The delegate is declared as a weak var so that the login view controller won’t act like it owns the delegate. Otherwise we could end up with a retain cycle where the delegate (our MasterViewController) owns the LoginViewController and vice versa. If that happens then neither view controller will ever be released and we’ll have a memory leak.
. Guess we need to make the master view controller conform to that protocol so we can handle those events: class MasterViewController: UITableViewController, LoginViewDelegate { ... }
And we’ll set it as the delegate just before we show the login view:
Authentication
116
func showOAuthLoginView() { let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle()) if let loginVC = storyboard.instantiateViewControllerWithIdentifier( "LoginViewController") as? LoginViewController { loginVC.delegate = self self.presentViewController(loginVC, animated: true, completion: nil) } }
And finally we’ll implement the protocol methods in the MasterViewController to handle tapping the login button: func didTapLoginButton() { self.dismissViewControllerAnimated(false, completion: nil) if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() { // TODO: show web page } }
When they tap the login button we’ll dismiss the login view and start the OAuth process. We’ll be back to this code shortly after a quick trip to GitHub.
10.5.3 The OAuth Login Flow To request a token from the GitHub API we can follow the flow in the docs¹³, even though it says it’s for web apps: 1. 2. 3. 4.
Redirect users to request GitHub access GitHub redirects back to your site (app for us) with a code Exchange the code for an access token Use the access token to access the API
Step 3 is the extra step I referred to at the start of this section. The user doesn’t see it happen so we don’t always think of it as part of the OAuth 2.0 flow but as coders we need to implement it.
. Step 1: Send Users to GitHub The first thing we need to do is to send the user to a web page at GitHub. The endpoint we need to send them to is: ¹³https://developer.github.com/v3/oauth/
Authentication
117
GET https://github.com/login/oauth/authorize
With a few parameters: • • • •
client_id redirect_uri scope state
Only the client_id is required but we’ll provide everything except the redirect_uri since we can specify that in the web interface. To get a client ID head over to GitHub: Create a new OAuth app¹⁴ If you don’t have a GitHub account you’ll need to create a free one. You’ll also need to star some gists so you can retrieve them in your API call. Fill out the form. For the Authorization callback URL (which is the same thing as the redirect_uri param), make up a URL format for your app that starts with some kind of unique ID for your app. For example, I’m using grokGitHub://?aParam=paramVal with grokGitHub:// as the custom URL protocol. The ?aParam=paramVal part isn’t necessary for our code but GitHub wouldn’t accept a callback URL without some kind of parameters. The Authorization callback URL will be used in step 2 when GitHub sends the user back to our app. For step 1 we just need to copy the client_id from GitHub. We’ll need the client_secret later so we’ll copy that too: class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() var alamofireManager:Alamofire.Manager let clientID: String = "1234567890" let clientSecret: String = "abcdefghijkl" ... }
Ideally we wouldn’t store the client ID & secret in our app since a malicious person could extract it from there. But it greatly simplifies showing how to implement OAuth and this section is plenty long enough as it is. So now that we’ve got our client ID (and had a nice little break, seriously I did, you totally should), we can implement URLToStartOAuth2Login(): ¹⁴https://github.com/settings/applications/new
118
Authentication // MARK: - OAuth flow func URLToStartOAuth2Login() -> NSURL? { let authPath:String = "https://github.com/login/oauth/authorize" + "?client_id=\(clientID)&scope=gist&state=TEST_STATE" guard let authURL:NSURL = NSURL(string: authPath) else { // TODO: handle error return nil } return authURL }
And in didTapLoginButton() we’ll use that function to send the user to that web page. iOS 9 has a nice new class SFSafariViewController that we can use to send the user to the OAuth login web page. To use the SFSafariViewController we need add the Safari Services framework to our project. To do that, click on your project in the organizer (top left corner). Then select your target and scroll all the way down in the first panel until you find the Linked Frameworks and Files Section. Click the plus sign under that section:
Linked Frameworks and Files Section
Then select the Safari Services Framework:
119
Authentication
Adding the Framework
And you should see that it’s been added to your project:
Framework Added
Now we can import the framework. We’ll need to keep that view controller around as a variable (and make MasterViewController its delegate). That’ll let us show the web page, handle it not loading if they don’t have an internet connection, and hide it when they’re done with it.
Authentication
120
import SafariServices class MasterViewController: UITableViewController, LoginViewDelegate, SFSafariViewControllerDelegate { var safariViewController: SFSafariViewController? ... }
Then we can create the view controller and display it: func didTapLoginButton() { self.dismissViewControllerAnimated(false, completion: nil) if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() { safariViewController = SFSafariViewController(URL: authURL) safariViewController?.delegate = self if let webViewController = safariViewController { self.presentViewController(webViewController, animated: true, completion: nil) } } }
And we’ll make sure the web page loads, dismissing the view if it fails: // MARK: - Safari View Controller Delegate func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { // Detect not being able to load the OAuth URL if (!didLoadSuccessfully) { // TODO: handle this better controller.dismissViewControllerAnimated(true, completion: nil) } }
Later we’ll add dismissing the view if it loads correctly and they login. Here’s what it looks like when UIApplication.sharedApplication().openURL(authURL) sends the user to Safari so they can authorize our app for their GitHub account:
121
Authentication
Authorize App with GitHub OAuth
So that takes care of step 1. But if we click on the Authorize button on that web page we’ll get an error:
Invalid Address Error
That’s because GitHub is trying to send the user back to our app using the callback URL that we provided: grokGitHub://?aParam=paramVal. But iOS has no idea what to do with a grokGitHub:// URL. So we need to tell iOS that our app will handle grokGitHub:// URLs. Step 2: GitHub Redirects Back In iOS any app can register a URL scheme. That’s what we’ll use to tell the operating system that our app will handle grokGitHub:// URLs. Then GitHub will be able to send the user back to our app along with the authorization code that we’ll later exchange for a token.
.
Why do we get a code to exchange for a token instead of just getting the token? Did you notice the state parameter in step 1? That’s for our security, if we want to implement it. We can send a state parameter and then make sure we get it back. If we don’t get it back then we can just not finish
122
Authentication
the OAuth flow and a token doesn’t get generated. That way we can be sure that step 2 is getting fired off by our app, not some random person or bot trying to get access to our GitHub account.
. To register a custom URL scheme, open up the info.plist in your Xcode project:
Open Info.plist
Right click on the info.plist and select “Add Row”:
Add Row to Info.plist
Change the identifier to “URL types”:
123
Authentication
Select URL Types
It should change the type to Array and add a sub-row “Item 0” with a “URL identifier” in it:
URL Types Array
The URL identifier should be unique. The easiest thing to use is your app ID:
Set URL ID
And right-click on Item 0 to add another row under it. Make that row “URL Schemes”:
Add URL Schemes
Set the URL Schemes’s Item 0 to your custom URL scheme without the :// (set it to grokGitHub, not grokGitHub://):
124
Authentication
Final URL Scheme Settings
Then switch to the AppDelegate file and add an application:handleOpenURL: function to indicate that we can open URLs (you can delete most of the boilerplate that Xcode generated to leave just this stuff): import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { var window: UIWindow? func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { // Override point for customization after application launch. let splitViewController = self.window!.rootViewController as! UISplitViewController let navigationController = splitViewController.viewControllers[ splitViewController.viewControllers.count-1] as! UINavigationController navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem() splitViewController.delegate = self return true } func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool { return true } // MARK: - Split view func splitViewController(splitViewController: UISplitViewController, collapseSecondaryViewController secondaryViewController:UIViewController, ontoPrimaryViewController primaryViewController:UIViewController) -> Bool { guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false } guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false } if topAsDetailController.detailItem == nil { // Return true to indicate that we have handled the collapse by doing nothing
Authentication
125
// the secondary controller will be discarded. return true } return false } }
That should be all you need for the custom URL scheme. To test it, launch your app. The code that we set up for OAuth step 1 above should send you to Safari then back to our app. If you’re having issues with it, revoke the GitHub access for your app on the Authorized Applications tab¹⁵ so you can re-authorize it. We’ll stop it from going to Safari every time you launch later but for now it’s handy to make sure the custom URL scheme is working. Step 3: Swap the Code for a Token When GitHub calls our custom URL scheme it passes us a code. We’ll need to process the URL that we got to extract that code and then exchange it for an OAuth token. First we need to send the URL that was used to open the app over to our GitHubAPIManager since it’s responsible for that kinda stuff. So change the function in the app delegate to: func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool { GitHubAPIManager.sharedInstance.processOAuthStep1Response(url) return true }
And flip over to the GitHubAPIManager file to implement processOAuthStep1Response: class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() ... func processOAuthStep1Response(url: NSURL) { // TODO: implement } }
The URL we’re receiving looks like:
¹⁵https://github.com/settings/applications
Authentication
126
grokgithub://?aParam=paramVal&code=123456789&state=TEST_STATE
Don’t believe me? Add print(url) in processOAuthStep1Response to check for yourself. We need to process that URL to extract the argument after &code=. Fortunately iOS has tools to process URL components as query items with names and values: func processOAuthStep1Response(url: NSURL) { let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false) var code:String? if let queryItems = components?.queryItems { for queryItem in queryItems { if (queryItem.name.lowercaseString == "code") { code = queryItem.value break } } } }
So we can turn the URL into an array of queryItems (which each have a name and a value), then go through those by name until we find the code item, then grab its value. If we get a code, we can set up the Alamofire request to exchange it for an OAuth token. Checking the GitHub docs¹⁶ we can see that we need to make a POST request to: https://github.com/login/oauth/access_token
With our client ID, client secret and the code we just received as parameters. We’ll also use a header to specify that we want the response as JSON: if let receivedCode = code { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in // TODO: handle response to extract OAuth token } }
Once we have that response, we can check for errors (kicking out if we’ve got one) and see what the results look like to figure out how to parse out the OAuth token (assuming there wasn’t an error): ¹⁶https://developer.github.com/v3/oauth/#github-redirects-back-to-your-site
Authentication
127
if let error = response.result.error { print(rror) return } print(response.result.value) // like "access_token=999999&scope=gist&token_type=bearer"
If we get an OAuth token we’ll need to store it. For now we’ll just stick it in a variable on our GitHubAPIManager. A little later we’ll figure out how to persist it between runs of the app and store it securely: class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() var OAuthToken: String? ... }
To parse out the OAuth token, we’ll step through the parameters in the results: if let receivedResults = response.result.value, jsonData = receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) { let jsonResults = JSON(data: jsonData) for (key, value) in jsonResults { switch key { case "access_token": self.OAuthToken = value.string case "scope": // TODO: verify scope print("SET SCOPE") case "token_type": // TODO: verify is bearer print("CHECK IF BEARER") default: print("got more than I expected from the OAuth token exchange") print(key) } } }
After we’ve converted the results to JSON, we loop through the key-value pairs, we check each key and figure out what to do with it. To keep things simple, I’ve just tossed in a TODO for each key
Authentication
128
that we don’t need right now. If you were really deploying this code in an app you’d want to make sure that you get the right type of token (bearer) and have the right kind of scope (gists). Ok, so we’ve got the OAuth token saved (if we got one): self.OAuthToken = value
Now we need to use it to get our starred gists, let’s add that after getting the token: if let receivedCode = code { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { print(error) return } print(response.result.value) if let receivedResults = response.result.value, ... { ... } if (self.hasOAuthToken()) { self.printMyStarredGistsWithOAuth2() } }
See any issues with that code? We didn’t update self.hasOAuthToken() to reflect whether we actually have a token! Better do that or we’ll always get an error returned: func hasOAuthToken() -> Bool { if let token = self.OAuthToken { return !token.isEmpty } return false }
Now hasOAuthToken() is true if we have a token and it’s not blank.
Authentication
129
10.5.4 Handling Multiple Launches So what happens if we run the app now? Well, every time the MasterViewController gets shown we: • Check for an OAuth token • If we don’t have one, we show the login view • If we do have one, we try to fetch our starred gists But the MasterViewController gets shown each time we launch the app, including when Safari reopens the app using our custom URL scheme. That’s a problem since at that point we’ll only have a code, not a token! So the login view will get shown again. To get around that we can check if we’ve already started the OAuth process. So when we start the OAuth process we’ll save a bool to the NSUserDefaults that says we’re currently loading the OAuth token: func didTapLoginButton() { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(true, forKey: "loadingOAuthToken") self.dismissViewControllerAnimated(false, completion: nil) if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() { safariViewController = SFSafariViewController(URL: authURL) safariViewController?.delegate = self if let webViewController = safariViewController { self.presentViewController(webViewController, animated: true, completion: nil) } } }
And we’ll set it to false when we’ve got an OAuth token (or we’ve failed to do so). We need to do that if we got a URL without a code in it, if we get an error from the POST request, or after we’ve parsed the response to trade a code for a token:
Authentication func processOAuthStep1Response(url: NSURL) { let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false) var code:String? if let queryItems = components?.queryItems { for queryItem in queryItems { if (queryItem.name.lowercaseString == "code") { code = queryItem.value break } } } if let receivedCode = code { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") // TODO: bubble up error return } print(response.result.value) if let receivedResults = response.result.value, jsonData = receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) { let jsonResults = JSON(data: jsonData) for (key, value) in jsonResults { switch key { case "access_token": self.OAuthToken = value.string case "scope": // TODO: verify scope print("SET SCOPE") case "token_type": // TODO: verify is bearer print("CHECK IF BEARER") default: print("got more than I expected from the OAuth token exchange") print(key) } } }
130
Authentication
131
if (self.hasOAuthToken()) { self.printMyStarredGistsWithOAuth2() } } } }
That’s getting pretty long, let’s break out the section that makes the call to swap the code for a token into its own function: func swapAuthCodeForToken(receivedCode: String) { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") // TODO: bubble up error return } print(response.result.value) if let receivedResults = response.result.value, jsonData = receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) { let jsonResults = JSON(data: jsonData) for (key, value) in jsonResults { switch key { case "access_token": self.OAuthToken = value.string case "scope": // TODO: verify scope print("SET SCOPE") case "token_type": // TODO: verify is bearer print("CHECK IF BEARER") default: print("got more than I expected from the OAuth token exchange") print(key) } } }
Authentication
132
let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if (self.hasOAuthToken()) { self.printMyStarredGistsWithOAuth2() } } } func processOAuthStep1Response(url: NSURL) { let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false) var code:String? if let queryItems = components?.queryItems { for queryItem in queryItems { if (queryItem.name.lowercaseString == "code") { code = queryItem.value break } } } if let receivedCode = code { swapAuthCodeForToken(receivedCode) } else { // no code in URL that we launched with let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") } }
Then we can change the MasterViewController to check whether we’re loading the OAuth token before we start loading data or start the OAuth login process: override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) let defaults = NSUserDefaults.standardUserDefaults() if (!defaults.boolForKey("loadingOAuthToken")) { loadInitialData() } }
And we’ll need to update it if we can’t load the OAuth web page:
Authentication
133
func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { // Detect not being able to load the OAuth URL if (!didLoadSuccessfully) { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") controller.dismissViewControllerAnimated(true, completion: nil) } }
NSUserDefaults is a dictionary that’s persisted between runs of the app. It’s a good place to store
small bits of data that need to be kept around but don’t need to be secure.
10.5.5 Using the OAuth Token for API Calls Ok, so we’ve finally got a token, now what do we do with it? We’ve got to pass it in an Authorization header with each call to the GitHub API. With our Alamofire Router it’s easy to include this header in all of the API calls. We’ll just add it to the NSMutableURL before we return it: enum GistRouter: URLRequestConvertible { ... var URLRequest: NSMutableURLRequest { ... let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) // Set OAuth token if we have one if let token = GitHubAPIManager.sharedInstance.OAuthToken { URLRequest.setValue("token \(token)", forHTTPHeaderField: "Authorization") } let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
Authentication
134
But there’s still a problem: the OAuthToken isn’t getting stored between runs of the app so we’re getting prompted to login every time we launch the app. We’ll want to store it securely so we won’t just use NSUserDefaults.
10.5.6 Storing the OAuth Token Securely The place to save most secure data in an iOS app is the Keychain. The code to work with the keychain can be pretty ugly so we’ll use a nice library called Locksmith¹⁷ to provide a simpler interface. Add Locksmith v2.0 to your project using CocoaPods. When that’s done switch back to Xcode. At the top of the GitHubAPIManager file add import Locksmith: import Foundation import Alamofire import Locksmith class GitHubAPIManager { ... }
Now using Locksmith we can save & retrieve the OAuth token: var OAuthToken: String? { set { if let valueToSave = newValue { do { try Locksmith.updateData(["token": valueToSave], forUserAccount: "github") } catch { let _ = try? Locksmith.deleteDataForUserAccount("github") } } else { // they set it to nil, so delete it let _ = try? Locksmith.deleteDataForUserAccount("github") } } get { // try to load from keychain Locksmith.loadDataForUserAccount("github") let dictionary = Locksmith.loadDataForUserAccount("github") if let token = dictionary?["token"] as? String {
¹⁷https://github.com/matthewpalmer/Locksmith
Authentication
135
return token } return nil } }
There are a few things in that code that deserve some explanation: newValue is what Swift passes in to a getter to tell us what the user is trying to set it to. So if we had GitHubManager.sharedInstance().OAuthToken = "abcd1234". The newValue within the set block for OAuthToken would be "abcd1234".
We’re using Locksmith.updateData because it will save the value even if we already have one saved in the Keychain. If we used Locksmith.saveData instead it would throw an error if we already had a value saved, which isn’t what we want. Swift 2.0 introduced do-try-catch. Since the Locksmith statements are marked throws we need to allow for the possibility of them throwing an exception. Sometimes we want to take special action, like if we can’t save the value we should make sure we don’t have an old value saved: do { try Locksmith.updateData(["token": valueToSave], forUserAccount: "github") } catch { // Handle exception }
And sometimes we want to just perform the action without worrying about the exception: let _ = try? Locksmith.deleteDataForUserAccount("github")
10.5.7 Making Authenticated Calls Ok, so the GitHubAPIManager looks good, how do we use it? Well, earlier we set up a printMyStarredGistsWithOAuth2 function that looked like this:
136
Authentication func printMyStarredGistsWithOAuth2() -> Void { alamofireManager.request(GistRouter.GetMyStarred()) .responseString { response in guard response.result.error == nil else { print(response.result.error!) return } if let receivedString = response.result.value { print(receivedString) } } }
Since we’re already using the router our OAuth token should get automatically added when we call GistRouter.GetMyStarred(). Save and try it out. All that work earlier pays off now to make that nice & simple. It’ll be easy to extend this OAuth token handling as we add more API calls, as long as we use our router (and we requested the correct scope when we got an OAuth token). Set up your project to make authenticated API calls.
10.5.8 And That’s the Login Flow for OAuth 2.0 I know, that’s a lot to process. If you have issues while testing, the first thing to try is revoking access so the OAuth process can start fresh. For GitHub you can do that on the Authorized Applications tab¹⁸. You might also want to wipe out the OAuth token if the printMyStarredGistsWithOAuth2 call fails: func printMyStarredGistsWithOAuth2() -> Void { alamofireManager.request(.GET, "https://api.github.com/gists/starred") .responseString { _, _, result in guard result.error == nil else { print(result.error) GitHubAPIManager.sharedInstance.OAuthToken = nil return } if let receivedString = result.value { print(receivedString) } } }
¹⁸https://github.com/settings/applications
Authentication
137
If you’re getting errors about the authorization credentials not being correct you can print the request (which includes the headers) using debugPrint to make sure they’re correct: func printMyStarredGistsWithOAuth2() -> Void { let starredGistsRequest = alamofireManager.request(GistRouter.GetMyStarred()) .responseString { _, _, result in guard result.error == nil else { print(result.error) GitHubAPIManager.sharedInstance.OAuthToken = nil return } if let receivedString = result.value { print(receivedString) } } debugPrint(starredGistsRequest) }
If all else fails, there’s a “Reset Content and Settings” option in the iOS Simulator menu that’ll get you back to a vanilla state. You’ll probably find you need to do all 3 of those things sometimes while debugging OAuth but just until you get this token stuff working. Then you’ll never have to look at it again.
10.6 Displaying the Results So how about displaying those gists in the table view? We already have it set up for showing public gists, let’s switch it to showing starred gists. Here’s the code we have for getting public gists (including the pagination and pull to refresh features that we added in previous chapters): func getGists(urlRequest: URLRequestConvertible, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { alamofireManager.request(urlRequest) .validate() .responseArray { (response:Response<[Gist], NSError>) in guard response.result.error == nil, let gists = response.result.value else { print(response.result.error) completionHandler(response.result, nil) return }
Authentication
138
// need to figure out if this is the last page // check the link header, if present let next = self.getNextPageFromHeaders(response.response) completionHandler(.Success(gists), next) } } func getPublicGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetPublic(), completionHandler: completionHandler) } }
We’ll need to add a getMyStarredGists function: func getMyStarredGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetMyStarred(), completionHandler: completionHandler) } }
Now we’d like to use one of these functions in loadInitialData, just like we did with getPublicGists but there’s a problem. What if we have to get an OAuth token first? We don’t want to freeze up the app while we’re getting the token so we’re using an asynchronous implementation. But then how will we know when it’s done? Normally we could add a completion handler to the method call, like we do in getGists. That would let us add a block of code to be called when the method is done. The problem is that part of getting the OAuth token is going to be the URL handling for our custom URL scheme, which doesn’t happen in the functions we’re calling directly. It’s really, really asynchronous. What we can do instead of passing a block of code to the startOAuth2Login method (that’ll get forgotten we get kicked to Safari) is to give the block of code to the GitHubAPIManager. Then the GitHubAPIManager can hold on to that code block until we’ve received an OAuth token. It’ll look like this:
Authentication
139
GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { // code that we want to execute when we get an OAuth token }
More specifically, we’ll want to check for any errors then fetch the repos if there are no errors: GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in if let error = error { print(error) self.isLoading = false // TODO: handle error // Something went wrong, try again self.showOAuthLoginView() } else { self.loadGists(nil) } }
And naturally that’ll only work if we add the variable to the GitHubAPIManager class: class GitHubAPIManager { static let sharedInstance = GitHubAPIManager() // handlers for the OAuth process // stored as vars since sometimes it requires a round trip to safari which // makes it hard to just keep a reference to it var OAuthTokenCompletionHandler:(NSError? -> Void)? ... }
Once that completion block is set up we can use it in loadInitialData(). We’ll set it then either start the OAuth login process or load the gists if we already have a token. Once we get an OAuth token then we’ll load the gists:
Authentication
140
func loadInitialData() { isLoading = true GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) if let error = error { print(error) self.isLoading = false // TODO: handle error // Something went wrong, try again self.showOAuthLoginView() } else { self.loadGists(nil) } } if (!GitHubAPIManager.sharedInstance.hasOAuthToken()) { self.showOAuthLoginView() } else { loadGists(nil) } }
From our pagination and pull to refresh work we have an implementation of loadGists that looks like this: func loadGists(urlToLoad: String?) { self.isLoading = true GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) self.nextPageURLString = nil return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else {
Authentication self.gists = fetchedGists } } // update "last updated" title for refresh control let now = NSDate() let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) self.refreshControl?.attributedTitle = NSAttributedString(string: updateString) self.tableView.reloadData() } }
All we need to do is switch it to use getMyStarredGists instead of getPublicGists: func loadGists(urlToLoad: String?) { self.isLoading = true GitHubAPIManager.sharedInstance.getMyStarredGists(urlToLoad) { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) self.nextPageURLString = nil return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else { self.gists = fetchedGists } } // update "last updated" title for refresh control let now = NSDate() let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) self.refreshControl?.attributedTitle = NSAttributedString(string: updateString) self.tableView.reloadData()
141
Authentication
142
} }
And we need to update the pull to refresh function to call the loadInitialData function so that if there’s an issue authenticating the user can try again by pulling to refresh: func refresh(sender:AnyObject) { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") nextPageURLString = nil // so it doesn't try to append the results loadInitialData() }
Now that we’ve implemented loadGists let’s fix the OAuth process so that the completion handler gets called right after we get an OAuth token. Otherwise the user would have to pull to refresh to get the list of gists to load. We’ll need to read through the login flow for OAuth 2.0 and find each spot where an error could bubble up. We’ll need to call the completion handler for those errors and when we finally get the token. Starting in didTapLoginButton, we could have a problem if the authorize URL isn’t valid. We could find that out two ways: the authURL can’t be created by the NSURL initializer or the web page doesn’t load. So let’s handle both of those cases (in GitHubAPIManager): func didTapLoginButton() { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(true, forKey: "loadingOAuthToken") self.dismissViewControllerAnimated(false, completion: nil) if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() { safariViewController = SFSafariViewController(URL: authURL) safariViewController?.delegate = self if let webViewController = safariViewController { self.presentViewController(webViewController, animated: true, completion: nil) } } else { defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler { let error = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey:
Authentication
143
"Could not create an OAuth authorization URL", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(error) } } }
func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { // Detect not being able to load the OAuth URL if (!didLoadSuccessfully) { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: [NSLocalizedDescriptionKey: "No Internet Connection", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(error) } controller.dismissViewControllerAnimated(true, completion: nil) } }
In both cases, if there’s a problem with the URL then we need to call the completion handler and reset the “loadingAuthToken” state to false. We’ll have to declare that custom error domain for the first case: class GitHubAPIManager { ... static let ErrorDomain = "com.error.GitHubAPIManager" ... }
Then next step in the process is in handleOpenURL in the AppDelegate: func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool { GitHubAPIManager.sharedInstance.processOAuthStep1Response(url) return true }
Doesn’t look like there’s anything to go wrong there. Next, processOAuthStep1Response which currently looks like this:
Authentication
144
func processOAuthStep1Response(url: NSURL) { let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false) var code:String? if let queryItems = components?.queryItems { for queryItem in queryItems { if (queryItem.name.lowercaseString == "code") { code = queryItem.value break } } } if let receivedCode = code { swapAuthCodeForToken(receivedCode) } }
If this function can’t find the code in the queryItems then it needs to let the completion handler know that it failed. We’ll make a custom error for that: func processOAuthStep1Response(url: NSURL) { let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false) var code:String? if let queryItems = components?.queryItems { for queryItem in queryItems { if (queryItem.name.lowercaseString == "code") { code = queryItem.value break } } } if let receivedCode = code { swapAuthCodeForToken(receivedCode) } else { // no code in URL that we launched with let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = self.OAuthTokenCompletionHandler { let noCodeInResponseError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth code", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(noCodeInResponseError) } } }
Authentication
Ok, one more step in the OAuth process, swapAuthCodeForToken: func swapAuthCodeForToken(receivedCode: String) { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") // TODO: bubble up error return } print(response.result.value) if let receivedResults = response.result.value, jsonData = receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) { let jsonResults = JSON(data: jsonData) for (key, value) in jsonResults { switch key { case "access_token": self.OAuthToken = value.string case "scope": // TODO: verify scope print("SET SCOPE") case "token_type": // TODO: verify is bearer print("CHECK IF BEARER") default: print("got more than I expected from the OAuth token exchange") print(key) } } } let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if (self.hasOAuthToken()) { self.printMyStarredGistsWithOAuth2() } } }
145
Authentication
146
We have success and failure cases here. Currently when we get the OAuth token we’re printing the starred gists. Instead we’ll call the completion handler without an error to let it know it can load the gists and display them now. We’ll also let it know if we fail: let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = self.OAuthTokenCompletionHandler { if (self.hasOAuthToken()) { completionHandler(nil) } else { let noOAuthError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth token", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(noOAuthError) } }
There’s also one more error path. Right after we make the URL request we check for any errors and return if we find one. We should call the completion handler there too: Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = self.OAuthTokenCompletionHandler { completionHandler(error) } return } ...
Here’s the fully updated swapAuthCodeForToken function:
Authentication func swapAuthCodeForToken(receivedCode: String) { let getTokenPath:String = "https://github.com/login/oauth/access_token" let tokenParams = ["client_id": clientID, "client_secret": clientSecret, "code": receivedCode] let jsonHeader = ["Accept": "application/json"] Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader) .responseString { response in if let error = response.result.error { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = self.OAuthTokenCompletionHandler { completionHandler(error) } return } print(response.result.value) if let receivedResults = response.result.value, jsonData = receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) { let jsonResults = JSON(data: jsonData) for (key, value) in jsonResults { switch key { case "access_token": self.OAuthToken = value.string case "scope": // TODO: verify scope print("SET SCOPE") case "token_type": // TODO: verify is bearer print("CHECK IF BEARER") default: print("got more than I expected from the OAuth token exchange") print(key) } } } let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = self.OAuthTokenCompletionHandler { if (self.hasOAuthToken()) { completionHandler(nil) } else {
147
148
Authentication let noOAuthError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth token", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(noOAuthError) } } } }
There we go, our super-async OAuthTokenCompletionHandler is now fully integrated with our table view and GitHubAPIManager. We’ve built a table view that displays data from an OAuth 2.0 API. Save and run to test it out. Try revoking the OAuth access to your GitHub account and deleting the app to force it to run as if it’s the very first run of the app. Finish integrating your authenticated API calls with your table view.
Here’s the finished code on GitHub (tagged “oauth”)¹⁹.
10.6.1 Refresh Tokens Some OAuth implementations will give you an access token that expires along with a refresh token that you can use to get a new access token later (along with your client ID and client secret). GitHub doesn’t do that. If you’re working with a different OAuth API then you should check the documentation to see if it returns a refresh token. If so, store the refresh token along with access token so you can use it later. Refreshing a token is similar to when we exchanged the code from the URL for an OAuth token: Make a GET request providing the refresh token with your client ID and secret. The advantage of using refresh tokens is that a compromised access token can only be used for a set time period. A refresh token is useless without the client ID and secret, so it would be best to secure those as well, but this section is long enough without adding something like creating a web server to host those values. Check your API documentation to see if you need to handle refresh tokens. If so, save it in the Keychain like the OAuth token. Then add an function to check for the expiry, similar to the unauthorized handling function. When you need to refresh the token you’ll need to follow a process similar to when we traded the OAuth code for the token.
¹⁹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/oauth
Authentication
149
10.7 Unauthorized Responses: 404 vs 401 What if the user later revokes our access? Then we’d have to do the OAuth login process again. What would happen in our code if we don’t have a valid OAuth token? It’s easy to check, just revoke it ourselves²⁰. The next time we make an API call we’ll run into trouble. We’ll think we have a valid OAuth token but it won’t be valid, so the call will fail. And we’ll just get a generic error: Error Domain=com.alamofire.error Code=-1 "The operation couldn't be completed. (com.alamof\ ire.error error -1.)"
But we if take a look at the response to our Alamofire request we can see that the HTTP status code is 401 Unauthorized. So we know that our token isn’t valid anymore. We’ll add a new function that will return an error if we’re unauthorized: func checkUnauthorized(urlResponse: NSHTTPURLResponse) -> (NSError?) { if (urlResponse.statusCode == 401) { self.OAuthToken = nil let lostOAuthError = NSError(domain: NSURLErrorDomain, code: NSURLErrorUserAuthenticationRequired, userInfo: [NSLocalizedDescriptionKey: "Not Logged In", NSLocalizedRecoverySuggestionErrorKey: "Please re-enter your GitHub credentials"]) return lostOAuthError } return nil }
This function takes in the NSHTTPURLResponse that we can access in our response serializers and checks the HTTP status code. If it’s 401 Unauthorized, then we generate an error and return it. Otherwise it just returns nil. Since there’s already a relevant error code available in iOS called NSURLErrorUserAuthenticationRequired in NSURLErrorDomain so we’ll use that for our error code. Then we can use that function in our Alamofire response serializers, starting with .getGists:
²⁰https://github.com/settings/applications
Authentication
150
func getGists(urlRequest: URLRequestConvertible, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { alamofireManager.request(urlRequest) .validate() .responseArray { (response:Response<[Gist], NSError>) in if let urlResponse = response.response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError), nil) return } ... } }
We get the NSHTTPURLResponse from the alamofire Response using urlResponse = response.response. Then we call our new checkUnauthorized function and return the error if we get one. By adding this call before the rest of the response serializer we can return immediately if we find an error. And we need to handle that error when we load the gists: func loadGists(urlToLoad: String?) { self.isLoading = true GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) self.nextPageURLString = nil self.isLoading = false if let error = result.error { if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } } return }
Authentication
151
if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else { self.gists = fetchedGists } } // update "last updated" title for refresh control let now = NSDate() let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) self.refreshControl?.attributedTitle = NSAttributedString(string: updateString) self.tableView.reloadData() } }
Test that out. Open the app and wait for the gists to load. Then revoke the app’s access²¹ and pull to refresh the gists. You should get sent back to GitHub in Safari to re-authorize the app.
10.8 And That’s All We worked through adding authentication to our API calls using basic auth, header-based auth or OAuth 2.0. As we add more API calls we’ll be able to reuse that code and just worry about handling the errors in the code that calls it. If you got tired of typing, here’s the code: (tagged “auth”)²². Now that we have authenticated calls we can add more API calls. Next we’ll let the users switch between three lists of gists in the main table view. ²¹https://github.com/settings/applications ²²https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/auth
11. Switching Lists Our requirements included being able to switch between 3 different lists of gists: public gists, starred gists, and my gists. Let’s see how we can set up our single table view to let us do that. Analyze your requirements to see what user interface components you need. Read through this chapter as an example of handling multiple calls that return similar results. Similar code could be used to display filters or search results if your API supports them.
11.1 Setting Up the UI We already have the functions to make API calls for all 3 sets of gists. We just need to hook them up to the user interface. It’d be handy to be able to switch between these sets of gists in our UI. Let’s toss in a UISegmentedControl to do that. In the main storyboard drag a segmented control into the navigation bar for the MasterViewController:
152
Switching Lists
Change the number of segments to 3:
Change the titles to “Public”, “Starred”, and “My Gists” (in that order):
153
Switching Lists
154
We’ll need to refer to the segmented control in code and get notified when the selected segment changed. So in the MasterViewController add an IBOutlet: class MasterViewController: UITableViewController { ... @IBOutlet weak var gistSegmentedControl: UISegmentedControl! ... }
And an IBAction: @IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { }
And hook them up in the storyboard:
Switching Lists
155
Switching Lists
156
11.2 Sharing a Completion Handler Now we need to load the correct data. So we’ll change our loadGists() function to check what the segmented control is currently telling us to show. To make it easier to reuse the completion handler for all 3 different functions we’ll declare it as a variable: func loadGists(urlToLoad: String?) { self.isLoading = false let completionHandler: (Result<[Gist], NSError>, String?) -> Void = { (result, nextPage) in self.isLoading = false self.nextPageURLString = nextPage // tell refresh control it can stop showing up now if self.refreshControl != nil && self.refreshControl!.refreshing { self.refreshControl?.endRefreshing() } guard result.error == nil else { print(result.error) self.nextPageURLString = nil self.isLoading = false if let error = result.error { if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } } return } if let fetchedGists = result.value { if urlToLoad != nil { self.gists += fetchedGists } else { self.gists = fetchedGists } } // update "last updated" title for refresh control let now = NSDate() let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) self.refreshControl?.attributedTitle = NSAttributedString(string: updateString)
Switching Lists
157
self.tableView.reloadData() } switch gistSegmentedControl.selectedSegmentIndex { case 0: GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad, completionHandler: completionHandler) case 1: GitHubAPIManager.sharedInstance.getMyStarredGists(urlToLoad, completionHandler: completionHandler) case 2: GitHubAPIManager.sharedInstance.getMyGists(urlToLoad, completionHandler: completionHandler) default: print("got an index that I didn't expect for selectedSegmentIndex") } }
Since we’re using Alamofire’s Result struct we’ll have to import it in to this file: import import import import
UIKit PINRemoteImage SafariServices Alamofire
class MasterViewController: UITableViewController, LoginViewDelegate, SFSafariViewControll\ erDelegate { ... }
To declare a block as a variable or constant, we need to specify the type so the compiler knows where it can be used. That’s what we’re doing here: let completionHandler: (Result<[Gist], NSError>, String?) -> Void = { ... }
We’re declaring a block called completionHandler. It’s a constant and it takes in 2 arguments: a Result that can hold either an array of gists or an error, and the URL for the next page of results as a String. And it doesn’t return anything (Void). You’ll notice that it matches the declarations for the completion handler arguments for getPublicGists, getMyStarredGists and getMyGists:
Switching Lists
158
func getPublicGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetPublic(), completionHandler: completionHandler) } } func getMyStarredGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetMyStarred(), completionHandler: completionHandler) } } func getMyGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetMine(), completionHandler: completionHandler) } }
And in the router we need to add the GetMine case. It’s similar to the GetMyStarred case that we added earlier when working on authentication: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" case case case case
GetPublic() // GET https://api.github.com/gists/public GetMyStarred() // GET https://api.github.com/gists/starred GetMine() // GET https://api.github.com/gists GetAtPath(String) // GET at given path
var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { case .GetPublic: return .GET case .GetMyStarred: return .GET
Switching Lists
159
case .GetMine: return .GET case .GetAtPath: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { case .GetPublic: return ("/gists/public", nil) case .GetMyStarred: return ("/gists/starred", nil) case .GetMine: return ("/gists", nil) case .GetAtPath(let path): let URL = NSURL(string: path) let relativePath = URL!.relativePath! return (relativePath, nil) } }() let URL = NSURL(string: GistRouter.baseURLString)! let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path)) // Set OAuth token if we have one if let token = GitHubAPIManager.sharedInstance.OAuthToken { URLRequest.setValue("token \(token)", forHTTPHeaderField: "Authorization") } let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters) encodedRequest.HTTPMethod = method.rawValue return encodedRequest } }
And we need to add that call to the API manager:
Switching Lists
160
func getMyGists(pageToLoad: String?, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { if let urlString = pageToLoad { getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) } else { getGists(GistRouter.GetMine(), completionHandler: completionHandler) } }
Finally, we need to call loadGists when the user changes the selected segment so that the list of gists that the user sees gets updated: @IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { loadGists(nil) }
11.3 And That’s All Since we put in so much work in to designing our API manager earlier, adding this functionality didn’t take too much effort. Save and run to test it out before moving on to creating a detail view in the next chapter. If you got tired of typing, here’s the code: (tagged “lists”)¹. ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/lists
12. Switching Between View Controllers and More JSON Parsing Now we have a Swift app that: • • • •
Pulls gist data from the GitHub Gists API¹ using Alamofire² Uses custom response serializers to process the JSON into an array of Gist objects Parses some string fields in the web service JSON Displays the results in a table view, including loading images from URLs in the table view cells • Lets the user select from 3 different lists with 3 different API calls • Loads more results as the user scrolls down in the table view • Allows users to pull to refresh the data In this chapter we’ll keep progressing on this app to add more of the features required in “real” apps. We will add: • Parsing JSON including arrays of objects (files in this case) and strings to dates (with NSDateFormatter) • Passing data from a table view to a detail view, using a storyboard and segue. Tapping on a row in the table view will open a detail view displaying additional data about that gist • Creating a new view controller completely in code to display the content of the gist’s files. Tapping on a filename will show the file’s content in a web view Two of those features involve changing which view controller is shown but in very different ways. The transition to the detail view uses a segue and the transition to a file’s content works by having a navigation controller push a new view controller onto its stack.
12.1 JSON Parsing: Arrays & Dates When our project is launched, it makes a call to the API to get a list of gists in JSON. The data passes through a response serializer which parses the JSON to create Gist objects. So far we’ve only been parsing a few string properties from the JSON for each gist. Now we’ll add extracting an array of files and some the dates that the gist was created and last updated. We’ll need to add some properties to our Gist model class: ¹https://developer.github.com/v3/gists/ ²https://github.com/Alamofire/Alamofire
161
Switching Between View Controllers and More JSON Parsing class var var var var var var var var ... }
162
Gist: ResponseJSONObjectSerializable { id: String? description: String? ownerLogin: String? ownerAvatarURL: String? url: String? files:[File]? createdAt:NSDate? updatedAt:NSDate?
And we’ll need a class to represent each of the files (a gist can have multiple files in it). For each file we’ll just pull out the filename and the URL of the raw content so we can display it in a web view. Let’s create a new File.swift class and implement the File class in it: import SwiftyJSON class File: ResponseJSONObjectSerializable { var filename: String? var raw_url: String? }
Like the Gist class, our File class needs to be created from a chunk of JSON so it’ll implement the ResponseJSONObjectSerializable protocol: class File: ResponseJSONObjectSerializable { var filename: String? var raw_url: String? required init?(json: JSON) { self.filename = json["filename"].string self.raw_url = json["raw_url"].string } }
And we need to extend the initializer for Gists to handle those files (we’ll get to the dates next):
Switching Between View Controllers and More JSON Parsing
163
required init?(json: JSON) { self.description = json["description"].string self.id = json["id"].string self.ownerLogin = json["owner"]["login"].string self.ownerAvatarURL = json["owner"]["avatar_url"].string self.url = json["url"].string // files self.files = [File]() if let filesJSON = json["files"].dictionary { for (_, fileJSON) in filesJSON { if let newFile = File(json: fileJSON) { self.files?.append(newFile) } } } // TODO: dates }
For the files we create an array: self.files = [File]() The files are a dictionary within the JSON that looks like this: "files": { "ring.erl": { "size": 932, "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0\ ff512853564e/ring.erl", "type": "text/plain", "language": "Erlang", "truncated": false, "content": "content of gist" }, ... }
So we loop through that dictionary and extract the filename and raw_url for each file:
Switching Between View Controllers and More JSON Parsing
164
if let filesJSON = json["files"].dictionary { for (_, fileJSON) in filesJSON { if let newFile = File(json: fileJSON) { self.files?.append(newFile) } } }
Since filesJSON is a dictionary we can loop over the key and value using for (_, fileJSON) in filesJSON. The _ is placeholder for the key that indicates that we’re not using those values. Then we try to create a new file with the JSON for a single file and add it to our array of files if it succeeds: if let newFile = File(json: fileJSON) { self.files?.append(newFile) }
And that’s all we need to do to parse the array of files in the JSON.
12.2 Parsing Dates in JSON What about the dates? How do we parse those? Since we’re getting the dates as strings and we want to store them as NSDate objects, we’ll need an NSDateFormatter. An NSDateFormatter can convert strings to NSDates and back. Our dates look like "2014-12-10T16:44:31.486000Z". To set up a date formatter for those strings we’ll want to specify the string format and set a few parameters to tell the date formatter what kind of date it’s processing: class func dateFormatter() -> NSDateFormatter { let aDateFormatter = NSDateFormatter() aDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" aDateFormatter.timeZone = NSTimeZone(abbreviation: "UTC") aDateFormatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") return aDateFormatter }
The locale and timeZone are useful for using NSDateFormatters with dates from servers, which you often can’t guarantee to have the same language, time zone and cultural display of dates as your users. Don’t use this approach when setting up dates to display to users. In that case you should use date and time styles like NSDateFormatterShortStyle so that the user sees their dates and times in the way that makes the most sense to them. Now we need to apply that date formatter to the created and updated date strings:
Switching Between View Controllers and More JSON Parsing
165
class Gist { ... required init?(json: JSON) { ... // Dates let dateFormatter = Gist.dateFormatter() if let dateString = json["created_at"].string { self.createdAt = dateFormatter.dateFromString(dateString) } if let dateString = json["updated_at"].string { self.updatedAt = dateFormatter.dateFromString(dateString) } } ... }
Creating NSDateFormatters or changing their properties can be very expensive computationally. This code creates a new NSDateFormatter for each gist being parsed. If I found that the app was slow that’s one of the first things I would optimize: having all of the gists share a single date formatter. Here’s how to do it: static let sharedDateFormatter = Gist.dateFormatter() required init?(json: JSON) { ... let dateFormatter = Gist.sharedDateFormatter if let dateString = json["created_at"].string { self.createdAt = dateFormatter.dateFromString(dateString) } if let dateString = json["updated_at"].string { self.updatedAt = dateFormatter.dateFromString(dateString) } }
If we run the app now it doesn’t even look any different. Don’t fret, getting the data to display isn’t all that difficult. Let’s do it by fixing up the detail view controller that Xcode created way back when we created this project. We’ll rig it so that when we tap on the table view row for a gist we’ll get a new view showing a bunch of its details. Look back at the JSON for your model object class. Select a few more properties to display in a detail view, maybe some dates or arrays. Add them to the class and the JSON parsing.
Switching Between View Controllers and More JSON Parsing
166
12.3 Configuring the Detail View Controller Open up the DetailViewController file that Xcode generated. It looks like this: import UIKit class DetailViewController: UIViewController { @IBOutlet weak var detailDescriptionLabel: UILabel! var detailItem: AnyObject? { didSet { // Update the view. self.configureView() } } func configureView() { // Update the user interface for the detail item. if let detail = self.detailItem { if let label = self.detailDescriptionLabel { label.text = detail.description } } } override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. self.configureView() } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } }
Checking out the storyboard we can see that it’s a pretty simple view: it just shows a label that will display the details for our detailObject.
Switching Between View Controllers and More JSON Parsing
167
Initial Detail View
We’ll want to improve that UI but first let’s change the detailObject from being a generic optional AnyObject to being a Gist. Change the variable’s name in Detail View Controller from detailItem to gist and declare it as a Gist: var gist: Gist? { didSet { // Update the view. self.configureView() } } func configureView() { // Update the user interface for the detail item. if let currentGist = self.gist { if let label = self.detailDescriptionLabel { label.text = currentGist.description } } }
Now where is this view getting the gist from? We’ll have to figure that out and change the variable name there too. There are 2 easy ways to find it:
Switching Between View Controllers and More JSON Parsing
168
1. Search the project for the text “detailItem” 2. Try to build the project, it’ll fail wherever “detailItem” is used since that variable doesn’t exist anymore
We shouldn’t be needing to hunt down all the uses of detailItem. If we were writing ObjectiveC we could just right-click on it and select Refactor -> Rename. Sadly that functionality isn’t implemented for Swift yet. Hopefully it’s coming soon.
. So using either of those methods we’ll find 2 places outside of the Detail View Controller where detailItem is used. So change both of them to refer to gist instead: In the AppDelegate: func splitViewController(splitViewController: UISplitViewController, collapseSecondaryViewController secondaryViewController:UIViewController, ontoPrimaryViewController primaryViewController:UIViewController) -> Bool { guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false } guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false } if topAsDetailController.gist == nil { // Return true to indicate that we have handled the collapse by doing nothing // the secondary controller will be discarded. return true } return false }
And in the MasterViewController where the gist from the table view row gets passed to the DetailViewController for display. Here we need to changed the type from NSDate to Gist as well as fixing the name. We’ll also change the code to check that the top view controller in the navigation controller is a DetailViewController instead of assuming it, just in case we ever change the navigation:
Switching Between View Controllers and More JSON Parsing
169
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if segue.identifier == "showDetail" { if let indexPath = self.tableView.indexPathForSelectedRow { let gist = gists[indexPath.row] as Gist if let detailViewController = (segue.destinationViewController as! UINavigationController).topViewController as? DetailViewController { detailViewController.gist = gist detailViewController.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem() detailViewController.navigationItem.leftItemsSupplementBackButton = true } } } }
Change the detail item in your DetailViewController to your model object class. Fix any other references to it in your project.
Now you can build & run. Tap on a gist and you should see a pretty boring display of its description:
Switching Between View Controllers and More JSON Parsing
Gist Description in Detail View
170
Switching Between View Controllers and More JSON Parsing
171
12.4 Passing Data in a Segue But how does it work? Well, there’s a connection in the storyboard between the MasterViewController table view cells and the DetailViewController (well, actually to the navigation controller holding the DetailViewController but that’s just so we have a nice title bar):
Detail Segue
To zoom out when viewing the storyboard select Editor -> Canvas -> Zoom. Nice of them to hide that so well, isn’t it?
. So when a table view cell is tapped that segue is activated. The prepareForSegue function is called when the transition (aka, “segue”) from the table view to the detail view starts. In prepareForSegue this is what happens: • Get the view controller we’re going to: let controller = (segue.destinationViewController as! UINavigationController).topViewController as! DetailViewController
• Figure out which row they tapped using indexPathForSelectedRow(). That’s the currently selected row. The indexPath gives us the row and section for it, in case we had a table view with multiple sections • Get the gist that corresponds to that row: let object = gists[indexPath.row] • And pass that gist to the destination view controller: controller.gist = object
Switching Between View Controllers and More JSON Parsing
172
That’s what Xcode set up for us when we generated the Master-Detail project. We updated it to use our Gist class earlier when we created that class: // MARK: - Segues override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if segue.identifier == "showDetail" { if let indexPath = self.tableView.indexPathForSelectedRow { let gist = gists[indexPath.row] as Gist if let detailViewController = (segue.destinationViewController as! UINavigationController).topViewController as? DetailViewController { detailViewController.detailItem = gist detailViewController.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem() detailViewController.navigationItem.leftItemsSupplementBackButton = true } } } }
12.5 Adding a Table View Let’s improve the UI by displaying the bits of data we have in a table view. First we’ll set up our DetailViewController as a UITableViewDataSource and UITableViewDelegate. We can do that even though DetailViewController isn’t a UITableViewController. This approach is useful when you want to use a table view as only part of a view, for example if you want to have a header above the table view that doesn’t scroll with the table view. We’ll also need a reference to the table view and we can get rid of that reference to the label: class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! ...
Now we need to switch over to the main storyboard so we can add the table view. Open the Main.Storyboard and select the DetailViewController:
Switching Between View Controllers and More JSON Parsing
173
Select Detail View Controller
In the Detail View Controller, delete the label and drag in a table view. Drag it around until it fills the view:
Add Table View
Switching Between View Controllers and More JSON Parsing
174
Align Table View
Add a single prototype cell to the table view. Set its identifier to “Cell”.
Add Prototype Cell
Set Cell ID
In the top of the right panel, choose the connection organizer (last tab, the icon is an arrow in a circle). Hook up the Detail View Controller as the table view’s data source and delegate. To do so, select the table view then drag connections from those circles to the Detail View Controller (yellow icon above the view). And hook up the table view IBOutlet from the Detail View Controller to the table view:
Switching Between View Controllers and More JSON Parsing
175
Connect Table View IBOutlet
Connect Data Source and Delegate
Now we need to go back to the Detail View Controller and fill in the data for that table view to display. We’ll have 2 sections: the general data about the gist like its description and a list of the files in the gist. Then if they tap on a file we’ll display its content:
Switching Between View Controllers and More JSON Parsing
176
So we have 2 sections and we can give them titles. The first section has 2 items in it (for the description and owner) and the second one has enough rows to show all of the files: func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 2 } func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { return 2 } else { return gist?.files?.count ?? 0 } } func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if section == 0 { return "About" } else { return "Files" } }
?? is the nil coalescing operator. It just means “give me this value, unless it’s nil, then give me this default value”. So return gist?.files?.count ?? 0 will return the number of files, unless we don’t
have any files, then we’ll get 0 instead of nil. We can use the data in the gist to populate those cells: func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) if indexPath.section == 0 { if indexPath.row == 0 { cell.textLabel?.text = gist?.description } else if indexPath.row == 1 { cell.textLabel?.text = gist?.ownerLogin } } else { if let file = gist?.files?[indexPath.row] { cell.textLabel?.text = file.filename }
Switching Between View Controllers and More JSON Parsing } return cell }
And we’ll need to tell the table view to reload after the gist is set: func configureView() { // Update the user interface for the detail item. if let detailsView = self.tableView { detailsView.reloadData() } }
Build & run to make sure that works. Set up a table view or custom view using IBOutlets in DetailViewController to show the details for the objects in your list.
Gist Details Displayed in Table View
177
Switching Between View Controllers and More JSON Parsing
178
While it’s nice to see the list of files, this view really isn’t all that useful. Let’s add 2 things to make it better: 1. Showing the file content when they tap on a filename 2. Let users star & unstar the gist
12.6 Displaying Gist File Content When parsing the JSON didn’t actually get the content for the files, we just parsed out a URL where the raw content of the file exist. We can display a URL using a web view. There are certainly nicer ways to do it but a web browser window will work. We added the Safari Services framework back when we set up (OAuth)[#oauth]. If we hadn’t done that we’d need to add it to our project now. Import the SafariServices framework in DetailViewController: import UIKit import SafariServices class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { ... }
Then hook it up when they select a row in that second section: func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { if indexPath.section == 1 { if let file = gist?.files?[indexPath.row], urlString = file.raw_url, url = NSURL(string: urlString) { let safariViewController = SFSafariViewController(URL: url) safariViewController.title = file.filename self.navigationController?.pushViewController(safariViewController, animated: true) } } }
So when they tap a filename, we get the url:
Switching Between View Controllers and More JSON Parsing
179
if let file = gist?.files?[indexPath.row], urlString = file.raw_url, url = NSURL(string: urlString) {
Then we create a Safari view controller with our URL, showing the filename in the navigation bar at the top of the screen: let safariViewController = SFSafariViewController(URL: url) safariViewController.title = file.filename
And use our navigation controller to switch to that view: self.navigationController?.pushViewController(safariViewController, animated: true)
Using the navigation controller means that they’ll get a back button or be able to use a swipe gesture to go back. We don’t need to add any buttons to our new view controller for that to work. Consider if any of your content would be best shown in a web view. If so, implement it by following the steps in this section.
12.7 And That’s All Build and run to test that out. Next chapter we’ll add additional web service calls to star & unstar the gist. If you got tired of typing, here’s the code: (tagged “detail”)³. ³https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/detail
13. Adding More API Calls - Starring This chapter is all about hooking in to additional web services to display details. Our DetailViewController will call three more API endpoints: • Loading more data from another web service: Is a gist starred? • PUT & DELETE: Star & unstar gists One bit of data that we didn’t get in the JSON about a gist is whether or not we’ve starred it. There’s a whole other API call for that: GET /gists/:id/star. According to the documentation we’ll get a 204 No Content response if we have starred the gist and a 404 Not Found response if we haven’t.
13.1 Is the Gist Starred? To set up starring we first need to know whether we’ve already starred a gist. So we’ll pop over to our GitHubAPIManager class and add a function for that. We can use .validate(statusCode: 204...204) to check for the 204 response so the rest of this function is pretty simple: // MARK: Starring / Unstarring / Star status func isGistStarred(gistId: String, completionHandler: Result -> Void) { // GET /gists/:id/star alamofireManager.request(GistRouter.IsStarred(gistId)) .validate(statusCode: [204]) .response { (request, response, data, error) in // 204 if starred, 404 if not if let error = error { print(error) if response?.statusCode == 404 { completionHandler(.Success(false)) return } completionHandler(.Failure(error)) return } completionHandler(.Success(true)) } }
And in the router: 180
Adding More API Calls - Starring
181
enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" ... case IsStarred(String) // GET https://api.github.com/gists/\(gistId)/star var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { ... case .IsStarred: return .GET } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { ... case .IsStarred(let id): return ("/gists/\(id)/star", nil) } }() ... return encodedRequest } }
13.2 Starred Status in the Table View In our DetailViewController we’ll want a variable to say if the gist has been starred or if we haven’t gotten a response from that API call yet. This is the perfect place for an optional: var isStarred: Bool?
If isStarred is nil then we haven’t gotten a response yet. Otherwise the true/false value indicates whether it’s starred or not. So let’s call the isGistStarred function when this view is shown:
Adding More API Calls - Starring
182
func configureView() { // Update the user interface for the detail item. if let _: Gist = self.gist { fetchStarredStatus() if let detailsView = self.tableView { detailsView.reloadData() } } } func fetchStarredStatus() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { result in if let error = result.error { print(error) } if let status = result.value where self.isStarred == nil { // just got it self.isStarred = status // TODO: update display } }) } }
To display the starred status we can add a third row to the first section of the table view, based on whether isStarred is nil or not. To animate adding that row when we get the API response we can use tableView?.insertRowsAtIndexPaths with a single indexPath (3rd row in the 1st section): func fetchStarredStatus() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { result in if let error = result.error { print(error) } if let status = result.value where self.isStarred == nil { // just got it self.isStarred = status self.tableView?.insertRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
Adding More API Calls - Starring
183
And we need to update our table view data source methods to know what to display in that row: func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { if let _ = isStarred { return 3 } return 2 } else { return gist?.files?.count ?? 0 } }
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) if indexPath.section == 0 { if indexPath.row == 0 { cell.textLabel?.text = gist?.description } else if indexPath.row == 1 { cell.textLabel?.text = gist?.ownerLogin } else { if let starred = isStarred { if starred { cell.textLabel?.text = "Unstar" } else { cell.textLabel?.text = "Star" } } } } else { if let file = gist?.files?[indexPath.row] { cell.textLabel?.text = file.filename // TODO: add disclosure indicators } } return cell }
In the table view cell we’ll display “Star” if it’s not starred and “Unstar” if it is starred. So we’ll need to take those actions if they tap on that row:
Adding More API Calls - Starring
184
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { if indexPath.section == 0 { if indexPath.row == 2 { // star or unstar if let starred = isStarred { if starred { // unstar unstarThisGist() } else { // star starThisGist() } } } } else if indexPath.section == 1 { ... } }
13.3 PUT and DELETE Calls to Star and Unstar Gists To implement starThisGist() and unstarThisGist() we need two more simple API calls in GitHubAPIManager (getting used to adding those yet?): func starGist(gistId: String, completionHandler: (NSError?) -> Void) { alamofireManager.request(GistRouter.Star(gistId)) .response { (request, response, data, error) in if let error = error { print(error) return } completionHandler(error) } } func unstarGist(gistId: String, completionHandler: (NSError?) -> Void) { alamofireManager.request(GistRouter.Unstar(gistId)) .response { (request, response, data, error) in if let error = error { print(error) return } completionHandler(error) } }
Adding More API Calls - Starring
185
And in the router: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" ... case Star(String) // PUT https://api.github.com/gists/\(gistId)/star case Unstar(String) // DELETE https://api.github.com/gists/\(gistId)/star var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { ... case .Star: return .PUT case .Unstar: return .DELETE } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { ... case .Star(let id): return ("/gists/\(id)/star", nil) case .Unstar(let id): return ("/gists/\(id)/star", nil) } }() ... return encodedRequest } }
In DetailViewController we can call those functions using our gist’s ID, update the isStarred status, and reload the row in the table view to reflect that status:
Adding More API Calls - Starring
186
func starThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.starGist(gistId, completionHandler: { (error) in if let error = error { print(error) } else { self.isStarred = true self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } } func unstarThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.unstarGist(gistId, completionHandler: { (error) in if let error = error { print(error) } else { self.isStarred = false self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
Go back to your requirements. Choose some API calls that haven’t been used in your app yet. Add functions to make those API calls to your API manager and router. Integrate them into your app’s UI. Don’t pick calls to create or delete objects yet, we’ll handle those in the next few chapters.
13.4 Authorization Check We should add a check that we’re still authorized to these calls, like we did with getGists. That way the user will see a more useful error message in those cases:
Adding More API Calls - Starring
187
func isGistStarred(gistId: String, completionHandler: Result -> Void) { alamofireManager.request(GistRouter.IsStarred(gistId)) .validate(statusCode: [204]) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError)) return } ... } } func starGist(gistId: String, completionHandler: (NSError?) -> Void) { alamofireManager.request(GistRouter.Star(gistId)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(authError) return } ... } } func unstarGist(gistId: String, completionHandler: (NSError?) -> Void) { alamofireManager.request(GistRouter.Unstar(gistId)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(authError) return } ... } }
The existing checkUnauthorized function will take care of creating an appropriate error in those cases. We’ll need to update the methods in DetailViewController so the user actually sees it:
Adding More API Calls - Starring
188
class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { ... var alertController: UIAlertController? ... func fetchStarredStatus() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { result in if let error = result.error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not get starred status", message: error.description, preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } } if let status = result.value where self.isStarred == nil { // just got it self.isStarred = status self.tableView?.insertRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } } ... }
We can check the error domain and code: error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired. If they match our OAuth error then we can show a UIAlertController with our error’s description. Starring and unstarring are similar, except for those we’ll show a generic error if the call fails. Since displaying the starred status isn’t something the user specifically requested we won’t interrupt their use of the app to show an error dialog if it didn’t work:
Adding More API Calls - Starring func starThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.starGist(gistId, completionHandler: { (error) in if let error = error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not star gist", message: error.description, preferredStyle: .Alert) } else { self.alertController = UIAlertController(title: "Could not star gist", message: "Sorry, your gist couldn't be starred. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) } // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } else { self.isStarred = true self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } } func unstarThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.unstarGist(gistId, completionHandler: { (error) in if let error = error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not unstar gist", message: error.description, preferredStyle: .Alert) } else { self.alertController = UIAlertController(title: "Could not unstar gist", message: "Sorry, your gist couldn't be unstarred. " + " Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert)
189
Adding More API Calls - Starring
190
} // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } else { self.isStarred = false self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
If necessary, add auth checks to your API calls.
13.5 And That’s All And finally that’s it for this detail view! Run it and check out all your deets. Enjoy the sweet animation when the isStarred status gets loaded just after the view appears. If you got tired of typing, here’s the code: (tagged “star”)¹. So what next? We only have a few chapters left but we’ll still be adding two key features: deleting and creating gists. After that we’ll figure out what happens to our very web-dependent app if the user doesn’t have an internet connection. Then we’ll wrap up with some ideas about how you could extend this app or make your own API-driven Swift app. ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/star
14. Deleting Gists Deleting gists isn’t much more difficult than starring and unstarring gists. We just need to check the documentation to find out what API calls need to be made, implement calling them in our GitHubAPIManager, and then hook that up to our user interface. Like starring, deleting gists requires authentication. You can’t just go around deleting other users’ gists.
14.1 DELETE API Call To delete a gist we need to make an authenticated call to: https://api.github.com/gists/*gistID*
We can implement this call in our GitHubAPIManager, including checking that their OAuth credentials are still valid: func deleteGist(gistId: String, completionHandler: (NSError?) -> Void) { alamofireManager.request(GistRouter.Delete(gistId)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(authError) return } if let error = error { print(error) return } completionHandler(error) } }
And in the router:
191
192
Deleting Gists enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" ... case Delete(String) // DELETE https://api.github.com/gists/\(gistId) var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { ... case .Delete: return .DELETE } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { ... case .Delete(let id): return ("/gists/\(id)", nil) } }() ... return encodedRequest } }
Be careful! Deleting gists really deletes them so don’t delete gists you want to keep. Now would be a good time to go create a bunch of private test gists that you can delete to test this feature.
If you can delete objects in your table view then set up the API call to do so in your API manager now.
14.2 User Interface: Table View Delete Features Table views have some great UI already built in for handling deletion. Swipe to delete is now commonly understood by users so there’s no need to implement your own different way to handle it.
Deleting Gists
193
Setting up swipe to delete for our table view in MasterViewController only takes a few steps. First we need to let the table view know that the rows can be deleted. But we only want to do that if we’re looking at our gists, not if we’re looking at the lists of public or starred gists: override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool { // only allow editing my gists return gistSegmentedControl.selectedSegmentIndex == 2 }
We can also add an edit button so that they can switch to edit mode to easily delete multiple gists. We’ll add it when they switch to viewing their gists and remove it for public and starred gists: @IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { // only show add button for my gists if (gistSegmentedControl.selectedSegmentIndex == 2) { self.navigationItem.leftBarButtonItem = self.editButtonItem() } else { self.navigationItem.leftBarButtonItem = nil } loadGists(nil) }
And remove it from viewDidLoad so it doesn’t show up when we first load the public gists: override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. self.navigationItem.leftBarButtonItem = self.editButtonItem() let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "insertNewObject:") self.navigationItem.rightBarButtonItem = addButton if let split = self.splitViewController { let controllers = split.viewControllers self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController } }
Then we need to tell the table view what to do when the user swipes and taps the delete button. We’ll take 3 actions:
Deleting Gists
194
1. Delete the gist from our array of gists 2. Remove the row from the table view. We can use tableView.deleteRowsAtIndexPaths instead of reloading all of the rows 3. Make the API call to delete the gist Here’s how to do that: override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { gists.removeAtIndex(indexPath.row) tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) // delete from API if let id = gists[indexPath.row].id { GitHubAPIManager.sharedInstance.deleteGist(id, completionHandler: { (error) in print(error) }) } } }
Now it might seem a bit odd that we’re removing the gist from the table view before it’s actually been deleted. It makes for a good user experience though: they’ll see the reaction to their tap immediately instead of having to wait for the server to respond to the API call. But what if there’s a problem and the API call fails? Then we’ll just have to put the gist back. That’s a rare enough occurrence that we’ll handle it by putting the gist back in our array and table view. We should also tell the user that their attempt to delete the gist didn’t work: override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { let gistToDelete = gists.removeAtIndex(indexPath.row) tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) // delete from API if let id = gists[indexPath.row].id { GitHubAPIManager.sharedInstance.deleteGist(id, completionHandler: { (error) in print(error) if let _ = error { // Put it back self.gists.insert(gistToDelete, atIndex: indexPath.row) tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Right)
195
Deleting Gists // tell them it didn't work let alertController = UIAlertController(title: "Could not delete gist", message: "Sorry, your gist couldn't be deleted. Maybe GitHub is " + "down or you don't have an internet connection.", preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) alertController.addAction(okAction) // show the alert self.presentViewController(alertController, animated:true, completion: nil) } }) } } }
Integrate your delete call with the table view’s built-in delete features.
14.3 And That’s All That’s all it takes to add deleting gists as a feature in our app. Adding swipe to delete and an edit mode are easy when you use a table view: If you got tired of typing, here’s the code: (tagged “delete”)¹. ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/delete
15. Creating Gists and Clearing the Cache Creating gists is a bit more work because we need to get some input from the user. So we’ll create a new view to collect that input. But first let’s look at the API call we need to make. Creating gists can be done anonymously but we’ll stick to creating gists that are owned by the current user since we already have the authentication built into our app.
15.1 POST API Call with Nested JSON Parameters To create a gist we POST to https://api.github.com/gists. We need to pass in the data to generate the gist as JSON that looks like this: { "description": "the description for this gist", "public": true, "files": { "file1.txt": { "content": "String file content" } } }
So we need two bits of input about the gist: 1. Description: String 2. Public or private: Boolean And two bits of info about each file in it: 1. File name: String 2. File content: String So let’s figure out how to take those inputs and turn them into the JSON that the API call expects. We already have a class that represents File objects so we’ll use that: 196
Creating Gists and Clearing the Cache
197
func createNewGist(description: String, isPublic: Bool, files: [File], completionHandler: (Result) -> Void) {
Ok, so we’ve got the data passed in, now how to turn it into JSON? Alamofire expects the JSON parameters as a [String: AnyObject] dictionary. It can be made up of arrays, dictionaries and strings. First let’s convert the isPublic boolean value to a string: let publicString: String if isPublic { publicString = "true" } else { publicString = "false" }
Then we’ll tackle the Files. We’ll create a little JSON dictionary of files that’ll look like this: "file1.txt": "content": } "file2.txt": "content": } ...
{ "String file1 content" { "String file2 content"
Our File object needs a content property and we’ll need to be able to create File objects with names and content: class var var var
File: ResponseJSONObjectSerializable { filename: String? raw_url: String? content: String?
required init?(json: JSON) { self.filename = json["filename"].string self.raw_url = json["raw_url"].string } init?(aName: String?, aContent: String?) { self.filename = aName self.content = aContent } }
Back in CreateNewGist let’s create that dictionary and then add an entry for each File object:
Creating Gists and Clearing the Cache var filesDictionary = [String: AnyObject]() for file in files { if let name = file.filename, content = file.content { filesDictionary[name] = ["content": content] } }
Then we can put all the bits together in one dictionary: let parameters:[String: AnyObject] = [ "description": description, "isPublic": publicString, "files" : filesDictionary ]
We need to add the API call to the router: enum GistRouter: URLRequestConvertible { static let baseURLString:String = "https://api.github.com" ... case Create([String: AnyObject]) // POST https://api.github.com/gists var URLRequest: NSMutableURLRequest { var method: Alamofire.Method { switch self { ... case .Create: return .POST } } let result: (path: String, parameters: [String: AnyObject]?) = { switch self { ... case .Create(let params): return ("/gists", params) } }() ... return encodedRequest } }
198
Creating Gists and Clearing the Cache
199
When creating the URL request the router will add the parameters and specify that they’re JSON. We’ve had that code in the router for a while but we haven’t used it yet: let encoding = Alamofire.ParameterEncoding.JSON let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters)
Now we’re set to make the request: alamofireManager.request(GistRouter.Create(parameters)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError)) return } if let error = error { print(error) completionHandler(.Success(false)) return } completionHandler(.Success(true)) }
So putting all of that together: func createNewGist(description: String, isPublic: Bool, files: [File], completionHandler: Result -> Void) { let publicString: String if isPublic { publicString = "true" } else { publicString = "false" } var filesDictionary = [String: AnyObject]() for file in files { if let name = file.filename, content = file.content { filesDictionary[name] = ["content": content] } } let parameters:[String: AnyObject] = [ "description": description, "isPublic": publicString, "files" : filesDictionary
Creating Gists and Clearing the Cache
200
] alamofireManager.request(GistRouter.Create(parameters)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError)) return } if let error = error { print(error) completionHandler(.Success(false)) return } completionHandler(.Success(true)) } }
If you have one, add a POST call to create a new object to your API manager.
Now to create a UI to get the input from the user.
15.2 Creating an Input Form with Validation We’ll create a form to get the info from the user using the XLForm¹ library. XLForm is a commonly used library to create forms in iOS with built-in validation. Add XLForm v3.0 to your project using CocoaPods. We’ll only want the user to be able to access this form when they’re looking at their gists. Like the edit button, we’ll add a + button to the top right corner of the view controller when they switch to that list of gists and remove it when they switch to public or starred gists (in MasterViewController). Unlike the edit button a + button doesn’t already have a default action hooked up to it so we’ll use the insertNewObject: function to handle that:
¹https://github.com/xmartlabs/XLForm
Creating Gists and Clearing the Cache
201
@IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { // only show add button for my gists if (gistSegmentedControl.selectedSegmentIndex == 2) { self.navigationItem.leftBarButtonItem = self.editButtonItem() let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "insertNewObject:") self.navigationItem.rightBarButtonItem = addButton } else { self.navigationItem.leftBarButtonItem = nil self.navigationItem.rightBarButtonItem = nil } loadGists(nil) }
And we need to remove setting the add button in viewDidLoad: override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "insertNewObject:") self.navigationItem.rightBarButtonItem = addButton if let split = self.splitViewController { let controllers = split.viewControllers self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController } }
Our insertNewObject: function will display the form (that we haven’t created yet): // MARK: - Creation func insertNewObject(sender: AnyObject) { let createVC = CreateGistViewController(nibName: nil, bundle: nil) self.navigationController?.pushViewController(createVC, animated: true) }
Guess we’d better code up the CreateGistViewController form or that’s going to crash. Create a new file called CreateGistViewController.swift. Import the XLForm library and make your class a subclass of XLFormViewController:
Creating Gists and Clearing the Cache
202
import Foundation import XLForm class CreateGistViewController: XLFormViewController { }
Now we need to add our fields to the form. To keep it simple we’ll only allow adding a single file to a gist for now. Since we’re creating a custom UIViewController (which is a parent class to XLFormViewController) we need to provide a custom initializer: required init(coder aDecoder: NSCoder). All that it’ll do is call the parent init method and then our initializeForm function. We’ll also want to initialize our form if they create a form from a xib or storyboard file so we’ll override init(nibName nibNameOrNil: String!, bundle nibBundleOrNil: NSBundle!): class CreateGistViewController: XLFormViewController { required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.initializeForm() } override init(nibName nibNameOrNil: String!, bundle nibBundleOrNil: NSBundle!) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) self.initializeForm() } private func initializeForm() { ... } }
Now we can add the fields to the form in initializeForm. XLFormViewController knows what fields to display from a XLFormDescriptor object. So we’ll create one of those to describe our form then add sections and rows to it. Unlike a table view we can just add all the sections and rows when we create it. Each row has a few properties: its type and title as well as a tag. The tag is used to get access to the rows later. We can also specify whether a row is required:
Creating Gists and Clearing the Cache
203
private func initializeForm() { let form = XLFormDescriptor(title: "Gist") // Section 1 let section1 = XLFormSectionDescriptor.formSection() as XLFormSectionDescriptor form.addFormSection(section1) let descriptionRow = XLFormRowDescriptor(tag: "description", rowType: XLFormRowDescriptorTypeText, title: "Description") descriptionRow.required = true section1.addFormRow(descriptionRow) let isPublicRow = XLFormRowDescriptor(tag: "isPublic", rowType: XLFormRowDescriptorTypeBooleanSwitch, title: "Public?") isPublicRow.required = false section1.addFormRow(isPublicRow) let section2 = XLFormSectionDescriptor.formSectionWithTitle("File 1") as XLFormSectionDescriptor form.addFormSection(section2) let filenameRow = XLFormRowDescriptor(tag: "filename", rowType: XLFormRowDescriptorTypeText, title: "Filename") filenameRow.required = true section2.addFormRow(filenameRow) let fileContent = XLFormRowDescriptor(tag: "fileContent", rowType: XLFormRowDescriptorTypeTextView, title: "File Content") fileContent.required = true section2.addFormRow(fileContent) self.form = form }
We aren’t setting the isPublicRow as required since the user doesn’t actually have to tap the switch. They can just leave it as the default value and we’ll know how to handle that. We’ll also want a few buttons on this form: a Cancel button in the top left corner and a Save button in the top right corner. When they cancel we’ll just go back to the list of gists. We’ll have to implement saving after we’ve got the form all set up:
Creating Gists and Clearing the Cache
204
class CreateGistViewController: XLFormViewController { override func viewDidLoad() { super.viewDidLoad() self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: UIBarButtonSystemItem.Cancel, target: self, action: "cancelPressed:") self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: UIBarButtonSystemItem.Save, target: self, action: "savePressed:") } func cancelPressed(button: UIBarButtonItem) { self.navigationController?.popViewControllerAnimated(true) } func savePressed(button: UIBarButtonItem) { // TODO: implement } ... }
Add a form to your app to create a new object. We’ll add the validation and hook it up to the POST call in the next section.
If you save and run your app now you should be able to see the form come up when you tap on the + button on your list of gists. You can fill out the fields but tapping the save button won’t do anything. Let’s fix that. First we’ll use the built-in XLForm formValidationErrors() function to make sure they filled out all of the required fields. If validation does find some errors we’ll display them (again with a built-in function: showFormValidationError) and let them fix them: func savePressed(button: UIBarButtonItem) { let validationErrors = self.formValidationErrors() as? [NSError] if validationErrors?.count > 0 { self.showFormValidationError(validationErrors!.first) return }
If they don’t have any errors then we can turn off editing mode on the table view: self.tableView.endEditing(true)
And then we can pull the data out of the form. To get a value from the form we use the tag that we set earlier like this, with the relevant type (String or Bool for our form):
Creating Gists and Clearing the Cache
205
form.formRowWithTag("tagForRow")?.value as? Type
So let’s use that. First isPublic, which won’t have a value if they didn’t tap on the switch so we need to set it to false if that’s the case: let isPublic: Bool if let isPublicValue = form.formRowWithTag("isPublic")?.value as? Bool { isPublic = isPublicValue } else { isPublic = false }
Then the String properties. We can use a single if-let statement to get the values for all three text entry sections (which won’t be blank because the validation already checked for that). Then we can create a File object with itas properties: if let description = form.formRowWithTag("description")?.value as? String, filename = form.formRowWithTag("filename")?.value as? String, fileContent = form.formRowWithTag("fileContent")?.value as? String { var files = [File]() if let file = File(aName: filename, aContent: fileContent) { files.append(file) }
And finally we can make the API call using the user’s input. If it fails we’ll show them an error (ironically, GitHub is down while I’m writing this section so it’s easy to test). If it succeeds then we’ll return to the list of my gists: GitHubAPIManager.sharedInstance.createNewGist(description, isPublic: isPublic, files: files, completionHandler: { result in guard result.error == nil, let successValue = result.value where successValue == true else { if let error = result.error { print(error) } let alertController = UIAlertController(title: "Could not create gist", message: "Sorry, your gist couldn't be deleted. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) alertController.addAction(okAction)
Creating Gists and Clearing the Cache self.presentViewController(alertController, animated:true, completion: nil) return } self.navigationController?.popViewControllerAnimated(true) }) } }
Putting that all together: func savePressed(button: UIBarButtonItem) { let validationErrors = self.formValidationErrors() as? [NSError] if validationErrors?.count > 0 { self.showFormValidationError(validationErrors!.first) return } self.tableView.endEditing(true) let isPublic: Bool if let isPublicValue = form.formRowWithTag("isPublic")?.value as? Bool { isPublic = isPublicValue } else { isPublic = false } if let description = form.formRowWithTag("description")?.value as? String, filename = form.formRowWithTag("filename")?.value as? String, fileContent = form.formRowWithTag("fileContent")?.value as? String { var files = [File]() if let file = File(aName: filename, aContent: fileContent) { files.append(file) } GitHubAPIManager.sharedInstance.createNewGist(description, isPublic: isPublic, files: files, completionHandler: { result in guard result.error == nil, let successValue = result.value where successValue == true else { if let error = result.error { print(error) } let alertController = UIAlertController(title: "Could not create gist", message: "Sorry, your gist couldn't be deleted. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) alertController.addAction(okAction)
206
Creating Gists and Clearing the Cache
207
self.presentViewController(alertController, animated:true, completion: nil) return } self.navigationController?.popViewControllerAnimated(true) }) } }
Now that will work for creating gists. Save & run to test it out. Hook up your form with validation and the creation API call.
Save and run. Notice anything wrong? Our new gist isn’t getting added to the list of gists when we go back to the MasterViewController even though loadGists runs when we go back to that screen: override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) let defaults = NSUserDefaults.standardUserDefaults() if (!defaults.boolForKey("loadingOAuthToken")) { loadInitialData() } }
It looks like we’re getting a cached version of the response. We’ll need to bypass that by telling Alamofire that we explicitly don’t want the cached response. Under the hood Alamofire uses NSURLCache so we can create a simple method in GitHubAPIManager to clear the cache: func clearCache() { let cache = NSURLCache.sharedURLCache() cache.removeAllCachedResponses() }
And then if we succeed in creating a new gist we can clear the cache so it gets loaded correctly:
Creating Gists and Clearing the Cache
208
func createNewGist(description: String, isPublic: Bool, files: [File], completionHandler: (Bool?, NSError?) -> Void) { ... alamofireManager.request(GistRouter.Create(parameters)) .response { (request, response, data, error) in if let urlResponse = response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError)) return } if let error = error { print(error) completionHandler(.Success(false)) return } self.clearCache() completionHandler(.Success(true)) } }
15.3 And That’s All Save and run to make sure that the new gists are displayed in the list now right after they’re created. If you got tired of typing, here’s the code: (tagged “add”)². ²https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/add
16. What if They’re Offline? One of the simplest ways to get your app rejected during App Store review is to not handle a lack of internet connection. There are lots of ways that different apps handle being offline, it really depends on your data and what your users are trying to do. At a high level, a few options are: • Tell them they need an internet connection • Go into a read-only mode with whatever data you already have cached • Let them interact with the app as if they were online and sync up with the API when they reconnect If your app does end up without an internet connection it should keep checking if the connection has been restored. Don’t assume that once the connection is gone that it won’t come back. When writing a new app it’s often easiest to start with the simplest approach: tell them they need a connection (and make sure the app doesn’t crash). Then you can analyze your app and determine where it’s worth improving the offline experience.
16.1 How Do We Know? If we try and can’t get data then we’ll know that they’re offline. We should show the user an alert when that happens so they know that the data that they’re seeing isn’t live. We’ll add that functionality to getGists in the GitHubAPIManager first. Here’s what getGists looks like now: func getGists(urlRequest: URLRequestConvertible, completionHandler: (Result<[Gist], NSError>, String?) -> Void) { alamofireManager.request(urlRequest) .validate() .responseArray { (response:Response<[Gist], NSError>) in if let urlResponse = response.response, authError = self.checkUnauthorized(urlResponse) { completionHandler(.Failure(authError), nil) return } guard response.result.error == nil, let gists = response.result.value else { print(response.result.error) completionHandler(response.result, nil) return
209
What if They’re Offline?
210
} // need to figure out if this is the last page // check the link header, if present let next = self.getNextPageFromHeaders(response.response) completionHandler(.Success(gists), next) } }
Try launching the app then turning off your internet connection to see what kind of error you get when you pull to refresh: {Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline." UserInfo={NSUnderlyingError=0x7fc8fb403940 {Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)" UserInfo={_kCFStreamErrorCodeKey=8, _kCFStreamErrorDomainKey=12}}, NSErrorFailingURLStringKey=https://api.github.com/gists/public, NSErrorFailingURLKey=https://api.github.com/gists/public, _kCFStreamErrorDomainKey=12, _kCFStreamErrorCodeKey=8, NSLocalizedDescription=The Internet connection appears to be offline.}
Looks like we’ve got an NSURLErrorDomain error with code -1009, which translates to NSURLErrorNotConnectedToInternet. So we can check for that error when we call getGists and show the user a warning that they’re not online. An alert view would be pretty intrusive in that case so we’ll pull in another CocoaPod that has a nicer error display. Add BRYXBanner v0.4.1 to your project using CocoaPods and import it in the MasterViewController: import import import import
UIKit Alamofire PINRemoteImage BRYXBanner
class MasterViewController: UITableViewController, LoginViewDelegate { ... }
Here’s how we’re currently handling errors in the loadGists:
What if They’re Offline?
211
if let error = result.error { if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } }
We need to add handling NSURLErrorNotConnectedToInternet errors as well as NSURLErrorUserAuthenticationRequired errors: if error.domain == NSURLErrorDomain { if error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } else if error.code == NSURLErrorNotConnectedToInternet { ... } }
When that happens we’ll show a banner telling them that they need an internet connection. If a banner is already showing we’ll have to hide it before showing a new one. To keep track of whether the banner is showing we’ll need to save it as a variable: ... import BRYXBanner class MasterViewController: UITableViewController, LoginViewDelegate { var var var var var var
detailViewController: DetailViewController? = nil gists = [Gist]() nextPageURLString: String? isLoading = false dateFormatter = NSDateFormatter() notConnectedBanner: Banner?
... }
To show the banner:
What if They’re Offline? guard result.error == nil else { print(result.error) self.nextPageURLString = nil self.isLoading = false if let error = result.error { if error.domain == NSURLErrorDomain { if error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } else if error.code == NSURLErrorNotConnectedToInternet { // show not connected error & tell em to try again when they do have a connection // check for existing banner if let existingBanner = self.notConnectedBanner { existingBanner.dismiss() } self.notConnectedBanner = Banner(title: "No Internet Connection", subtitle: "Could not load gists." + " Try again when you're connected to the internet", image: nil, backgroundColor: UIColor.redColor()) } self.notConnectedBanner?.dismissesOnSwipe = true self.notConnectedBanner?.show(duration: nil) } } return }
Here’s what that’ll look like when it’s shown:
212
213
What if They’re Offline?
No Internet Banner
Let’s check our other API calls to make sure they’re also handling a lack of internet connection properly. First creating new gists: GitHubAPIManager.sharedInstance.createNewGist(description, isPublic: isPublic, files: files, completionHandler: { result in guard result.error == nil, let successValue = result.value where successValue == true else { if let error = result.error { print(error) } let alertController = UIAlertController(title: "Could not create gist", message: "Sorry, your gist couldn't be deleted. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) alertController.addAction(okAction) self.presentViewController(alertController, animated:true, completion: nil) return } self.navigationController?.popViewControllerAnimated(true) })
If we wanted to we could specifically check the domain and code for that error. But we won’t bother since we don’t have a different message that we could show for other domains and codes. At this
What if They’re Offline?
214
point the user probably needs to be alerted to the failure of the delete call so a UIAlertController is appropriate. You could change it to a banner if you prefer that look but it’s fine as it is. GitHubAPIManager.sharedInstance.deleteGist(id, completionHandler: { (error) in print(error) if let _ = error { // Put it back self.gists.insert(gistToDelete, atIndex: indexPath.row) tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Right) // tell them it didn't work let alertController = UIAlertController(title: "Could not delete gist", message: "Sorry, your gist couldn't be deleted. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) alertController.addAction(okAction) // show the alert self.presentViewController(alertController, animated:true, completion: nil) } })
Deleting is similar to creating: it makes sense to interrupt the user’s actions to tell them that it failed. What about getting the starred status: GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { result in if let error = result.error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could load starred status", message: error.description, preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } } if let status = result.value where self.isStarred == nil { // just got it self.isStarred = status
What if They’re Offline?
215
self.tableView?.insertRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } })
If there isn’t an internet connection then we’ll get an error printed to the console but the user won’t get told anything. We should probably let the user know that it’s happening. We’ll use an orange banner instead of a red one since it isn’t a critical error. First import BRYXBanner in the DetailViewController and add a variable for the banner: import UIKit import WebKit import BRYXBanner class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! var isStarred: Bool? var alertController: UIAlertController? var notConnectedBanner: Banner? ... }
And add creating the banner if there’s no internet connection: func fetchStarredStatus() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { result in if let error = result.error { print(error) if error.domain == NSURLErrorDomain { if error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not get starred status", message: error.description, preferredStyle: .Alert) // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } else if error.code == NSURLErrorNotConnectedToInternet { // show not connected error & tell em to try again when they do have a conne\
What if They’re Offline?
216
ction // check for existing banner if let existingBanner = self.notConnectedBanner { existingBanner.dismiss() } self.notConnectedBanner = Banner(title: "No Internet Connection", subtitle: "Can not display starred status. " + "Try again when you're connected to the internet", image: nil, backgroundColor: UIColor.orangeColor()) self.notConnectedBanner?.dismissesOnSwipe = true self.notConnectedBanner?.show(duration: nil) } } } if let status = result.value where self.isStarred == nil { // just got it self.isStarred = status self.tableView?.insertRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
We also have 2 more web service calls to star and unstar gists. We opted to display alerts for those if there’s an error since they’re actions that the user specifically requested to happen: func starThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.starGist(gistId, completionHandler: { (error) in if let error = error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not star gist", message: error.description, preferredStyle: .Alert) } else { self.alertController = UIAlertController(title: "Could not star gist", message: "Sorry, your gist couldn't be starred. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) }
What if They’re Offline? // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } else { self.isStarred = true self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
func unstarThisGist() { if let gistId = gist?.id { GitHubAPIManager.sharedInstance.unstarGist(gistId, completionHandler: { (error) in if let error = error { print(error) if error.domain == NSURLErrorDomain && error.code == NSURLErrorUserAuthenticationRequired { self.alertController = UIAlertController(title: "Could not unstar gist", message: error.description, preferredStyle: .Alert) } else { self.alertController = UIAlertController(title: "Could not unstar gist", message: "Sorry, your gist couldn't be unstarred. " + "Maybe GitHub is down or you don't have an internet connection.", preferredStyle: .Alert) } // add ok button let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) self.alertController?.addAction(okAction) self.presentViewController(self.alertController!, animated:true, completion: nil) } else { self.isStarred = false self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 2, inSection: 0)], withRowAnimation: .Automatic) } }) } }
217
What if They’re Offline?
218
Now we can play with the app a bit and see if we find any bugs. Try turning your internet connection on and off to find any issues. I found two: First, if there’s a red banner showing then we select a gist we’ll see the orange banner. But if we dismiss the orange banner then the red one is still shown. We should dismiss the banner when we change views: override func viewWillDisappear(animated: Bool) { if let existingBanner = self.notConnectedBanner { existingBanner.dismiss() } super.viewWillDisappear(animated) }
We’ll need to add that code to both the MasterViewController and the DetailViewController. The second issue happens if we lose our internet connection and we switch lists of gists. Then we can end up with the wrong list showing. To fix that we’ll clear out the list of gists when they select a different list in the segmented control: @IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { // only show add/edit buttons for my gists ... // clear gists so they can't get shown for the wrong list self.gists = [Gist]() self.tableView.reloadData() loadGists(nil) }
There’s just one more bit of internet dependent functionality in our app: logging in with OAuth 2.0. To test that feature you’ll have to reset the simulator using Simulator -> Reset Content and Services or uninstall the app from your device to simulate the initial run of the app. When we test that feature out we’ll find that the Login view controller just keeps popping up when we tap the login button. That’s a pretty awful experience for the user, especially if this is the very first time they’ve run the app. Our code is detecting the lack of internet connection correctly using the SFSafariViewControllerDelegate method:
What if They’re Offline? func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { // Detect not being able to load the OAuth URL if (!didLoadSuccessfully) { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: [NSLocalizedDescriptionKey: "No Internet Connection", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(error) } controller.dismissViewControllerAnimated(true, completion: nil) } }
But the completion handler is set up to just try again no matter what the error is: GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) if let error = error { print(error) self.isLoading = false // TODO: handle error // Something went wrong, try again self.showOAuthLoginView() } else { self.loadGists(nil) } }
Let’s improve that using our banners:
219
What if They’re Offline?
220
GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) if let error = error { print(error) self.isLoading = false if error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet { // show not connected error & tell em to try again when they do have a connection // check for existing banner if let existingBanner = self.notConnectedBanner { existingBanner.dismiss() } self.notConnectedBanner = Banner(title: "No Internet Connection", subtitle: "Could not load gists. Try again when you're connected to the internet", image: nil, backgroundColor: UIColor.redColor()) self.notConnectedBanner?.dismissesOnSwipe = true self.notConnectedBanner?.show(duration: nil) } else { // Something went wrong, try again self.showOAuthLoginView() } } else { self.loadGists(nil) } }
If we test that we’ll find the Login view controller is still getting popped up because it’s set to always show up when we show the Master view controller but aren’t logged in. The easy way to fix that is to leave the app thinking it’s still loading an OAuth token when we don’t have an internet connection: func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { // Detect not being able to load the OAuth URL if (!didLoadSuccessfully) { let defaults = NSUserDefaults.standardUserDefaults() defaults.setBool(false, forKey: "loadingOAuthToken") if let completionHandler = GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: [ NSLocalizedDescriptionKey: "No Internet Connection", NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"]) completionHandler(error) } controller.dismissViewControllerAnimated(true, completion: nil)
What if They’re Offline?
221
} }
Now the app won’t pop up the Login view controller again until we pull to refresh. Analyze and test your app for web service calls that could fail. Make sure each one is handled with an experience that will be acceptable to the user and that they’ll understand what’s happening. Add banners and alerts as appropriate.
We’ve handled all of the issues with lack of network connection so Apple won’t be rejecting our app for that. But what if we wanted to provide a better experience, by letting users look at the gists they previously loaded while they’re offline? We’ll do that next. If you got tired of typing, here’s the code: (tagged “noInternet”)¹.
16.2 Save a Local Copy For a simple app like ours it’s probably good enough to show them that they’re offline and display the latest data that they loaded in a read-only mode. So we’d need to persist the lists of gists. Without an internet connection they wouldn’t be able to delete gists or star/unstar gists or see whether they previously starred a gist. But we already handled those cases earlier in this chapter. NSKeyedArchiver can be used to easily serialize objects so they can be written to disk. It works out
of the box for types like arrays and strings but we’ll have to let it know how to deal with our custom classes. The two classes that we’ll need to add that support to are Gist and File. To support that archiving our classes need to conform to the NSCoding protocol, which in turn requires the NSObject protocol: class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { ... }
The NSObject protocol requires that we change the declaration of the existing init function to: required override init() { }
It also includes a description property so we’ll change ours to gistDescription: ¹https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/noInternet
What if They’re Offline?
222
class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { var id: String? var gistDescription: String? ... required init(json: JSON) { self.gistDescription = json["description"].string ... } ... }
And we’ll have to change the other view controllers to use gistDescription. The easiest way to find what needs to be changed is to search for .description. In the MasterViewController: cell.textLabel!.text = gist.gistDescription
And in the DetailViewController: func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) if indexPath.section == 0 { if indexPath.row == 0 { cell.textLabel?.text = gist?.gistDescription
The NSCoding protocol requires 2 functions, one to encode the object and the other to create an object by decoding it: class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { ... // MARK: NSCoding @objc func encodeWithCoder(aCoder: NSCoder) { ... } @objc required convenience init?(coder aDecoder: NSCoder) { self.init()
What if They’re Offline?
223
... } }
We need to include each property in those functions using the functions available on NSCoder to encode and decode each property: @objc func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(self.id, forKey: "id") aCoder.encodeObject(self.gistDescription, forKey: "gistDescription") aCoder.encodeObject(self.ownerLogin, forKey: "ownerLogin") aCoder.encodeObject(self.ownerAvatarURL, forKey: "ownerAvatarURL") aCoder.encodeObject(self.url, forKey: "url") aCoder.encodeObject(self.createdAt, forKey: "createdAt") aCoder.encodeObject(self.updatedAt, forKey: "updatedAt") if let files = self.files { aCoder.encodeObject(files, forKey: "files") } } @objc required convenience init?(coder aDecoder: NSCoder) { self.init() self.id = aDecoder.decodeObjectForKey("id") as? String self.gistDescription = aDecoder.decodeObjectForKey("gistDescription") as? String self.ownerLogin = aDecoder.decodeObjectForKey("ownerLogin") as? String self.ownerAvatarURL = aDecoder.decodeObjectForKey("ownerAvatarURL") as? String self.createdAt = aDecoder.decodeObjectForKey("createdAt") as? NSDate self.updatedAt = aDecoder.decodeObjectForKey("updatedAt") as? NSDate if let files = aDecoder.decodeObjectForKey("files") as? [File] { self.files = files } }
And the same for File:
What if They’re Offline? class var var var
224
File: NSObject, NSCoding, ResponseJSONObjectSerializable { filename: String? raw_url: String? content: String?
... // MARK: NSCoding @objc func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(self.filename, forKey: "filename") aCoder.encodeObject(self.raw_url, forKey: "raw_url") aCoder.encodeObject(self.content, forKey: "content") } @objc required convenience init?(coder aDecoder: NSCoder) { let filename = aDecoder.decodeObjectForKey("filename") as? String let content = aDecoder.decodeObjectForKey("content") as? String // use the existing init function self.init(aName: filename, aContent: content) self.raw_url = aDecoder.decodeObjectForKey("raw_url") as? String } }
Now we can save the gists but we need to implement actually doing so. Create a new PersistenceManager.swift file that will be responsible for handling the saving and loading. We’ll keep it generic by set up saving and loading arrays instead of specifying that they’re gists: import Foundation class PersistenceManager { class func saveArray(arrayToSave: [T], path: Path) { // TODO: implement } class func loadArray(path: Path) -> [T]? { // TODO: implement } }
We’ll need a spot to save the gists. There’s a documents directory that will work fine:
What if They’re Offline?
225
class PersistenceManager { class private func documentsDirectory() -> NSString { let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true) let documentDirectory = paths[0] as String return documentDirectory } ... }
We’ll need to specify a different place to save each list of gists so they don’t overwrite each other. Let’s use an enum for that. We could add more items later if our app needed to save other objects: enum Path: String { case Public = "Public" case Starred = "Starred" case MyGists = "MyGists" } class PersistenceManager { ... }
Ok, now let’s implement saving. We’ll get the path to save it to then use NSKeyedArchiver.archiveRootObject to save the array to that path: class func saveArray(arrayToSave: [T], path: Path) { let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) NSKeyedArchiver.archiveRootObject(arrayToSave, toFile: file) }
Loading the array is pretty similar: class func loadArray(path: Path) -> [T]? { let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) let result = NSKeyedUnarchiver.unarchiveObjectWithFile(file) return result as? [T] }
So all together here’s our PersistenceManager:
What if They’re Offline?
226
import Foundation enum Path: String { case Public = "Public" case Starred = "Starred" case MyGists = "MyGists" } class PersistenceManager { class private func documentsDirectory() -> NSString { let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true) let documentDirectory = paths[0] as String return documentDirectory } class func saveArray(arrayToSave: [T], path: Path) { let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) NSKeyedArchiver.archiveRootObject(arrayToSave, toFile: file) } class func loadArray(path: Path) -> [T]? { let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) let result = NSKeyedUnarchiver.unarchiveObjectWithFile(file) return result as? [T] } }
When should we save the gists? As soon as they’re loaded makes sense to me. We’ll add a call to PersistenceManager.saveArray in loadGists after getting the correct path: if let fetchedGists = result.value { if let _ = urlToLoad { self.gists += fetchedGists } else { self.gists = fetchedGists } let path:Path if self.gistSegmentedControl.selectedSegmentIndex == 0 { path = .Public } else if self.gistSegmentedControl.selectedSegmentIndex == 1 { path = .Starred } else { path = .MyGists }
What if They’re Offline?
227
PersistenceManager.saveArray(self.gists, path: path) }
And then we can load them if we don’t have an internet connection when we show the banner: if error.code == NSURLErrorUserAuthenticationRequired { self.showOAuthLoginView() } else if error.code == NSURLErrorNotConnectedToInternet { let path:Path if self.gistSegmentedControl.selectedSegmentIndex == 0 { path = .Public } else if self.gistSegmentedControl.selectedSegmentIndex == 1 { path = .Starred } else { path = .MyGists } if let archived:[Gist] = PersistenceManager.loadArray(path) { self.gists = archived } else { self.gists = [] // don't have any saved gists } // show not connected error & tell em to try again when they do have a connection ... }
Save and run. After you’ve loaded some gists, turn the internet off and make sure you can still see them. Relaunch the app with the internet still off and they should still show up with the red banner shown. See if there are any parts of your app that make sense for read-only offline support. If so, use NSKeyedArchiver to save and load those items to the device when you get them so users can see them offline.
If you got tired of typing, here’s the code: (tagged “persistent”)².
16.3 Databases If your app is more complex then you’ll probably want a real database. There are whole books written on iOS databases. Along with keeping your data in a database comes syncing issues. It’s not ²https://github.com/cmoulton/grokSwiftREST_v1.1/releases/tag/persistent
What if They’re Offline?
228
too bad if the data is only accessed by a single user but if multiple users can modify objects then you’ll have to deal with conflicts between their changes when one of them didn’t have the latest version. Databases don’t inherently fix the issues that come with not having an internet connection. But they do make it easier to handle complicated relationships between objects and large amounts of data. If you need to go down this path take a look at Core Data. It’s built-in to iOS and does a lot more than simple database functions. You will need to monitor the internet connection and sync up when you can. Realm³ is becoming popular as an alternative. If you want to you can use SQLite (which is actually what Core Data uses under the hood). If you’re building your whole app from scratch, including the back end, you might want to consider services like Parse⁴ or Kinvey⁵. They offer SDKs that include features to manage offline use. Consider just how rich your app’s offline experience needs to be. Consider using a database if users should be able to perform lots of tasks offline and sync up with the back-end later.
³https://realm.io ⁴http://blog.parse.com/learn/parse-local-datastore-for-ios/ ⁵http://devcenter.kinvey.com/ios/guides/caching-offline
17. What Next? We’ve built a working prototype of a REST API-backed iOS app in Swift. Before it’s ready for the App Store it’ll need a few things. Each of these items merits a book on its own so you should seek other sources to make sure you’ve covered them adequately before submitting your app:
17.1 User Interface We’ve been neglecting the user interface throughout this book. Before releasing at least read through the Apple iOS Human Interface Guidelines¹. Not following the HIG is cause for an App Store rejection. Beyond the HIG you’ll probably want to make the app a lot prettier too. If you don’t have a designer check out the numerous UIKits available online to get away from completely standard styles. Don’t go too far from the norm though, users want something familiar enough that they know how to interact with it.
17.2 Test the User Experience Give it to someone, ask them to do one of the tasks and silently watch them try. If they can’t easily figure it out then you should strongly consider redesigning the app to make that task more obvious. Try this test with a few people, preferably not your friends that you’ve already told all about the app.
17.3 Suggested Exercises If you want to keep working on the demo app here are a few ideas. Read the GitHub Gists API docs² for details: • Make it look great • Make the file display a lot better. You can use the content property but you’ll need to handle truncation since you’ll only get the first MB of data for each file • Let the user navigate to the GitHub web page from a gist or file in the app ¹https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/ ²https://developer.github.com/v3/
229
What Next?
230
• Add filters to let users search the gists. Unfortunately there isn’t any support for searching in the API but you can let users filter or search the gists that are loaded to their device • Add an edit mode to the list of starred & public gists to star/unstar multiple gists at a time • Extend the gist creation form to allow multiple files • Let users edit their existing gists • Build out user profiles so users can view details about the writers of the gists
17.4 Did I Miss Anything? One of the best things about ebooks is that cats can’t shred them. Another is that they can be updated, just like this one was when Alamofire v3 came out. So while the book is fresh in your mind email me right now at [email protected]³ and tell me one way it could be better. If you’re looking for more Swift tutorials, I write them about once a week at Grok Swift⁴.
³mailto:[email protected] ⁴https://grokswift.com
A Brief Introduction to CocoaPods If you’re not familiar with CocoaPods, it’s worth taking a few minutes to learn about the lovely dependency manager commonly used for iOS libraries today. Cocoapods is great for adding libraries to your iOS projects, in Objective-C and Swift. In fact, it’s easy to use Objective-C code in iOS Swift projects. If you’re curious, check out Objective-C in Swift Project⁵. We’ll just cover the simple basics that we’ll use throughout this book so that when I say “add the Alamofire v3.1 CocoaPod to your project” we don’t need to spend a few paragraphs detailing how to do that.
Adding a CocoaPod to a Project Let’s say we’re going to add the SwiftyJSON CocoaPod to an Xcode project. Here’s what we need to do: Close Xcode Open the terminal in the project top directory (the directory with the .xcodeproj file for your project). If you haven’t previously installed CocoaPods run: sudo gem install cocoapods
If you need more info on installing CocoaPods, check out their Getting Started guide⁶. Once it’s done installing CocoaPods, you need to initialize Cocoapods for your project. So run: pod init
That will create a new file called “Podfile” (to be honest, I think that’s all it does). Using a text editor open the newly created Podfile and replace the contents with:
⁵https://grokswift.com/objective-c-in-swift/ ⁶https://guides.cocoapods.org/using/getting-started.html
231
A Brief Introduction to CocoaPods
232
source 'https://github.com/CocoaPods/Specs.git' platform :ios, '9.0' use_frameworks! pod 'SwiftyJSON'
Save the Podfile then switch back to Terminal and run: pod install
Open the .xcworkspace file in Xcode. Navigate back to whatever class you want to use SwiftyJSON in and add “import SwiftyJSON” at the top like this: import Foundation import SwiftyJSON class MyClass { ... }
Now you can use SwiftyJSON in that file.
What Does the Podfile Mean? While it’s nice to just get instructions that work, it’s usually a good idea to know why they work. So let’s take a look at that Podfile: source 'https://github.com/CocoaPods/Specs.git' platform :ios, '9.0' use_frameworks! pod 'SwiftyJSON'
When you run pod install CocoaPods looks for a Podfile and tries to install the pods listed in it. Install in this case means “download and add to the Xcode project”. A pod is generally a library, really just a chunk of code that you want to use in your project. Let’s go through it line by line: source 'https://github.com/CocoaPods/Specs.git'
The first line tells CocoaPods where on the internet to find pods. We’re using the default public CocoaPods repository which is a great place to check for libraries for your projects.
A Brief Introduction to CocoaPods
233
platform :ios, '9.0'
The second line specifies that we’re working on an app for iOS (not OS X) and we’re building an app for the iOS 9.0 SDK. Including this info in a Podfile means that pods can have different version for iOS and OS X as well as for different versions of the iOS SDK. use_frameworks!
use_frameworks! tells CocoaPods how we want to integrate the code libraries with our project. In
Swift we want it to wrap up the code in a framework then include the framework in our project. Since CocoaPods pre-dates Swift, that’s not the default so we have to include this line. Want more details? See the release notes for CocoaPods v0.36⁷. pod 'SwiftyJSON'
And finally we specify which pod (or pods) we want to install.
Other Options You can do some neat stuff with CocoaPods including adding different code to testing and App Store versions of your app or making private CocoaPods for use within a team. If you’re curious, check out Creating and Using CocoaPods by Jeffrey Sambells⁸.
Dependencies The real time saver in CocoaPods is that pods can specify dependencies. So if SwiftyJSON required some other library then CocoaPods would make sure we have it in our Pods before downloading SwiftyJSON and adding it to our project. It’ll also make sure that we have the correct compatible version. So we don’t need to hunt down and install a bunch of prerequisites before installing a pod.
CocoaPods Version Numbers One option that we’ll use in CocoaPods is to specify which version of a pod we want. We can specify an exact number or a less specific number. For example to use v2.3.0:
⁷http://blog.cocoapods.org/CocoaPods-0.36/ ⁸http://jeffreysambells.com/talks/2014/01/23/using-and-creating-cocoapods
A Brief Introduction to CocoaPods
234
pod 'SwiftyJSON', '2.3.0'
We could also say that we want to use SwiftyJSON v2.3.whatever to get small updates: pod 'SwiftyJSON', '~> 2.3.0'
Which would allow v2.3.0, 2.3.1, 2.3.2, … but not v2.4. Or even v2.whatever: pod 'SwiftyJSON', '~> 2.3'
Which would allow v2.3, 2.4, … but not v3.0. If we leave off the version number then CocoaPods will just install the latest version.
Updating CocoaPods Unless you tell it to, CocoaPods won’t auto-update to newer versions. To tell CocoaPods that you do want newer versions (if your version numbers will allow it) run: pod update
You’ll see a message in the terminal showing you which pods were updated and to which versions. You’ll also need to run that command if you change the version numbers in the Podfile or add more pods to it.