NetNewsWire/Feedly/Sources/Feedly/FeedlyAPICaller.swift

691 lines
23 KiB
Swift

//
// FeedlyAPICaller.swift
// Account
//
// Created by Kiel Gillard on 13/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import Web
import Secrets
public protocol FeedlyAPICallerDelegate: AnyObject {
/// Implemented by the `FeedlyAccountDelegate` reauthorize the client with a fresh OAuth token so the client can retry the unauthorized request.
/// Pass `true` to the completion handler if the failing request should be retried with a fresh token or `false` if the unauthorized request should complete with the original failure error.
@MainActor func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller) async -> Bool
}
@MainActor public final class FeedlyAPICaller {
public enum API {
case sandbox
case cloud
public var baseUrlComponents: URLComponents {
var components = URLComponents()
components.scheme = "https"
switch self{
case .sandbox:
// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
components.host = "sandbox7.feedly.com"
case .cloud:
// https://developer.feedly.com/cloud/
components.host = "cloud.feedly.com"
}
return components
}
public func oauthAuthorizationClient(secretsProvider: SecretsProvider) -> OAuthAuthorizationClient {
switch self {
case .sandbox:
return .feedlySandboxClient
case .cloud:
return OAuthAuthorizationClient.feedlyCloudClient(secretsProvider: secretsProvider)
}
}
}
private let transport: Transport
private let baseURLComponents: URLComponents
private let uriComponentAllowed: CharacterSet
private let secretsProvider: SecretsProvider
private let api: FeedlyAPICaller.API
public init(transport: Transport, api: API, secretsProvider: SecretsProvider) {
self.transport = transport
self.baseURLComponents = api.baseUrlComponents
self.secretsProvider = secretsProvider
self.api = api
var urlHostAllowed = CharacterSet.urlHostAllowed
urlHostAllowed.remove("+")
uriComponentAllowed = urlHostAllowed
}
public weak var delegate: FeedlyAPICallerDelegate?
public var credentials: Credentials?
public var server: String? {
return baseURLComponents.host
}
func cancelAll() {
transport.cancelAll()
}
private var isSuspended = false
/// Cancels all pending requests rejects any that come in later
public func suspend() {
transport.cancelAll()
isSuspended = true
}
public func resume() {
isSuspended = false
}
private func send<R: Decodable & Sendable>(request: URLRequest, resultType: R.Type) async throws -> (HTTPURLResponse, R?) {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, resultType: resultType, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let response):
continuation.resume(returning: response)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func send<R: Decodable & Sendable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping @Sendable (Result<(HTTPURLResponse, R?), Error>) -> Void) {
transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { [weak self] result in
Task { @MainActor [weak self] in
switch result {
case .success:
completion(result)
case .failure(let error):
switch error {
case TransportError.httpError(let statusCode) where statusCode == 401:
assert(self == nil ? true : self?.delegate != nil, "Check the delegate is set.")
guard let self = self, let delegate = self.delegate else {
completion(result)
return
}
/// Capture the credentials before the reauthorization to check for a change.
let credentialsBefore = self.credentials
let isReauthorizedAndShouldRetry = await delegate.reauthorizeFeedlyAPICaller(self)
guard isReauthorizedAndShouldRetry else {
completion(result)
return
}
// Check for a change. Not only would it help debugging, but it'll also catch an infinitely recursive attempt to refresh.
guard let accessToken = self.credentials?.secret, accessToken != credentialsBefore?.secret else {
assertionFailure("Could not update the request with a new OAuth token. Did \(String(describing: self.delegate)) set them on \(self)?")
completion(result)
return
}
var reauthorizedRequest = request
reauthorizedRequest.setValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
self.send(request: reauthorizedRequest, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding, completion: completion)
default:
completion(result)
}
}
}
}
}
public func importOPML(_ opmlData: Data) async throws {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/opml", method: HTTPMethod.post, includeJSONHeaders: false, includeOAuthToken: true)
request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.acceptType)
request.httpBody = opmlData
let (httpResponse, _) = try await send(request: request, resultType: String.self)
if httpResponse.statusCode != HTTPResponseCode.OK {
throw URLError(.cannotDecodeContentData)
}
}
public func createCollection(named label: String) async throws -> FeedlyCollection {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
struct CreateCollectionBody: Encodable {
var label: String
}
try addObject(CreateCollectionBody(label: label), to: &request)
let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
guard let collection = collections?.first, httpResponse.statusCode == HTTPResponseCode.OK else {
throw URLError(.cannotDecodeContentData)
}
return collection
}
public func renameCollection(with id: String, to name: String) async throws -> FeedlyCollection {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
struct RenameCollectionBody: Encodable {
var id: String
var label: String
}
try addObject(RenameCollectionBody(id: id, label: name), to: &request)
let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
guard let collection = collections?.first, httpResponse.statusCode == HTTPResponseCode.OK else {
throw URLError(.cannotDecodeContentData)
}
return collection
}
private func encodeForURLPath(_ pathComponent: String) -> String? {
return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
}
public func deleteCollection(with id: String) async throws {
guard !isSuspended else { throw TransportError.suspended }
guard let encodedID = encodeForURLPath(id) else {
throw FeedlyAccountDelegateError.unexpectedResourceID(id)
}
let request = try urlRequest(path: "/v3/collections/\(encodedID)", method: HTTPMethod.delete, includeJSONHeaders: true, includeOAuthToken: true)
let (httpResponse, _) = try await send(request: request, resultType: Optional<FeedlyCollection>.self)
guard httpResponse.statusCode == HTTPResponseCode.OK else {
throw URLError(.cannotDecodeContentData)
}
}
public func removeFeed(_ feedID: String, fromCollectionWith collectionID: String) async throws {
guard !isSuspended else { throw TransportError.suspended }
guard let encodedCollectionID = encodeForURLPath(collectionID) else {
throw FeedlyAccountDelegateError.unexpectedResourceID(collectionID)
}
var components = baseURLComponents
components.percentEncodedPath = "/v3/collections/\(encodedCollectionID)/feeds/.mdelete"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.delete
addJSONHeaders(&request)
try addOAuthAccessToken(&request)
struct RemovableFeed: Encodable {
let id: String
}
try addObject([RemovableFeed(id: feedID)], to: &request)
// `resultType` is optional because the Feedly API has gone from returning an array of removed feeds to returning `null`.
// https://developer.feedly.com/v3/collections/#remove-multiple-feeds-from-a-personal-collection
let (httpResponse, _) = try await send(request: request, resultType: Optional<[FeedlyFeed]>.self)
guard httpResponse.statusCode == HTTPResponseCode.OK else {
throw URLError(.cannotDecodeContentData)
}
}
}
extension FeedlyAPICaller {
@discardableResult
@MainActor public func addFeed(with feedID: FeedlyFeedResourceID, title: String? = nil, toCollectionWith collectionID: String) async throws -> [FeedlyFeed] {
guard !isSuspended else { throw TransportError.suspended }
guard let encodedID = encodeForURLPath(collectionID) else {
throw FeedlyAccountDelegateError.unexpectedResourceID(collectionID)
}
var components = baseURLComponents
components.percentEncodedPath = "/v3/collections/\(encodedID)/feeds"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.put
addJSONHeaders(&request)
try addOAuthAccessToken(&request)
struct AddFeedBody: Encodable {
var id: String
var title: String?
}
try addObject(AddFeedBody(id: feedID.id, title: title), to: &request)
let (_, collectionFeeds) = try await send(request: request, resultType: [FeedlyFeed].self)
guard let collectionFeeds else {
throw URLError(.cannotDecodeContentData)
}
return collectionFeeds
}
}
extension FeedlyAPICaller {
/// https://tools.ietf.org/html/rfc6749#section-4.1
/// Provides the URL request that allows users to consent to the client having access to their information. Typically loaded by a web view.
/// - Parameter request: The information about the client requesting authorization to be granted access tokens.
/// - Parameter baseUrlComponents: The scheme and host of the url except for the path.
static public func authorizationCodeURLRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest {
var components = baseUrlComponents
components.path = "/v3/auth/auth"
components.queryItems = request.queryItems
guard let url = components.url else {
assert(components.scheme != nil)
assert(components.host != nil)
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
return request
}
/// Performs the request for the access token given an authorization code.
/// - Parameter authorizationRequest: The authorization code and other information the authorization server requires to grant the client access tokens on the user's behalf.
/// - Returns: On success, the access token response appropriate for concrete type's service. On failure, throws possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
public func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest) async throws -> FeedlyOAuthAccessTokenResponse {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/auth/token", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: false)
try addObject(authorizationRequest, keyEncodingStrategy: .convertToSnakeCase, to: &request)
let (_, tokenResponse) = try await send(request: request, resultType: FeedlyOAuthAccessTokenResponse.self)
guard let tokenResponse else {
throw URLError(.cannotDecodeContentData)
}
return tokenResponse
}
}
extension FeedlyAPICaller {
/// Access tokens expire. Perform a request for a fresh access token given the long life refresh token received when authorization was granted.
///
/// [Documentation](https://tools.ietf.org/html/rfc6749#section-6)
///
/// - Parameter refreshRequest: The refresh token and other information the authorization server requires to grant the client fresh access tokens on the user's behalf.
/// - Returns: On success, the access token response appropriate for concrete type's service. Both the access and refresh token should be stored, preferably on the Keychain. On failure, throws an Error.
public func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest) async throws -> FeedlyOAuthAccessTokenResponse {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/auth/token", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: false)
try addObject(refreshRequest, keyEncodingStrategy: .convertToSnakeCase, to: &request)
let (_, tokenResponse) = try await send(request: request, resultType: FeedlyOAuthAccessTokenResponse.self)
guard let tokenResponse else {
throw URLError(.cannotDecodeContentData)
}
return tokenResponse
}
}
extension FeedlyAPICaller {
public func getCollections() async throws -> Set<FeedlyCollection> {
guard !isSuspended else { throw TransportError.suspended }
let request = try urlRequest(path: "/v3/collections", method: HTTPMethod.get, includeJSONHeaders: true, includeOAuthToken: true)
let (_, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
guard let collections else {
throw URLError(.cannotDecodeContentData)
}
return Set(collections)
}
}
extension FeedlyAPICaller {
@MainActor public func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?) async throws -> FeedlyStream {
guard !isSuspended else { throw TransportError.suspended }
var components = baseURLComponents
components.path = "/v3/streams/contents"
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
if let value = continuation, !value.isEmpty {
let queryItem = URLQueryItem(name: "continuation", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "1000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
addJSONHeaders(&request)
try addOAuthAccessToken(&request)
let (_, collections) = try await send(request: request, resultType: FeedlyStream.self)
guard let collections else {
throw URLError(.cannotDecodeContentData)
}
return collections
}
}
extension FeedlyAPICaller {
@MainActor public func getStreamIDs(for resource: FeedlyResourceID, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?) async throws -> FeedlyStreamIDs {
guard !isSuspended else { throw TransportError.suspended }
var components = baseURLComponents
components.path = "/v3/streams/ids"
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
if let value = continuation, !value.isEmpty {
let queryItem = URLQueryItem(name: "continuation", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "10000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
addJSONHeaders(&request)
try addOAuthAccessToken(&request)
let (_, collections) = try await send(request: request, resultType: FeedlyStreamIDs.self)
guard let collections else {
throw URLError(.cannotDecodeContentData)
}
return collections
}
}
extension FeedlyAPICaller {
@MainActor public func getEntries(for ids: Set<String>) async throws -> [FeedlyEntry] {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/entries/.mget", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
let body = Array(ids)
try addObject(body, to: &request)
let (_, entries) = try await send(request: request, resultType: [FeedlyEntry].self)
guard let entries else {
throw URLError(.cannotDecodeContentData)
}
return entries
}
}
extension FeedlyAPICaller {
private struct MarkerEntriesBody: Encodable {
let type = "entries"
var action: String
var entryIDs: [String]
}
public func mark(_ articleIDs: Set<String>, as action: FeedlyMarkAction) async throws {
guard !isSuspended else { throw TransportError.suspended }
let articleIDChunks = Array(articleIDs).chunked(into: 300)
for articleIDChunk in articleIDChunks {
var request = try urlRequest(path: "/v3/markers", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
let body = MarkerEntriesBody(action: action.actionValue, entryIDs: Array(articleIDChunk))
try addObject(body, to: &request)
let (httpResponse, _) = try await send(request: request, resultType: String.self)
if httpResponse.statusCode != 200 {
throw URLError(.cannotDecodeContentData)
}
}
}
}
extension FeedlyAPICaller {
public func getFeeds(for query: String, count: Int, localeIdentifier: String) async throws -> FeedlyFeedsSearchResponse {
guard !isSuspended else { throw TransportError.suspended }
var components = baseURLComponents
components.path = "/v3/search/feeds"
components.queryItems = [
URLQueryItem(name: "query", value: query),
URLQueryItem(name: "count", value: String(count)),
URLQueryItem(name: "locale", value: localeIdentifier)
]
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
addJSONHeaders(&request)
let (_, searchResponse) = try await send(request: request, resultType: FeedlyFeedsSearchResponse.self)
guard let searchResponse else {
throw URLError(.cannotDecodeContentData)
}
return searchResponse
}
}
extension FeedlyAPICaller {
public func logout() async throws {
guard !isSuspended else { throw TransportError.suspended }
let request = try urlRequest(path: "/v3/auth/logout", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
let (httpResponse, _) = try await send(request: request, resultType: String.self)
if httpResponse.statusCode != HTTPResponseCode.OK {
throw URLError(.cannotDecodeContentData)
}
}
}
// MARK: - OAuth
extension FeedlyAPICaller {
private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions"
public static func oauthAuthorizationCodeGrantRequest(secretsProvider: SecretsProvider) -> URLRequest {
let client = API.cloud.oauthAuthorizationClient(secretsProvider: secretsProvider)
let authorizationRequest = OAuthAuthorizationRequest(clientID: client.id,
redirectURI: client.redirectURI,
scope: oauthAuthorizationGrantScope,
state: client.state)
let baseURLComponents = API.cloud.baseUrlComponents
return FeedlyAPICaller.authorizationCodeURLRequest(for: authorizationRequest, baseUrlComponents: baseURLComponents)
}
public static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: any Web.Transport, secretsProvider: any Secrets.SecretsProvider) async throws -> OAuthAuthorizationGrant {
let client = API.cloud.oauthAuthorizationClient(secretsProvider: secretsProvider)
let request = OAuthAccessTokenRequest(authorizationResponse: response,
scope: oauthAuthorizationGrantScope,
client: client)
let caller = FeedlyAPICaller(transport: transport, api: .cloud, secretsProvider: secretsProvider)
let response = try await caller.requestAccessToken(request)
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
return grant
}
public func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient) async throws -> OAuthAuthorizationGrant {
let request = OAuthRefreshAccessTokenRequest(refreshToken: refreshToken, scope: nil, client: client)
let response = try await refreshAccessToken(request)
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
return grant
}
}
private extension FeedlyAPICaller {
func urlRequest(path: String, method: String, includeJSONHeaders: Bool, includeOAuthToken: Bool) throws -> URLRequest {
let url = apiURL(path)
var request = URLRequest(url: url)
request.httpMethod = method
if includeJSONHeaders {
addJSONHeaders(&request)
}
if includeOAuthToken {
try addOAuthAccessToken(&request)
}
return request
}
func addJSONHeaders(_ request: inout URLRequest) {
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
}
func addOAuthAccessToken(_ request: inout URLRequest) throws {
guard let accessToken = credentials?.secret else {
throw CredentialsError.incompleteCredentials
}
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
}
func apiURL(_ path: String) -> URL {
var components = baseURLComponents
components.path = path
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
return url
}
func addObject<T: Encodable & Sendable>(_ object: T, keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys, to request: inout URLRequest) throws {
let data = try JSONEncoder().encode(object)
request.httpBody = data
}
}