Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Bug 1378997 - Backend API fetching for Pocket Trending stories. (#2984)…
… r=garvan * Bug 1378997 - Backend API fetching for Pocket Trending stories. * Rename a few things and clean up the API usage.
- Loading branch information
1 parent
b7d2697
commit 24e215f
Showing
4 changed files
with
237 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
/* This Source Code Form is subject to the terms of the Mozilla Public | ||
* License, v. 2.0. If a copy of the MPL was not distributed with this | ||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
||
import UIKit | ||
import GCDWebServers | ||
import XCTest | ||
|
||
@testable import Client | ||
|
||
class PocketStoriesTests: XCTestCase { | ||
|
||
var pocketAPI: String! | ||
let webServer: GCDWebServer = GCDWebServer() | ||
|
||
/// Setup a basic web server that binds to a random port and that has one default handler on /hello | ||
fileprivate func setupWebServer() { | ||
let path = Bundle(for: type(of: self)).path(forResource: "pocketglobalfeed", ofType: "json") | ||
let data = try! Data(contentsOf: URL(fileURLWithPath: path!)) | ||
|
||
webServer.addHandler(forMethod: "GET", path: "/pocketglobalfeed", request: GCDWebServerRequest.self) { (request) -> GCDWebServerResponse! in | ||
return GCDWebServerDataResponse(data: data, contentType: "application/json") | ||
} | ||
|
||
if webServer.start(withPort: 0, bonjourName: nil) == false { | ||
XCTFail("Can't start the GCDWebServer") | ||
} | ||
pocketAPI = "http://localhost:\(webServer.port)/pocketglobalfeed" | ||
} | ||
|
||
override func setUp() { | ||
super.setUp() | ||
setupWebServer() | ||
} | ||
|
||
override func tearDown() { | ||
super.tearDown() | ||
} | ||
|
||
func testPocketStoriesCaching() { | ||
let expect = expectation(description: "Pocket") | ||
let PocketFeed = Pocket(endPoint: pocketAPI) | ||
|
||
PocketFeed.globalFeed(items: 4).upon { result in | ||
let items = result | ||
XCTAssertEqual(items.count, 2, "We are fetching a static feed. There are only 2 items in it") | ||
self.webServer.stop() // Stop the webserver so we can check caching | ||
|
||
// Try again now that the webserver is down | ||
PocketFeed.globalFeed(items: 4).upon { result in | ||
let items = result | ||
XCTAssertEqual(items.count, 2, "We are fetching a static feed. There are only 2 items in it") | ||
let item = items.first | ||
//These are all not optional so they should never be nil. | ||
//But lets check in case someone decides to change something | ||
XCTAssertNotNil(item?.domain, "Why") | ||
XCTAssertNotNil(item?.imageURL, "You") | ||
XCTAssertNotNil(item?.storyDescription, "Do") | ||
XCTAssertNotNil(item?.title, "This") | ||
XCTAssertNotNil(item?.url, "?") | ||
expect.fulfill() | ||
} | ||
} | ||
waitForExpectations(timeout: 10, handler: nil) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
{ | ||
"status": 1, | ||
"list": [{ | ||
"id": 2092, | ||
"url": "https:\/\/pocket.co\/xMnD5u", | ||
"dedupe_url": "https:\/\/www.wired.com\/story\/turn-off-your-push-notifications\/", | ||
"title": "Turn Off Your Push Notifications. All of Them", | ||
"excerpt": "Push notifications are ruining my life. Yours too, I bet. Download more than a few apps and the notifications become a non-stop, cacophonous waterfall of nonsense. Here's just part of an afternoon on my phone:", | ||
"domain": "wired.com", | ||
"image_src": "https:\/\/d33ypg4xwx0n86.cloudfront.net\/direct?url=https%3A%2F%2Fmedia.wired.com%2Fphotos%2F597267fd023c38366e1ae497%2Fmaster%2Fw_1200%2Cc_limit%2Fno_notifications-TA.gif&resize=w450", | ||
"published_timestamp": "1332306000", | ||
"sort_id": 0 | ||
}, { | ||
"id": 2091, | ||
"url": "https:\/\/pocket.co\/sMnD5m", | ||
"dedupe_url": "http:\/\/www.latimes.com\/business\/la-fi-agenda-best-buy-20170717-htmlstory.html", | ||
"title": "Why the grim reaper of retail hasn't come to claim Best Buy", | ||
"excerpt": "Five years ago Best Buy Co. looked like a retail dinosaur, another victim of e-commerce juggernaut Amazon.com and other online sellers.", | ||
"domain": "latimes.com", | ||
"image_src": "https:\/\/d33ypg4xwx0n86.cloudfront.net\/direct?url=http%3A%2F%2Fwww.trbimg.com%2Fimg-547cc080%2Fturbine%2Fla-fi-mh-radio-shack-20141201-004%2F&resize=w450", | ||
"published_timestamp": "1500267600", | ||
"sort_id": 1 | ||
}] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
/* This Source Code Form is subject to the terms of the Mozilla Public | ||
* License, v. 2.0. If a copy of the MPL was not distributed with this | ||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
||
import Foundation | ||
import Alamofire | ||
import Shared | ||
import Deferred | ||
import Storage | ||
|
||
private let PocketEnvAPIKey = "PocketEnvironmentAPIKey" | ||
private let PocketGlobalFeed = "https://getpocket.com/v3/firefox/global-recs" | ||
private let MaxCacheAge: Timestamp = OneMinuteInMilliseconds * 60 // 1 hour in milliseconds | ||
|
||
/*s | ||
The Pocket class is used to fetch stories from the Pocked API. | ||
Right now this only supports the global feed | ||
For a sample feed item check ClientTests/pocketglobalfeed.json | ||
*/ | ||
struct PocketStory { | ||
let url: URL | ||
let title: String | ||
let storyDescription: String | ||
let imageURL: URL | ||
let domain: String | ||
|
||
static func parseJSON(list: Array<[String: Any]>) -> [PocketStory] { | ||
return list.flatMap({ (storyDict) -> PocketStory? in | ||
guard let urlS = storyDict["url"] as? String, let domain = storyDict["domain"] as? String, | ||
let imageURLS = storyDict["image_src"] as? String, | ||
let title = storyDict["title"] as? String, | ||
let description = storyDict["excerpt"] as? String else { | ||
return nil | ||
} | ||
guard let url = URL(string: urlS), let imageURL = URL(string: imageURLS) else { | ||
return nil | ||
} | ||
return PocketStory(url: url, title: title, storyDescription: description, imageURL: imageURL, domain: domain) | ||
}) | ||
} | ||
} | ||
|
||
private class PocketError: MaybeErrorType { | ||
var description = "Failed to load from API" | ||
} | ||
|
||
class Pocket { | ||
|
||
private let pocketGlobalFeed: String | ||
// Allow endPoint to be overriden for testing | ||
init(endPoint: String? = nil) { | ||
pocketGlobalFeed = endPoint ?? PocketGlobalFeed | ||
} | ||
|
||
lazy fileprivate var alamofire: SessionManager = { | ||
let ua = UserAgent.fxaUserAgent //TODO: use a different UA | ||
let configuration = URLSessionConfiguration.default | ||
return SessionManager.managerWithUserAgent(ua, configuration: configuration) | ||
}() | ||
|
||
private func findCachedResponse(for request: URLRequest) -> [String: Any]? { | ||
let cachedResponse = URLCache.shared.cachedResponse(for: request) | ||
guard let cachedAtTime = cachedResponse?.userInfo?["cache-time"] as? Timestamp, (Date.now() - cachedAtTime) < MaxCacheAge else { | ||
return nil | ||
} | ||
|
||
guard let data = cachedResponse?.data, let json = try? JSONSerialization.jsonObject(with: data, options: []) else { | ||
return nil | ||
} | ||
|
||
return json as? [String: Any] | ||
} | ||
|
||
private func cache(response: HTTPURLResponse?, for request: URLRequest, with data: Data?) { | ||
guard let resp = response, let data = data else { | ||
return | ||
} | ||
let metadata = ["cache-time": Date.now()] | ||
let cachedResp = CachedURLResponse(response: resp, data: data, userInfo: metadata, storagePolicy: .allowed) | ||
URLCache.shared.removeCachedResponse(for: request) | ||
URLCache.shared.storeCachedResponse(cachedResp, for: request) | ||
} | ||
|
||
// Fetch items from the global pocket feed | ||
func globalFeed(items: Int = 2) -> Deferred<Array<PocketStory>> { | ||
let deferred = Deferred<Array<PocketStory>>() | ||
|
||
guard let request = createGlobalFeedRequest(items: items) else { | ||
deferred.fill([]) | ||
return deferred | ||
} | ||
|
||
if let cachedResponse = findCachedResponse(for: request), let items = cachedResponse["list"] as? Array<[String: Any]> { | ||
deferred.fill(PocketStory.parseJSON(list: items)) | ||
return deferred | ||
} | ||
|
||
alamofire.request(request).validate(contentType: ["application/json"]).responseJSON { response in | ||
guard response.error == nil, let result = response.result.value as? [String: Any] else { | ||
return deferred.fill([]) | ||
} | ||
self.cache(response: response.response, for: request, with: response.data) | ||
guard let items = result["list"] as? Array<[String: Any]> else { | ||
return deferred.fill([]) | ||
} | ||
return deferred.fill(PocketStory.parseJSON(list: items)) | ||
} | ||
|
||
return deferred | ||
} | ||
|
||
// Create the URL request to query the Pocket API. The max items that the query can return is 20 | ||
private func createGlobalFeedRequest(items: Int = 2) -> URLRequest? { | ||
guard items > 0 && items <= 20 else { | ||
return nil | ||
} | ||
|
||
guard let feedURL = URL(string: pocketGlobalFeed)?.withQueryParam("count", value: "\(items)") else { | ||
return nil | ||
} | ||
let apiURL = addAPIKey(url: feedURL) | ||
return URLRequest(url: apiURL, cachePolicy: URLRequest.CachePolicy.reloadIgnoringCacheData, timeoutInterval: 5) | ||
} | ||
|
||
private func addAPIKey(url: URL) -> URL { | ||
let bundle = Bundle.main | ||
guard let api_key = bundle.object(forInfoDictionaryKey: PocketEnvAPIKey) as? String else { | ||
return url | ||
} | ||
return url.withQueryParam("consumer_key", value: api_key) | ||
} | ||
|
||
} |