(Swift) iOS Apps with REST APIs(
本篇及上两篇都是讲解使用OAuth2.0进行认证的,篇幅比较长,顺便你的有点OAuth2.0的基础知识。
重要说明: 这是一个系列教程,非本人原创,而是翻译国外的一个教程。本人也在学习Swift,看到这个教程对开发一个实际的APP非常有帮助,所以翻译共享给大家。原教程非常长,我会陆续翻译并发布,欢迎交流与分享。
显示查询结果
那么该如何在表视图中显示这些Gists呢?之前我们已经能够在表视图中显示公共的Gists列表,所以这里我们只需要切换它,用来显示我们收藏的Gists列表即可。
这是我们之前用来获取公共Gists的代码(还包含了分页、下拉刷新等功能):
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)
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)
}
}
下面我们需要添加一个名称为getMyStarredGists
的函数:
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)
}
}
现在我们就可以在loadInitialData
中使用它们中间的一个了,就像我们之前使用getPublicGists
一样。但这里还有一个问题,就是我们在获取OAuth访问令牌时会发生什么?我们可不想在获取OAuth访问令牌的时候整个APP傻傻的不能做任何操作,因此我们这里需要使用异步加载来实现。但,我们怎么知道什么时候搞定了呢?
通常我们可以使用一个完成处理程序,就像我们在getGists
中做的一样。只需要在调用的方法后面增加一个块来响应即可。但,这里我们是通过自定义处理URL方案来做的,也就是说这个动作不是我们直接发起的。这是真真正正的异步。在这里我们要把原本要给startOAuth2Login
的回调函数(当我们启动的Safari后就会被遗忘),而是将回调函数赋给GitHubAPIManager
。这样GitHubAPIManager
就好hold住这段代码,直到获取了一个OAuth访问令牌。
代码看起来如下:
GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = {
// code that we want to execute when we get an OAuth token
}
更具体一点来说就是,这里我们将检查错误,如果没有任何错误我们将获取相应的数据:
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)
}
}
当然,只有当我们在GitHubAPIManager
类中添加了相应的变量后才能工作:
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)?
...
}
这样设置后,一旦我们获取了OAuth访问令牌,就可以在loadInitialData()
中加载数据了:
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)
}
}
对于分页和下拉刷新我们使用loadGists
函数来实现了:
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 {
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()
}
}
我们还需要使用getMyStarredGists
替换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()
}
}
我们还要将下拉刷新的代码替换为loadInitialData
,这样当身份认证出现问题的时候可以尝试下拉刷新来解决:
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()
}
现在我们已经实现了loadGists
,那么下面让我们来解决OAuth认证过程,以便当得到一个OAuth访问令牌后就可以启动完成处理程序了。否则用户必须通过下拉刷新才能够加载Gists列表。
我们需要对OAuth 2.0登录过程中每一个可能出现错误的点进行处理,以便最终我们能以得到访问令牌。
从didTapLoginButton
开始,如果验证URL无效会出现一个错误。这大概有两种可能:一是NSURL
初始化器无法创建authURL
,二是网页根本没有加载成功。下面来让我们处理这两种情况(在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:
"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: SURLErrorNotConnectedToInternet,
userInfo: [NSLocalizedDescriptionKey: "No Internet Connection",
NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
completionHandler(error)
}
controller.dismissViewControllerAnimated(true, completion: nil)
}
}
在这两种情况下,一旦URL出现问题,我们将调用完成处理函数,并将loadingAuthToken
的状态设置为false
。
我们还需要为第一种情况定义一个用户自定义错误域:
class GitHubAPIManager {
...
static let ErrorDomain = "com.error.GitHubAPIManager"
...
}
接下来是AppDelegate
中的handleOpenURL
:
func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool {
GitHubAPIManager.sharedInstance.processOAuthStep1Response(url)
return true
}
这个怎么看都好像不会发生什么错误。
那继续看下面的processOAuthStep1Response
:
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)
}
}
如果该函数在返回的数据中找不到code
参数,这时候应该通知完成处理函数调用失败。我们为此将自定义一个错误:
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)
}
}
}
Ok,在OAuth认证流程中还有一个步骤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()
}
}
}
这里我们有成功和失败两种情况。当前当我们获得OAuth访问令牌,我们获取并打印收藏的Gists列表。现在我们将替换为调用完成处理函数,当成功时可以加载Gists列表并显示。如果有错误我们会知道。
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)
}
}
这里还有一个错误路径需要处理。就是我们在构建URL请求后,我们检查了是否有错误,如果有我们将返回一个错误。这里我们也得调用完成处理函数:
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
}
...
下面就是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")
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 {
let noOAuthError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1,
userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth token",
NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
completionHandler(noOAuthError)
}
}
}
}
嗯,到这里为止,我们已经将这个超级异步的OAuthTokenCompletionHandler
和我们的表格视图及GitHubAPIManager
整合起来。并可以在表格视图中显示通过OAuth 2.0 API
所获取的数据。
保存并测试它。尝试在你的GitHub账户中撤销OAuth的访问,然后运行。如果不是第一次运行那么需要删除该应用重新来。
完成你的认证API与表视图的整合。
这里有完成的代码:oauth。
刷新访问令牌
有些OAuth实现会在获取访问令牌的时候,给出一个过期时间及一个refresh token
,这样你就可以在一段时间后获取一个新的访问令牌(还需要你的client ID
和client secret
)。但GitHub没有这么做。如果你是使用另外的一个OAuth API那么你首先得查询开发文档看是否返回了refresh token
。如果是,你必须保存它以便后面使用。
更新访问令牌和我们使用码来换取访问令牌差不多一样,就是发起一个GET请求,并提供refresh token
和你的client ID
及client secret
。
刷新令牌的一个优点就是,访问令牌只能在一个时间段内使用。refresh token
在没有client ID
和'client secret'时是没有任何用的。所以最好能安全保存。但在本章节中我们并没有构建一个web服务器来存储这些值。
检查你的API文档看是否需要刷新令牌。如果是把它像保存OAuth访问令牌一样保存到
Keychain
中。然后再添加一个方法来检查是否已经过期,就像我们检查未认证方法一样。当你需要刷新令牌时,你需要像之前通过码来获取OAuth访问令牌一样进行处理。
未认证响应:404 vs 401
如果用户撤销了我们的访问权限会怎么样?我们必须重新进行OAuth认证。那假如我们的OAuth访问令牌是无效的呢?这个比较容易进行测试,你只需要在这里撤销就可以了。
当我们下次运行APP的时候将会引发麻烦。因为,我们认为我们所持有的OAuth访问令牌是有效的,但其实不是,因此,我们会调用失败。并会得到一个错误:
Error Domain=com.alamofire.error Code=-1 "The operation couldn't be completed. (com.alamofire.error error -1.)"
但当我们检查我们请求所返回的响应,会发现HTTP状态码是401 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
}
该方法有一个NSHTTPURLResponse
参数,这样我们可以在我们的响应序列化器中访问,并可以检查HTTP的状态码。如果是401 Unauthorized
,我们将产生一个错误并返回。否则返回nil
。既然在NSURLErrorDomain
中已经定义了用户认证错误代码:NSURLErrorUserAuthenticationRequired
,我们这里就使用它好了。
然后,我们就可以在Alamofire的响应系列化器中使用了:
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
}
...
}
}
我们使用'urlResponse = response.response'从Alamofire的Response
获取一个NSHTTPURLResponse
。然后调用我们刚才定义的方法checkUnauthorized
,如果检测到错误我们立刻返回。
我们还需要在加载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
}
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()
}
}
可以进行测试了。首先打开APP并等待加载Gists列表完毕。然后到GitHub上取消授权,并下拉刷新。这时候App将带你去GitHub重新进行授权。
小结
在本章我们讲解了并添加基础认证、基于报头认证及OAuth 2.0等认证方法。这样当我们进行更多的API调用时就可以复用这些代码,然后只需要关心怎么进行错误处理就可以了。
你可以在这里auth下载本章代码。
现在我们已经搞定认证了,下面就可以增进更多的API调用了。下一章我们将讲解用户怎么样在表格视图中切换显示这三个列表。