Episode #204

JSON API Client - Part 2

21 minutes
Published on January 14, 2016

This video is only available to subscribers. Get access to this video and 572 others.

In this episode we start writing an application-specific API Client. We use Argo to decode our JSON response into an Episode model, including some nested object decoding, date formatting, and wrap it up by testing the implementation to make sure it works.

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!

This episode uses Swift 2.2, Argo 2.2.0.