[AltStoreCore] Refactors PatreonAPI to reduce duplicate logic

This commit is contained in:
Riley Testut
2023-11-15 14:13:58 -06:00
parent 417837049f
commit 7ed2dc8291
15 changed files with 389 additions and 222 deletions

View File

@@ -18,7 +18,7 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[];
#import <AltStoreCore/ALTAppPermissions.h>
#import <AltStoreCore/ALTSourceUserInfoKey.h>
#import <AltStoreCore/ALTPatreonBenefitType.h>
#import <AltStoreCore/ALTPatreonBenefitID.h>
// Shared
#import <AltStoreCore/ALTConstants.h>

View File

@@ -19,7 +19,7 @@ public class ManagedPatron: NSManagedObject, Fetchable
super.init(entity: entity, insertInto: context)
}
public init?(patron: Patron, context: NSManagedObjectContext)
public init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
{
// Only cache Patrons with non-nil names.
guard let name = patron.name else { return nil }

View File

@@ -8,27 +8,6 @@
import CoreData
extension PatreonAPI
{
struct AccountResponse: Decodable
{
struct Data: Decodable
{
struct Attributes: Decodable
{
var first_name: String?
var full_name: String
}
var id: String
var attributes: Attributes
}
var data: Data
var included: [PatronResponse]?
}
}
@objc(PatreonAccount)
public class PatreonAccount: NSManagedObject, Fetchable
{
@@ -44,18 +23,18 @@ public class PatreonAccount: NSManagedObject, Fetchable
super.init(entity: entity, insertInto: context)
}
init(response: PatreonAPI.AccountResponse, context: NSManagedObjectContext)
init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext)
{
super.init(entity: PatreonAccount.entity(), insertInto: context)
self.identifier = response.data.id
self.name = response.data.attributes.full_name
self.firstName = response.data.attributes.first_name
self.identifier = account.identifier
self.name = account.name
self.firstName = account.firstName
if let patronResponse = response.included?.first
if let altstorePledge = account.pledges?.first(where: { $0.campaign?.identifier == PatreonAPI.altstoreCampaignID })
{
let patron = Patron(response: patronResponse)
self.isPatron = (patron.status == .active)
let isActivePatron = (altstorePledge.status == .active)
self.isPatron = isActivePatron
}
else
{

View File

@@ -10,18 +10,25 @@ import Foundation
extension PatreonAPI
{
struct BenefitResponse: Decodable
typealias BenefitResponse = DataResponse<BenefitAttributes, AnyRelationships>
struct BenefitAttributes: Decodable
{
var id: String
var title: String
}
}
public struct Benefit: Hashable
extension PatreonAPI
{
public var type: ALTPatreonBenefitType
init(response: PatreonAPI.BenefitResponse)
public struct Benefit: Hashable
{
self.type = ALTPatreonBenefitType(response.id)
public var name: String
public var identifier: ALTPatreonBenefitID
internal init(response: BenefitResponse)
{
self.name = response.attributes.title
self.identifier = ALTPatreonBenefitID(response.id)
}
}
}

View File

@@ -10,18 +10,25 @@ import Foundation
extension PatreonAPI
{
struct CampaignResponse: Decodable
typealias CampaignResponse = DataResponse<CampaignAttributes, AnyRelationships>
struct CampaignAttributes: Decodable
{
var id: String
var url: URL
}
}
public struct Campaign
extension PatreonAPI
{
public var identifier: String
init(response: PatreonAPI.CampaignResponse)
public struct Campaign
{
self.identifier = response.id
public var identifier: String
public var url: URL
internal init(response: PatreonAPI.CampaignResponse)
{
self.identifier = response.id
self.url = response.attributes.url
}
}
}

View File

@@ -0,0 +1,161 @@
//
// PatreonAPI+Responses.swift
// AltStoreCore
//
// Created by Riley Testut on 11/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
protocol ResponseData: Decodable
{
}
// Allows us to use Arrays with Response<> despite them not conforming to `ItemResponse`
extension Array: ResponseData where Element: ItemResponse
{
}
protocol ItemResponse: ResponseData
{
var id: String { get }
var type: String { get }
}
extension PatreonAPI
{
struct Response<Data: ResponseData>: Decodable
{
var data: Data
var included: IncludedResponses?
var links: [String: URL]?
}
struct AnyItemResponse: ItemResponse
{
var id: String
var type: String
}
struct DataResponse<Attributes: Decodable, Relationships: Decodable>: ItemResponse
{
var id: String
var type: String
var attributes: Attributes
var relationships: Relationships?
}
// `Never` only conforms to Decodable from iOS 17 onwards,
// so use our own "Empty" type for DataResponses without relationships.
struct AnyRelationships: Decodable
{
}
struct IncludedResponses: Decodable
{
var items: [IncludedItem]
var campaigns: [String: CampaignResponse]
var patrons: [String: PatronResponse]
var tiers: [String: TierResponse]
var benefits: [String: BenefitResponse]
init(from decoder: Decoder) throws
{
let container = try decoder.singleValueContainer()
self.items = try container.decode([IncludedItem].self)
var campaignsByID = [String: PatreonAPI.CampaignResponse]()
var patronsByID = [String: PatreonAPI.PatronResponse]()
var tiersByID = [String: PatreonAPI.TierResponse]()
var benefitsByID = [String: PatreonAPI.BenefitResponse]()
for response in self.items
{
switch response
{
case .campaign(let response): campaignsByID[response.id] = response
case .patron(let response): patronsByID[response.id] = response
case .tier(let response): tiersByID[response.id] = response
case .benefit(let response): benefitsByID[response.id] = response
case .unknown: break // Ignore
}
}
self.campaigns = campaignsByID
self.patrons = patronsByID
self.tiers = tiersByID
self.benefits = benefitsByID
}
}
enum IncludedItem: ItemResponse
{
case tier(TierResponse)
case benefit(BenefitResponse)
case patron(PatronResponse)
case campaign(CampaignResponse)
case unknown(AnyItemResponse)
var id: String {
switch self
{
case .tier(let response): return response.id
case .benefit(let response): return response.id
case .patron(let response): return response.id
case .campaign(let response): return response.id
case .unknown(let response): return response.id
}
}
var type: String {
switch self
{
case .tier(let response): return response.type
case .benefit(let response): return response.type
case .patron(let response): return response.type
case .campaign(let response): return response.type
case .unknown(let response): return response.type
}
}
private enum CodingKeys: String, CodingKey
{
case type
}
init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type
{
case "tier":
let response = try TierResponse(from: decoder)
self = .tier(response)
case "benefit":
let response = try BenefitResponse(from: decoder)
self = .benefit(response)
case "member":
let response = try PatronResponse(from: decoder)
self = .patron(response)
case "campaign":
let response = try CampaignResponse(from: decoder)
self = .campaign(response)
default:
Logger.main.error("Unrecognized PatreonAPI response type: \(type, privacy: .public).")
let response = try AnyItemResponse(from: decoder)
self = .unknown(response)
}
}
}
}

View File

@@ -13,8 +13,6 @@ import CoreData
private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2"
private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt"
private let campaignID = "2863968"
typealias PatreonAPIError = PatreonAPIErrorCode.Error
enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
{
@@ -34,42 +32,17 @@ enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
extension PatreonAPI
{
static let altstoreCampaignID = "2863968"
typealias FetchAccountResponse = Response<UserAccountResponse>
typealias FriendZonePatronsResponse = Response<[PatronResponse]>
enum AuthorizationType
{
case none
case user
case creator
}
enum AnyResponse: Decodable
{
case tier(TierResponse)
case benefit(BenefitResponse)
enum CodingKeys: String, CodingKey
{
case type
}
init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type
{
case "tier":
let tier = try TierResponse(from: decoder)
self = .tier(tier)
case "benefit":
let benefit = try BenefitResponse(from: decoder)
self = .benefit(benefit)
default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unrecognized Patreon response type.")
}
}
}
}
public class PatreonAPI: NSObject
@@ -138,14 +111,17 @@ public extension PatreonAPI
func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
{
var components = URLComponents(string: "/api/oauth2/v2/identity")!
components.queryItems = [URLQueryItem(name: "include", value: "memberships"),
components.queryItems = [URLQueryItem(name: "include", value: "memberships.campaign.tiers,memberships.currently_entitled_tiers.benefits"),
URLQueryItem(name: "fields[user]", value: "first_name,full_name"),
URLQueryItem(name: "fields[member]", value: "full_name,patron_status")]
URLQueryItem(name: "fields[tier]", value: "title"),
URLQueryItem(name: "fields[benefit]", value: "title"),
URLQueryItem(name: "fields[campaign]", value: "url"),
let requestURL = components.url(relativeTo: self.baseURL)!
let request = URLRequest(url: requestURL)
self.send(request, authorizationType: .user) { (result: Result<AccountResponse, Swift.Error>) in
self.send(request, authorizationType: .user) { (result: Result<FetchAccountResponse, Swift.Error>) in
switch result
{
case .failure(~PatreonAPIErrorCode.notAuthenticated):
@@ -153,10 +129,15 @@ public extension PatreonAPI
completion(.failure(PatreonAPIError(.notAuthenticated)))
}
case .failure(let error): completion(.failure(error))
case .failure(let error as NSError):
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
completion(.failure(error))
case .success(let response):
let account = PatreonAPI.UserAccount(response: response.data, including: response.included)
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let account = PatreonAccount(response: response, context: context)
let account = PatreonAccount(account: account, context: context)
Keychain.shared.patreonAccountID = account.identifier
completion(.success(account))
}
@@ -166,57 +147,34 @@ public extension PatreonAPI
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void)
{
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(campaignID)/members")!
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(PatreonAPI.altstoreCampaignID)/members")!
components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"),
URLQueryItem(name: "fields[tier]", value: "title"),
URLQueryItem(name: "fields[benefit]", value: "title"),
URLQueryItem(name: "fields[member]", value: "full_name,patron_status"),
URLQueryItem(name: "page[size]", value: "1000")]
let requestURL = components.url(relativeTo: self.baseURL)!
struct Response: Decodable
{
var data: [PatronResponse]
var included: [AnyResponse]
var links: [String: URL]?
}
var allPatrons = [Patron]()
func fetchPatrons(url: URL)
{
let request = URLRequest(url: url)
self.send(request, authorizationType: .creator) { (result: Result<Response, Swift.Error>) in
self.send(request, authorizationType: .creator) { (result: Result<FriendZonePatronsResponse, Swift.Error>) in
switch result
{
case .failure(let error): completion(.failure(error))
case .success(let response):
let tiers = response.included.compactMap { (response) -> Tier? in
switch response
{
case .tier(let tierResponse): return Tier(response: tierResponse)
case .benefit: return nil
}
}
let tiersByIdentifier = Dictionary(tiers.map { ($0.identifier, $0) }, uniquingKeysWith: { (a, b) in return a })
let patrons = response.data.map { (response) -> Patron in
let patron = Patron(response: response)
for tierID in response.relationships?.currently_entitled_tiers.data ?? []
{
guard let tier = tiersByIdentifier[tierID.id] else { continue }
patron.benefits.formUnion(tier.benefits)
}
case .success(let patronsResponse):
let patrons = patronsResponse.data.map { (response) -> Patron in
let patron = Patron(response: response, including: patronsResponse.included)
return patron
}.filter { $0.benefits.contains(where: { $0.type == .credits }) }
}.filter { $0.benefits.contains(where: { $0.identifier == .credits }) }
allPatrons.append(contentsOf: patrons)
if let nextURL = response.links?["next"]
if let nextURL = patronsResponse.links?["next"]
{
fetchPatrons(url: nextURL)
}

View File

@@ -10,38 +10,22 @@ import Foundation
extension PatreonAPI
{
struct PatronResponse: Decodable
typealias PatronResponse = DataResponse<PatronAttributes, PatronRelationships>
struct PatronAttributes: Decodable
{
struct Attributes: Decodable
{
var full_name: String?
var patron_status: String?
}
struct Relationships: Decodable
{
struct Tiers: Decodable
{
struct TierID: Decodable
{
var id: String
var type: String
}
var data: [TierID]
}
var currently_entitled_tiers: Tiers
}
var id: String
var attributes: Attributes
var relationships: Relationships?
var full_name: String?
var patron_status: String?
}
struct PatronRelationships: Decodable
{
var campaign: Response<AnyItemResponse>?
var currently_entitled_tiers: Response<[AnyItemResponse]>?
}
}
extension Patron
extension PatreonAPI
{
public enum Status: String, Decodable
{
@@ -50,29 +34,46 @@ extension Patron
case former = "former_patron"
case unknown = "unknown"
}
}
public class Patron
{
public var name: String?
public var identifier: String
public var status: Status
public var benefits: Set<Benefit> = []
init(response: PatreonAPI.PatronResponse)
// Roughly equivalent to AltStoreCore.Pledge
public class Patron
{
self.name = response.attributes.full_name
self.identifier = response.id
public var name: String?
public var identifier: String
public var status: Status
if let status = response.attributes.patron_status
// Relationships
public var campaign: Campaign?
public var tiers: Set<Tier> = []
public var benefits: Set<Benefit> = []
internal init(response: PatronResponse, including included: IncludedResponses?)
{
self.status = Status(rawValue: status) ?? .unknown
}
else
{
self.status = .unknown
self.name = response.attributes.full_name
self.identifier = response.id
if let status = response.attributes.patron_status
{
self.status = Status(rawValue: status) ?? .unknown
}
else
{
self.status = .unknown
}
guard let included, let relationships = response.relationships else { return }
if let campaignID = relationships.campaign?.data.id, let response = included.campaigns[campaignID]
{
let campaign = Campaign(response: response)
self.campaign = campaign
}
let tiers = (relationships.currently_entitled_tiers?.data ?? []).compactMap { included.tiers[$0.id] }.map { Tier(response: $0, including: included) }
self.tiers = Set(tiers)
let benefits = tiers.flatMap { $0.benefits }
self.benefits = Set(benefits)
}
}
}

View File

@@ -10,41 +10,38 @@ import Foundation
extension PatreonAPI
{
struct TierResponse: Decodable
typealias TierResponse = DataResponse<TierAttributes, TierRelationships>
struct TierAttributes: Decodable
{
struct Attributes: Decodable
{
var title: String
}
struct Relationships: Decodable
{
struct Benefits: Decodable
{
var data: [BenefitResponse]
}
var benefits: Benefits
}
var id: String
var attributes: Attributes
var relationships: Relationships
var title: String
}
struct TierRelationships: Decodable
{
var benefits: Response<[AnyItemResponse]>?
}
}
public struct Tier
extension PatreonAPI
{
public var name: String
public var identifier: String
public var benefits: [Benefit] = []
init(response: PatreonAPI.TierResponse)
public struct Tier: Hashable
{
self.name = response.attributes.title
self.identifier = response.id
self.benefits = response.relationships.benefits.data.map(Benefit.init(response:))
public var name: String
public var identifier: String
// Relationships
public var benefits: [Benefit] = []
internal init(response: TierResponse, including included: IncludedResponses?)
{
self.name = response.attributes.title
self.identifier = response.id
guard let included, let benefitIDs = response.relationships?.benefits?.data.map(\.id) else { return }
let benefits = benefitIDs.compactMap { included.benefits[$0] }.map(Benefit.init(response:))
self.benefits = benefits
}
}
}

View File

@@ -0,0 +1,49 @@
//
// Account.swift
// AltStoreCore
//
// Created by Riley Testut on 11/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
extension PatreonAPI
{
typealias UserAccountResponse = DataResponse<UserAccountAttributes, AnyRelationships>
struct UserAccountAttributes: Decodable
{
var first_name: String?
var full_name: String
}
}
extension PatreonAPI
{
public struct UserAccount
{
var name: String
var firstName: String?
var identifier: String
// Relationships
var pledges: [Patron]?
init(response: UserAccountResponse, including included: IncludedResponses?)
{
self.identifier = response.id
self.name = response.attributes.full_name
self.firstName = response.attributes.first_name
guard let included else { return }
let patrons = included.patrons.values.compactMap { response -> Patron? in
let patron = Patron(response: response, including: included)
return patron
}
self.pledges = patrons
}
}
}

View File

@@ -0,0 +1,13 @@
//
// ALTPatreonBenefitType.h
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
typedef NSString *ALTPatreonBenefitID NS_TYPED_EXTENSIBLE_ENUM;
extern ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess;
extern ALTPatreonBenefitID const ALTPatreonBenefitIDCredits;

View File

@@ -0,0 +1,12 @@
//
// ALTPatreonBenefitType.m
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "ALTPatreonBenefitID.h"
ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess = @"1186336";
ALTPatreonBenefitID const ALTPatreonBenefitIDCredits = @"1186340";

View File

@@ -1,13 +0,0 @@
//
// ALTPatreonBenefitType.h
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
typedef NSString *ALTPatreonBenefitType NS_TYPED_EXTENSIBLE_ENUM;
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess;
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits;

View File

@@ -1,12 +0,0 @@
//
// ALTPatreonBenefitType.m
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "ALTPatreonBenefitType.h"
ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess = @"1186336";
ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits = @"1186340";