
This video is only available to subscribers. Start a subscription today to get access to this and 469 other videos.
JSON API Client - Part 2
Episode Links
Creating an Episode Model
Our Episode
model will be a simple Swift class that has properties
that map from the API response.
class Episode {
var episodeId: Int?
var title: String?
var number: Int?
var duration: Int?
var dominantColor: String?
var largeImageUrl: NSURL?
var mediumImageUrl: NSURL?
var thumbnailImageUrl: NSURL?
var videoUrl: NSURL?
var hlsUrl: NSURL?
var episodeDescription: String?
var episodeType: EpisodeType?
var publishedAt: NSDate?
var showNotes: String?
}
This is pretty straightforward. Here I'm allowing any of these parameters to be nil
, but you might choose to consider a model invalid if any required parameters are nil.
We also need to define the EpisodeType
type:
enum EpisodeType : String {
case Free = "free"
case Paid = "paid"
}
There might be other episode types in the future, and I might also choose to add some logic related to the type, so an enum is a good choice here, rather than just a string.
Adding Argo Support
Next we need to add Argo support for our model. I prefer to do this in another file and extend the type that way, so it's easy to read, and if I want to change it later it will be all contained in one place.
I'll create a new file called Episode+Decodable.swift
and define the parsing logic there. For now we'll just return the simplest thing that we can, which is a simple decode error indicating that something went wrong. Argo uses this return type to better communicate why an object cannot be parsed, which is extremely helpful in diagnosing problems.
import Foundation
import Argo
extension Episode : Decodable {
typealias DecodedType = Episode
static func decode(json: JSON) -> Decoded<Episode> {
return Decoded.Failure(DecodeError.Custom("not implemented"))
}
}
Implementing our own Api Client
Now we can turn our attention to the API client specific to our application.
We'll start with a simple case, fetching an episode by ID:
class NSScreencastApiClient : ApiClient {
func fetchEpisode(id: Int, completion: ApiClientResult<Episode> -> Void) {
completion(ApiClientResult.NotFound)
}
}
Now that we have the most minimal (albeit broken) implementation, we can write a test to see if we get the expected error.
import Foundation
import XCTest
@testable import nsscreencast_tvdemo
class NSScreencastApiClientTests : XCTestCase {
var apiClient: NSScreencastApiClient!
override func setUp() {
apiClient = NSScreencastApiClient(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())
}
func testFetchSingleEpisode() {
let expectation = expectationWithDescription("response received")
apiClient.fetchEpisode(1) { result in
switch result {
case .Success(_): break
default: XCTFail()
}
}
waitForExpectationsWithTimeout(3, handler: nil)
}
}
We also want to set up our own sesison configuration so we can supply common request headers to be sent with our requests. For this I'll create a singleton property we can access from anywhere.
static var sharedApiClient: NSScreencastApiClient = {
let config = NSURLSessionConfiguration.defaultSessionConfiguration()
config.HTTPAdditionalHeaders = [
"Content-Type" : "application/json",
"Accept" : "application/json"
]
return NSScreencastApiClient(configuration: config)
}()
Decodable Implementation for Episode
Going back to Episode+Decodable.swift
we can write our implementation:
extension Episode : Decodable {
public static func decode(json: JSON) -> Decoded<Episode> {
let episode = Episode()
episode.episodeId = (json <| "id").value
episode.title = (json <| "title").value
episode.number = (json <| "episode_number").value
episode.dominantColor = (json <| "dominant_color").value
episode.largeImageUrl = (json <| "large_artwork_url").value
episode.mediumImageUrl = (json <| "medium_artwork_url").value
episode.thumbnailImageUrl = (json <| "small_artwork_url").value
episode.videoUrl = (json <| "video_url").value
episode.hlsUrl = (json <| "hls_url").value
episode.episodeType = (json <| "episode_type").value
episode.episodeDescription = (json <| "description").value
episode.showNotes = (json <| "show_notes").value
episode.duration = (json <| "duration").value
return .Success(episode)
}
}
Parsing NSURLs
Parsing an NSURL
is= easy, but since no decodable implementation is builtin, we can provide our own:
extension NSURL: Decodable {
public typealias DecodedType = NSURL
public class func decode(j: JSON) -> Decoded<NSURL> {
switch j {
case .String(let urlString):
if let url = NSURL(string: urlString) {
return .Success(url)
} else {
return .typeMismatch("URL", actual: j)
}
default: return .typeMismatch("URL", actual: j)
}
}
}
Note that the if let ... else
could be simplified with some functional voodoo:
return NSURL(string: urlString).map(pure) ?? .typeMismatch("URL", actual: j)
The idea here is that you're mapping an optional value onto the pure
function, which wraps the value in a Success
. If the value is nil
, then map
returns nil
, in which case the right side of the ??
operator is returned.
While a neat one-liner, I find the former a bit more readable.
Supporting ISO8601 Format to NSDate
Parsing a string into an NSDate
isn't as straightforward as NSURL
. We could provide a Decodable
implementation for it, but that would assume a single date format. Instead we'll have a function we can use to do the parsing for us:
class DateHelper {
static var dateFormatterISO8601: NSDateFormatter = {
let formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
return formatter
}()
class func parseDateISO8601(dateString: String) -> Decoded<NSDate> {
if let date = dateFormatterISO8601.dateFromString(dateString) {
return Decoded.Success(date)
}
return Decoded.Success(NSDate())
}
}
Then we can use it when decoding as needed:
episode.publishedAt = (json <| "published_at" >>- DateHelper.parseDateISO8601).value
Working inside out here, parseDateISO8601
takes a String
and returns a Decoded<NSDate>
, so that gives the <|
operator the information it needs to treat the value as a string first, then apply it to the provided function. the >>-
operator does this for us.
With this in hand we can easily use multiple date formats without being tied down to just one.
Back to the API Client
Now, back on the API client, we can write our request & send it off to complete the example and run our test.
func fetchEpisodeDetail(id: Int, completion: ApiClientResult<Episode> -> ()) {
let url = ApiEndpoints.Episode(id).url()
let request = buildRequest("GET", url: url, params: nil)
fetchResource(request, rootKey: "episode", completion: completion)
}
Here we're using the enum technique for modeling endpoints we looked at in Episode 194.
enum Endpoints {
case Episode(Int)
var url: NSURL {
let path: String
switch self {
case .Episode(let id):
path = "/episodes/\(id)"
}
return NSURL(string: NSScreencastApiClient.baseURL + path)!
}
}
Adding some Assertions
Finally, we can go back to our test and add a couple assertions to make sure that things are working as expected.
func testFetchSingleEpisode() {
let expectation = expectationWithDescription("api response received")
apiClient.fetchEpisode(1) { result in
expectation.fulfill()
switch result {
case .Success(let episode):
XCTAssertEqual(1, episode.episodeId)
XCTAssertEqual(1, episode.number)
break
default:
XCTFail()
}
}
waitForExpectationsWithTimeout(3, handler: nil)
}
We run the test, and it passes! Yay!