691 lines
23 KiB
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
|
|
}
|
|
}
|