April 11, 2016
Previously we used NSURLSession
data tasks to make some REST requests to a web service. Today let’s clean that up by building a higher layer of abstraction by mapping the JSON to a strongly-typed class.
This tutorial has been written using for Swift 2.2 and iOS 9.
If you prefer to work with libraries like Alamofire and SwiftyJSON, here’s a similar post using those tools.
First, we’ll need a class to represent the Todo objects we’re dealing with. Create a new class in it’s own file to represent the Todo objects:
class Todo {
var title: String?
var id: Int?
var userId: Int?
var completed: Int?
required init?(aTitle: String?, anId: Int?, aUserId: Int?, aCompletedStatus: Int?) {
self.title = aTitle
self.id = anId
self.userId = aUserId
self.completed = aCompletedStatus
}
func description() -> String {
return "ID: \(self.id)" +
"User ID: \(self.userId)" +
"Title: \(self.title)\n" +
"Completed: \(self.completed)\n"
}
}
So we’ve got a Todo class with some pretty obvious but optional fields. For our GET and POST requests we’ll need to access the endpoints for all todos and a single todo:
class Todo {
...
// MARK: URLs
class func endpointForID(id: Int) -> String {
return "https://jsonplaceholder.typicode.com/todos/\(id)"
}
class func endpointForTodos() -> String {
return "https://jsonplaceholder.typicode.com/todos/"
}
}
When setting up API calls I like to work backwards. So we’ll start with the calls we’d like to make then figure out how to make them work. First, we want to be able to GET a todo from an ID number:
// MARK: GET
// Get first todo
Todo.todoByID(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 /todos")
print(error)
return
}
guard let todo = result.value else {
print("error calling POST on /todos: result is nil")
return
}
// success!
print(todo.description())
print(todo.title)
})
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 Todos, not the underlying levels of abstraction.
We’ll also want to be able to create Todos by sending them to the server:
// MARK: POST
// Create new todo
guard let newTodo = Todo(aTitle: "Frist todo", anId: nil, aUserId: 1, aCompletedStatus: 0) else {
print("error: newTodo isn't a todo")
return
}
newTodo.save { result in
if let error = result.error {
// got an error in getting the data, need to handle it
print("error calling POST on /todos")
print(error)
return
}
guard let todo = result.value else {
print("error calling POST on /todos: result is nil")
return
}
// success!
print(todo.description())
print(todo.title)
}
We’ve separated creating a new Todo object locally from saving it on the server (Todo(...)
vs newTodo.save(...)
). We’re leaving the ID number blank on creation since that will be assigned by the server.
So those are what we want to be able to do. Let’s set up some NSURLRequests
that we’ll be able to send using NSURLSession
and see how we can interface them to those Todo calls. First the GET request (using our handy-dandy URL endpoint class funcs):
class func todoByID(id: Int, completionHandler: (Todo?, NSError?) -> Void) {
let endpoint = Todo.endpointForID(id)
guard let url = NSURL(string: endpoint) else {
print("Error: cannot create URL")
let error = NSError(domain: "TodoClass", code: 1, userInfo: [NSLocalizedDescriptionKey: "cannot create URL"])
completionHandler(nil, error)
return
}
let urlRequest = NSURLRequest(URL: url)
let config = NSURLSessionConfiguration.defaultSessionConfiguration()
let session = NSURLSession(configuration: config)
let task = session.dataTaskWithRequest(urlRequest, completionHandler: {
(data, response, error) in
guard let responseData = data else {
print("Error: did not receive data")
completionHandler(nil, error)
return
}
guard error == nil else {
print("error calling GET on /todos/1")
print(error)
completionHandler(nil, error)
return
}
// parse the result as JSON, since that's what the API provides
do {
if let todoJSON = try NSJSONSerialization.JSONObjectWithData(responseData, options: []) as? [String: AnyObject] {
if let todo = Todo(json: todoJSON) {
// created a TODO object
completionHandler(todo, nil)
} else {
// couldn't create a todo object from the JSON
let error = NSError(domain: "TodoClass", code: 3, userInfo: [NSLocalizedDescriptionKey: "Couldn't create a todo object from the JSON"])
completionHandler(nil, error)
}
}
} catch {
print("error trying to convert data to JSON")
let error = NSError(domain: "TodoClass", code: 2, userInfo: [NSLocalizedDescriptionKey: "error trying to convert data to JSON"])
completionHandler(nil, error)
return
}
})
task.resume()
}
That’s pretty much the same as we had last time with 2 differences:
- The completion handler
- Creating todo objects from JSON:
if let todo = Todo(json: todoJSON)
Completion Handlers
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., 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 you can use them in your own code.
Here todoByID
takes a completion handler as an argument. So when we call it we provide a block of code to execute when it’s done. The completion handler has 2 optional arguments: a todo object and and error. So if we can’t get the todo we can provide an error to the calling code so it can handle it.
Creating Todo Objects from JSON
We’ve used this bit of code in todoByID
:
if let todo = Todo(json: todoJSON)
So we need to create an initializer for todos that takes in the todoJSON
. The todoJSON
happens to be a [String: AnyObject]
dictionary. Let’s implement that:
class Todo {
var title: String?
var id: Int?
var userId: Int?
var completed: Int?
...
required init?(json: [String: AnyObject]) {
self.title = json["title"] as? String
self.id = json["id"] as? Int
self.userId = json["userId"] as? Int
self.completed = json["completed"] as? Int
}
...
}
It’s easy to parse out the contents of the JSON into the properties of the Todo object.
Now the GET call should work. We can run our nice pretty Todo.todoByID(1) call now :) Save & run to test it out.
But, of course, there are always more requirements: We said we’d implement the POST call to save new Todos too. To do that we need to convert todo objects into the correct JSON format to send to the API.
In our Todo class, we’ll need a method to turn a todo into a dictionary with String keys (which we’ll call JSON for convenience):
func toJSON() -> [String: AnyObject] {
var json = [String: AnyObject]()
if let title = title {
json["title"] = title
}
if let id = id {
json["id"] = id
}
if let userId = userId {
json["userId"] = userId
}
if let completed = completed {
json["completed"] = completed
}
return json
}
To finish implementing the save()
function for Todos:
// POST / Create
func saveNoAlamofire(completionHandler: (Todo?, NSError?) -> Void) {
let todosEndpoint = Todo.endpointForTodos()
guard let todosURL = NSURL(string: todosEndpoint) else {
let error = NSError(domain: "TodoClass", code: 4, userInfo: [NSLocalizedDescriptionKey: "Error: cannot create URL"])
completionHandler(nil, error)
return
}
let todosUrlRequest = NSMutableURLRequest(URL: todosURL)
todosUrlRequest.HTTPMethod = "POST"
let newTodoAsJSON = self.toJSON()
let jsonTodo: NSData
do {
jsonTodo = try NSJSONSerialization.dataWithJSONObject(newTodoAsJSON, options: [])
todosUrlRequest.HTTPBody = jsonTodo
} catch {
let error = NSError(domain: "TodoClass", code: 5, userInfo: [NSLocalizedDescriptionKey: "Error: cannot create JSON from todo"])
completionHandler(nil, error)
return
}
let config = NSURLSessionConfiguration.defaultSessionConfiguration()
let session = NSURLSession(configuration: config)
let task = session.dataTaskWithRequest(todosUrlRequest, completionHandler: {
(data, response, error) in
guard let responseData = data else {
print("Error: did not receive data")
return
}
guard error == nil else {
print("error calling POST on /todos/1")
print(error)
return
}
do {
if let todoJSON = try NSJSONSerialization.JSONObjectWithData(responseData,
options: []) as? [String: AnyObject] {
if let todo = Todo(json: todoJSON) {
// created a TODO object
completionHandler(todo, nil)
} else {
// couldn't create a todo object from the JSON
let error = NSError(domain: "TodoClass", code: 3, userInfo: [NSLocalizedDescriptionKey: "Couldn't create a todo object from the JSON"])
completionHandler(nil, error)
}
}
} catch {
print("error parsing response from POST on /todos")
return
}
})
task.resume()
}
Like with the GET call we’ve added completion handler calls to pass back an error if we can’t create the todo. The other improvements from last time are using Todo.endpointForTodos()
so we don’t have URL strings everywhere and using the toJSON()
function to generate the JSON.
And that’s it! Now we can run our nice pretty calls to retrieve and save todos that we set up at the start. Even better, the caller of those functions no longer has any knowledge of how the todos are getting retrieved & saved. Good code design means that each piece has its responsibilities and its inner details aren’t exposed to the othere parts. We can work with todo objects in our code without having to know that they came from JSON.