RxSwift/RxExample/RxExample/Examples/GitHubSearchRepositories/GitHubSearchRepositoriesAPI...

265 lines
9.3 KiB
Swift

//
// GitHubSearchRepositoriesAPI.swift
// RxExample
//
// Created by Krunoslav Zaher on 10/18/15.
// Copyright © 2015 Krunoslav Zaher. All rights reserved.
//
import Foundation
#if !RX_NO_MODULE
import RxSwift
#endif
/**
Parsed GitHub respository.
*/
struct Repository: CustomDebugStringConvertible {
var name: String
var url: String
init(name: String, url: String) {
self.name = name
self.url = url
}
}
extension Repository {
var debugDescription: String {
return "\(name) | \(url)"
}
}
/**
ServiceState state.
*/
enum ServiceState {
case online
case offline
}
/**
Raw response from GitHub API
*/
enum SearchRepositoryResponse {
/**
New repositories just fetched
*/
case repositories(repositories: [Repository], nextURL: URL?)
/**
In case there was some problem fetching data from service, this will be returned.
It really doesn't matter if that is a failure in network layer, parsing error or something else.
In case data can't be read and parsed properly, something is wrong with server response.
*/
case serviceOffline
/**
This example uses unauthenticated GitHub API. That API does have throttling policy and you won't
be able to make more then 10 requests per minute.
That is actually an awesome scenario to demonstrate complex retries using alert views and combination of timers.
Just search like mad, and everything will be handled right.
*/
case limitExceeded
}
/**
This is the final result of loading. Crème de la crème.
*/
struct RepositoriesState {
/**
List of parsed repositories ready to be shown in the UI.
*/
let repositories: [Repository]
/**
Current network state.
*/
let serviceState: ServiceState?
/**
Limit exceeded
*/
let limitExceeded: Bool
static let empty = RepositoriesState(repositories: [], serviceState: nil, limitExceeded: false)
}
class GitHubSearchRepositoriesAPI {
// *****************************************************************************************
// !!! This is defined for simplicity sake, using singletons isn't advised !!!
// !!! This is just a simple way to move services to one location so you can see Rx code !!!
// *****************************************************************************************
static let sharedAPI = GitHubSearchRepositoriesAPI(wireframe: DefaultWireframe(), reachabilityService: try! DefaultReachabilityService())
let activityIndicator = ActivityIndicator()
// Why would network service have wireframe service? It's here to abstract promting user
// Do we really want to make this example project factory/fascade/service competition? :)
private let _wireframe: Wireframe
fileprivate let _reachabilityService: ReachabilityService
private init(wireframe: Wireframe, reachabilityService: ReachabilityService) {
_wireframe = wireframe
_reachabilityService = reachabilityService
}
}
// MARK: Pagination
extension GitHubSearchRepositoriesAPI {
/**
Public fascade for search.
*/
func search(_ query: String, loadNextPageTrigger: Observable<Void>) -> Observable<RepositoriesState> {
let escapedQuery = query.URLEscaped
let url = URL(string: "https://api.github.com/search/repositories?q=\(escapedQuery)")!
return recursivelySearch([], loadNextURL: url, loadNextPageTrigger: loadNextPageTrigger)
// Here we go again
.startWith(RepositoriesState.empty)
}
private func recursivelySearch(_ loadedSoFar: [Repository], loadNextURL: URL, loadNextPageTrigger: Observable<Void>) -> Observable<RepositoriesState> {
return loadSearchURL(loadNextURL).flatMap { searchResponse -> Observable<RepositoriesState> in
switch searchResponse {
/**
If service is offline, that's ok, that means that this isn't the last thing we've heard from that API.
It will retry until either battery drains, you become angry and close the app or evil machine comes back
from the future, steals your device and Googles Sarah Connor's address.
*/
case .serviceOffline:
return Observable.just(RepositoriesState(repositories: loadedSoFar, serviceState: .offline, limitExceeded: false))
case .limitExceeded:
return Observable.just(RepositoriesState(repositories: loadedSoFar, serviceState: .online, limitExceeded: true))
case let .repositories(newPageRepositories, maybeNextURL):
var loadedRepositories = loadedSoFar
loadedRepositories.append(contentsOf: newPageRepositories)
let appenedRepositories = RepositoriesState(repositories: loadedRepositories, serviceState: .online, limitExceeded: false)
// if next page can't be loaded, just return what was loaded, and stop
guard let nextURL = maybeNextURL else {
return Observable.just(appenedRepositories)
}
return [
// return loaded immediately
Observable.just(appenedRepositories),
// wait until next page can be loaded
Observable.never().takeUntil(loadNextPageTrigger),
// load next page
self.recursivelySearch(loadedRepositories, loadNextURL: nextURL, loadNextPageTrigger: loadNextPageTrigger)
].concat()
}
}
}
private func loadSearchURL(_ searchURL: URL) -> Observable<SearchRepositoryResponse> {
return URLSession.shared
.rx_response(URLRequest(url: searchURL))
.retry(3)
.trackActivity(self.activityIndicator)
.observeOn(Dependencies.sharedDependencies.backgroundWorkScheduler)
.map { data, httpResponse -> SearchRepositoryResponse in
if httpResponse.statusCode == 403 {
return .limitExceeded
}
let jsonRoot = try GitHubSearchRepositoriesAPI.parseJSON(httpResponse, data: data)
guard let json = jsonRoot as? [String: AnyObject] else {
throw exampleError("Casting to dictionary failed")
}
let repositories = try GitHubSearchRepositoriesAPI.parseRepositories(json)
let nextURL = try GitHubSearchRepositoriesAPI.parseNextURL(httpResponse)
return .repositories(repositories: repositories, nextURL: nextURL)
}
.retryOnBecomesReachable(.serviceOffline, reachabilityService: _reachabilityService)
}
}
// MARK: Parsing the response
extension GitHubSearchRepositoriesAPI {
private static let parseLinksPattern = "\\s*,?\\s*<([^\\>]*)>\\s*;\\s*rel=\"([^\"]*)\""
private static let linksRegex = try! NSRegularExpression(pattern: parseLinksPattern, options: [.allowCommentsAndWhitespace])
fileprivate static func parseLinks(_ links: String) throws -> [String: String] {
let length = (links as NSString).length
let matches = GitHubSearchRepositoriesAPI.linksRegex.matches(in: links, options: NSRegularExpression.MatchingOptions(), range: NSRange(location: 0, length: length))
var result: [String: String] = [:]
for m in matches {
let matches = (1 ..< m.numberOfRanges).map { rangeIndex -> String in
let range = m.rangeAt(rangeIndex)
let startIndex = links.characters.index(links.startIndex, offsetBy: range.location)
let endIndex = links.characters.index(links.startIndex, offsetBy: range.location + range.length)
let stringRange = startIndex ..< endIndex
return links.substring(with: stringRange)
}
if matches.count != 2 {
throw exampleError("Error parsing links")
}
result[matches[1]] = matches[0]
}
return result
}
fileprivate static func parseNextURL(_ httpResponse: HTTPURLResponse) throws -> URL? {
guard let serializedLinks = httpResponse.allHeaderFields["Link"] as? String else {
return nil
}
let links = try GitHubSearchRepositoriesAPI.parseLinks(serializedLinks)
guard let nextPageURL = links["next"] else {
return nil
}
guard let nextUrl = URL(string: nextPageURL) else {
throw exampleError("Error parsing next url `\(nextPageURL)`")
}
return nextUrl
}
fileprivate static func parseJSON(_ httpResponse: HTTPURLResponse, data: Data) throws -> AnyObject {
if !(200 ..< 300 ~= httpResponse.statusCode) {
throw exampleError("Call failed")
}
return try JSONSerialization.jsonObject(with: data, options: []) as AnyObject
}
fileprivate static func parseRepositories(_ json: [String: AnyObject]) throws -> [Repository] {
guard let items = json["items"] as? [[String: AnyObject]] else {
throw exampleError("Can't find items")
}
return try items.map { item in
guard let name = item["name"] as? String,
let url = item["url"] as? String else {
throw exampleError("Can't parse repository")
}
return Repository(name: name, url: url)
}
}
}