The client is a Master Detail application called RepositoryBrowser
. This sets up a template that can be used on a large device with a split view controller, or a navigator view controller on a small device. In addition, actions to add entries are created.
To build the APIs necessary to display content, several utility classes are needed:
URITemplate
class processes URI templates with a set of key/value pairsThreads
class allows functions to be run in the background or in the main threadNSURLExtensions
class provides easy parsing JSON objects from a URLDictionaryExtensions
class provides a means of creating a Swift dictionary from a JSON objectGitHubAPI
class provides access to the GitHub remote APIURI templates are defined in RFC 6570 at https://tools.ietf.org/html/rfc6570. They can be used to replace sequences of text surrounded by {}
in a URI. Although GitHub's API uses optional values {?...}
, the example client presented in this chapter will not need to use these, and so they can be ignored in this implementation.
The template class replaces the parameters with values from a dictionary. To create the API, it is useful to write a test case first, following test-driven development. A test case class can be created by navigating to File | New | File... | iOS | Source | Test Case Class and creating a subclass of XCTestCase
in Swift. The test code will look like:
import XCTest class URITemplateTests: XCTestCase { func testURITemplate() { let template = "http://example.com/{blah}/blah/{?blah}" let replacement = URITemplate.replace( template,values: ["blah":"foo"]) XCTAssertEqual("http://example.com/foo/blah/", replacement,"Template replacement") } }
The replace
function requires string processing. Although the function could be a class function or an extension on String
, having it as a separate class makes testing easier. The function signature looks like:
import Foundation class URITemplate { class func replace(template:String, values:[String:String]) -> String { var replacement = template while true { // replace until no more {…} are present } return replacement } }
The parameters are matched using a regular expression such as {[^}]*}
. To search or access this from a string involves a Range
of String.Index
values. These are like integer indexes into the string, but instead of referring to a character by its byte offset, the index is an abstract representation. (Some character encodings such as UTF-8 use multiple bytes to represent a single character.)
The rangeOfString
method takes a string or regular expression and returns a range if there is a match present (or nil
if there isn't). This can be used to detect whether a pattern is present, or to break out of the while
loop:
// replace until no more {…} are present if let parameterRange = replacement.rangeOfString( "\{[^}]*\}", options: NSStringCompareOptions.RegularExpressionSearch) { // perform a replacement of parameterRange } else { break }
The parameterRange
contains a start
and end
index that represent the locations of the {
and }
characters. The value of the parameter can be extracted with replacement.substringWithRange(parameterRange)
. If it starts with {?
, it is replaced with an empty string:
// perform a replacement of parameterRange var value:String let parameter = replacement.substringWithRange(parameterRange) if parameter.hasPrefix("{?") { value = "" } else { // substitute with real replacement } replacement.replaceRange(parameterRange, with: value)
Finally, if the replacement is of the form {user}
, then the value of user
is acquired from the dictionary and used as the replacement value. To get the name of the parameter, startIndex
has to be advanced to the successor
, and endIndex
has to be reversed to the predecessor
to account for the {
and }
characters:
// substitute with real replacement let start = parameterRange.startIndex.successor() let end = parameterRange.endIndex.predecessor() let name = replacement.substringWithRange( Range<String.Index>(start:start,end:end)) value = values[name] ?? ""
Now, when the test is run by navigating to Product | Test or by pressing Command + U, the string replacement will pass.
Background threading allows functions to be trivially launched on the UI thread or on a background thread, as appropriate. This was explained in Chapter 6, Parsing Networked Data, in the Networking and user interface section. Add the following as Threads.swift
:
import Foundation class Threads { class func runOnBackgroundThread(fn:()->()) { dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),fn) } class func runOnUIThread(fn:()->()) { if(NSThread.isMainThread()) { fn() } else { dispatch_async(dispatch_get_main_queue(), fn) } } }
The Threads
class can be tested with the following test case:
import XCTest class ThreadsTest: XCTestCase { func testThreads() { Threads.runOnBackgroundThread { XCTAssertFalse(NSThread.isMainThread(), "Running on background thread") Threads.runOnUIThread { XCTAssertTrue(NSThread.isMainThread(), "Running on UI thread") } } } }
When the tests are run by pressing Command + U, the tests should pass.
As many network responses are returned in JSON format, and to make JSON parsing easier, extensions can be added to the NSURL
class to facilitate acquiring and parsing of content loaded from network locations. Instead of designing a synchronous extension that blocks until data is available, using a callback function is better practice. Create a file named NSURLExtensions.swift
with the following content:
extension NSURL {
func withJSONDictionary(fn:[String:String] -> ()) {
let session = NSURLSession.sharedSession()
session.dataTaskWithURL(self) {
data,response,error -> () in
if let json = NSJSONSerialization.JSONObjectWithData(
data, options: nil, error: nil) as? [String:AnyObject] {
// fn(json)
} else {
fn([String:String]())
}
}.resume()
}
}
This provides an extension for an NSURL
to provide a JSON dictionary. However, the data type returned from the JSONObjectWithData
method is [String:AnyObject]
, not [String:String]
. Although it might be expected that it could just be cast to the right type, the as
will perform a test and if there are mixed values (such as a number or nil
) then the entire object is considered invalid. Instead, the JSON data structure must be converted to a [String:String]
type. Add the following as a standalone function to NSURLExtensions.swift
:
func toStringString(dict:[String:AnyObject]) -> [String:String] { var result:[String:String] = [:] for (key,value) in dict { if let valueString = value as? String { result[key] = valueString } else { result[key] = "(value)" } } return result }
This can be used to convert [String:AnyObject]
in the JSON function:
fn(toStringString(json))
The function can be tested with a test class using the data:
protocol by passing in a
base64 encoded string representing the JSON data. To create a base64 representation, create a string, convert it to a UTF-8 data object, and then convert it back to a string representation with a data:
prefix:
import XCTest class NSURLExtensionsTest: XCTestCase { func testNSURLJSON() { let json = "{"test":"value"}". dataUsingEncoding(NSUTF8StringEncoding)! let base64 = json.base64EncodedDataWithOptions(nil) let data = NSString(data: base64, encoding: NSUTF8StringEncoding)! let dataURL = NSURL(string:"data:text/plain;base64,(data)")! dataURL.withJSONDictionary { dict in XCTAssertEqual(dict["test"] ?? "", "value", "Value is as expected") } } }
A similar approach can be used to parse arrays of dictionaries (such as those returned by the list repositories resource). The differences here are the type signatures (which have extra square brackets []
to represent the array) and the fact that a
map is being used to process the elements in the list:
func withJSONArrayOfDictionary(fn:[[String:String]] -> ()) { … if let json = NSJSONSerialization.JSONObjectWithData( data, options: nil, error: nil) as? [[String:AnyObject]] { fn(json.map(toStringString)) } else { fn([[String:String]]()) }
The test can be extended as well:
let json = "[{"test":"value"}]". dataUsingEncoding(NSUTF8StringEncoding)! … dataURL.withJSONArrayOfDictionary { dict in XCTAssertEqual(dict[0]["test"] ?? "", "value", "Value is as expected") }