Revision control

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
import Foundation
6
import Shared
7
import Storage
8
9
private let PocketEnvAPIKey = "PocketEnvironmentAPIKey"
10
private let PocketGlobalFeed = "https://getpocket.cdn.mozilla.net/v3/firefox/global-recs"
11
private let MaxCacheAge: Timestamp = OneMinuteInMilliseconds * 60 // 1 hour in milliseconds
12
private let SupportedLocales = ["en_US", "en_GB", "en_ZA", "de_DE", "de_AT", "de_CH"]
14
15
/*s
16
The Pocket class is used to fetch stories from the Pocked API.
17
Right now this only supports the global feed
18
19
For a sample feed item check ClientTests/pocketglobalfeed.json
20
*/
21
struct PocketStory {
22
let url: URL
23
let title: String
24
let storyDescription: String
25
let imageURL: URL
26
let domain: String
27
28
static func parseJSON(list: Array<[String: Any]>) -> [PocketStory] {
29
return list.compactMap({ (storyDict) -> PocketStory? in
30
guard let urlS = storyDict["url"] as? String, let domain = storyDict["domain"] as? String,
31
let imageURLS = storyDict["image_src"] as? String,
32
let title = storyDict["title"] as? String,
33
let description = storyDict["excerpt"] as? String else {
34
return nil
35
}
36
guard let url = URL(string: urlS), let imageURL = URL(string: imageURLS) else {
37
return nil
38
}
39
return PocketStory(url: url, title: title, storyDescription: description, imageURL: imageURL, domain: domain)
40
})
41
}
42
}
43
44
private class PocketError: MaybeErrorType {
45
var description = "Failed to load from API"
46
}
47
48
class Pocket {
49
private let pocketGlobalFeed: String
50
static let MoreStoriesURL = URL(string: "https://getpocket.com/explore/trending?src=ff_ios&cdn=0")!
51
52
// Allow endPoint to be overriden for testing
53
init(endPoint: String = PocketGlobalFeed) {
54
self.pocketGlobalFeed = endPoint
55
}
56
57
lazy fileprivate var urlSession = makeURLSession(userAgent: UserAgent.defaultClientUserAgent, configuration: URLSessionConfiguration.default)
58
59
private func findCachedResponse(for request: URLRequest) -> [String: Any]? {
60
let cachedResponse = URLCache.shared.cachedResponse(for: request)
61
guard let cachedAtTime = cachedResponse?.userInfo?["cache-time"] as? Timestamp, (Date.now() - cachedAtTime) < MaxCacheAge else {
62
return nil
63
}
64
65
guard let data = cachedResponse?.data, let json = try? JSONSerialization.jsonObject(with: data, options: []) else {
66
return nil
67
}
68
69
return json as? [String: Any]
70
}
71
72
private func cache(response: HTTPURLResponse?, for request: URLRequest, with data: Data?) {
73
guard let resp = response, let data = data else {
74
return
75
}
76
let metadata = ["cache-time": Date.now()]
77
let cachedResp = CachedURLResponse(response: resp, data: data, userInfo: metadata, storagePolicy: .allowed)
78
URLCache.shared.removeCachedResponse(for: request)
79
URLCache.shared.storeCachedResponse(cachedResp, for: request)
80
}
81
82
// Fetch items from the global pocket feed
83
func globalFeed(items: Int = 2) -> Deferred<Array<PocketStory>> {
84
let deferred = Deferred<Array<PocketStory>>()
85
86
guard let request = createGlobalFeedRequest(items: items) else {
87
deferred.fill([])
88
return deferred
89
}
90
91
if let cachedResponse = findCachedResponse(for: request), let items = cachedResponse["recommendations"] as? Array<[String: Any]> {
92
deferred.fill(PocketStory.parseJSON(list: items))
93
return deferred
94
}
95
96
urlSession.dataTask(with: request) { (data, response, error) in
97
guard let response = validatedHTTPResponse(response, contentType: "application/json"), let data = data else {
98
return deferred.fill([])
99
}
100
101
self.cache(response: response, for: request, with: data)
102
103
let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
104
guard let items = json?["recommendations"] as? Array<[String: Any]> else {
105
return deferred.fill([])
106
}
107
return deferred.fill(PocketStory.parseJSON(list: items))
108
}.resume()
109
110
return deferred
111
}
112
113
// Returns nil if the locale is not supported
114
static func IslocaleSupported(_ locale: String) -> Bool {
115
return SupportedLocales.contains(locale)
116
}
117
118
// Create the URL request to query the Pocket API. The max items that the query can return is 20
119
private func createGlobalFeedRequest(items: Int = 2) -> URLRequest? {
120
guard items > 0 && items <= 20 else {
121
return nil
122
}
123
124
let locale = Locale.current.identifier
125
let pocketLocale = locale.replacingOccurrences(of: "_", with: "-")
126
var params = [URLQueryItem(name: "count", value: String(items)), URLQueryItem(name: "locale_lang", value: pocketLocale), URLQueryItem(name: "version", value: "3")]
127
if let consumerKey = Bundle.main.object(forInfoDictionaryKey: PocketEnvAPIKey) as? String {
128
params.append(URLQueryItem(name: "consumer_key", value: consumerKey))
129
}
130
131
guard let feedURL = URL(string: pocketGlobalFeed)?.withQueryParams(params) else {
132
return nil
133
}
134
135
return URLRequest(url: feedURL, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: 5)
136
}
137
}