Skip to content

Commit

Permalink
OfferingsManager/OfferingsFactory: using OfferingsResponse
Browse files Browse the repository at this point in the history
This takes full advantage of `Decodable` when decoding and creating `Offerings`.
  • Loading branch information
NachoSoto committed Apr 11, 2022
1 parent 11d5c7f commit 7456ec6
Show file tree
Hide file tree
Showing 16 changed files with 414 additions and 396 deletions.
12 changes: 12 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
57CFB96D27FE0E79002A6730 /* MockCurrentUserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57CFB96B27FE0E79002A6730 /* MockCurrentUserProvider.swift */; };
57D04BB827D947C6006DAC06 /* HTTPResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */; };
57D5414227F656D9004CC35C /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D5414127F656D9004CC35C /* NetworkError.swift */; };
57D5412E27F6311C004CC35C /* OfferingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D5412D27F6311C004CC35C /* OfferingsResponse.swift */; };
57DC9F4627CC2E4900DA6AF9 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DC9F4527CC2E4900DA6AF9 /* HTTPRequest.swift */; };
57DC9F4A27CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */; };
57E0473B277260DE0082FE91 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 57E0473A277260DE0082FE91 /* SnapshotTesting */; };
Expand Down Expand Up @@ -693,6 +694,7 @@
57CFB96B27FE0E79002A6730 /* MockCurrentUserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCurrentUserProvider.swift; sourceTree = "<group>"; };
57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseTests.swift; sourceTree = "<group>"; };
57D5414127F656D9004CC35C /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
57D5412D27F6311C004CC35C /* OfferingsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferingsResponse.swift; sourceTree = "<group>"; };
57DC9F4527CC2E4900DA6AF9 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = "<group>"; };
57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatusCodeTests.swift; sourceTree = "<group>"; };
57E0474B27729A1E0082FE91 /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1392,6 +1394,7 @@
35D832CB262A5B3400E60AC5 /* Networking */ = {
isa = PBXGroup;
children = (
57D5412C27F63108004CC35C /* Responses */,
B34605A1279A6E380031CA74 /* Caching */,
B34605AA279A6E380031CA74 /* Operations */,
B3C4AAD426B8911300E1B3C8 /* Backend.swift */,
Expand Down Expand Up @@ -1561,6 +1564,14 @@
path = ..;
sourceTree = "<group>";
};
57D5412C27F63108004CC35C /* Responses */ = {
isa = PBXGroup;
children = (
57D5412D27F6311C004CC35C /* OfferingsResponse.swift */,
);
path = Responses;
sourceTree = "<group>";
};
B324DC482720C15300103EE9 /* Error Handling */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -2091,6 +2102,7 @@
2DDF41B624F6F387005BC22D /* ASN1ObjectIdentifierBuilder.swift in Sources */,
35D832D2262E56DB00E60AC5 /* HTTPStatusCode.swift in Sources */,
572247D127BEC28E00C524A7 /* Array+Extensions.swift in Sources */,
57D5412E27F6311C004CC35C /* OfferingsResponse.swift in Sources */,
57AC4C1C2770F56200DDE30F /* SK1StoreProduct.swift in Sources */,
B34605C3279A6E380031CA74 /* PostOfferForSigningOperation.swift in Sources */,
B34605C7279A6E380031CA74 /* SubscriberAttributesMarshaller.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion Sources/Networking/Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Backend {

typealias CustomerInfoResponseHandler = (Result<CustomerInfo, BackendError>) -> Void
typealias IntroEligibilityResponseHandler = ([String: IntroEligibility], BackendError?) -> Void
typealias OfferingsResponseHandler = (Result<[String: Any], BackendError>) -> Void
typealias OfferingsResponseHandler = (Result<OfferingsResponse, BackendError>) -> Void
typealias OfferSigningResponseHandler = (Result<PostOfferForSigningOperation.SigningData, BackendError>) -> Void
typealias SimpleResponseHandler = (BackendError?) -> Void
typealias LogInResponseHandler = (Result<(info: CustomerInfo, created: Bool), BackendError>) -> Void
Expand Down
2 changes: 1 addition & 1 deletion Sources/Networking/Caching/OfferingsCallback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ import Foundation
struct OfferingsCallback: CacheKeyProviding {

let cacheKey: String
let completion: (Result<[String: Any], BackendError>) -> Void
let completion: (Result<OfferingsResponse, BackendError>) -> Void

}
3 changes: 2 additions & 1 deletion Sources/Networking/Operations/GetOfferingsOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ private extension GetOfferingsOperation {

let request = HTTPRequest(method: .get, path: .getOfferings(appUserID: appUserID))

httpClient.perform(request, authHeaders: self.authHeaders) { (response: HTTPResponse<[String: Any]>.Result) in
httpClient.perform(request,
authHeaders: self.authHeaders) { (response: HTTPResponse<OfferingsResponse>.Result) in
defer {
completion()
}
Expand Down
57 changes: 57 additions & 0 deletions Sources/Networking/Responses/OfferingsResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// OfferingsResponse.swift
//
// Created by Nacho Soto on 3/31/22.

import Foundation

// swiftlint:disable nesting

struct OfferingsResponse {

struct Offering {

struct Package {

let identifier: String
let platformProductIdentifier: String

}

let identifier: String
let description: String
let packages: [Package]

}

let currentOfferingId: String?
let offerings: [Offering]

}

extension OfferingsResponse {

var productIdentifiers: Set<String> {
return Set(
self.offerings
.lazy
.flatMap { $0.packages }
.map { $0.platformProductIdentifier }
)
}

}

extension OfferingsResponse.Offering.Package: Decodable {}
extension OfferingsResponse.Offering: Decodable {}
extension OfferingsResponse: Decodable {}

extension OfferingsResponse: HTTPResponseBody {}
80 changes: 40 additions & 40 deletions Sources/Purchasing/OfferingsFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,66 +17,66 @@ import StoreKit

class OfferingsFactory {

func createOfferings(from storeProductsByID: [String: StoreProduct], data: [String: Any]) -> Offerings? {
guard let offeringsData = data["offerings"] as? [[String: Any]] else {
return nil
}

let offerings = offeringsData.reduce([String: Offering]()) { (dict, offeringData) -> [String: Offering] in
var dict = dict
if let offering = createOffering(from: storeProductsByID,
offeringData: offeringData) {
dict[offering.identifier] = offering
if offering.availablePackages.isEmpty {
Logger.warn(Strings.offering.offering_empty(offeringIdentifier: offering.identifier))
}
func createOfferings(from storeProductsByID: [String: StoreProduct], data: OfferingsResponse) -> Offerings? {
let offerings: [String: Offering] = data
.offerings
.compactMap { offeringData in
createOffering(from: storeProductsByID, offering: offeringData)
}
return dict
}
.dictionaryAllowingDuplicateKeys { $0.identifier }

guard !offerings.isEmpty else {
return nil
}

let currentOfferingID = data["current_offering_id"] as? String

return Offerings(offerings: offerings, currentOfferingID: currentOfferingID)
return Offerings(offerings: offerings, currentOfferingID: data.currentOfferingId)
}

func createOffering(from storeProductsByID: [String: StoreProduct], offeringData: [String: Any]) -> Offering? {
guard let offeringIdentifier = offeringData["identifier"] as? String,
let packagesData = offeringData["packages"] as? [[String: Any]],
let serverDescription = offeringData["description"] as? String else {
return nil
func createOffering(
from storeProductsByID: [String: StoreProduct],
offering: OfferingsResponse.Offering
) -> Offering? {
let availablePackages: [Package] = offering.packages.compactMap { package in
createPackage(with: package, productsByID: storeProductsByID, offeringIdentifier: offering.identifier)
}

let availablePackages = packagesData.compactMap { packageData -> Package? in
createPackage(with: packageData,
storeProductsByID: storeProductsByID,
offeringIdentifier: offeringIdentifier)
}
guard !availablePackages.isEmpty else {
Logger.warn(Strings.offering.offering_empty(offeringIdentifier: offering.identifier))
return nil
}

return Offering(identifier: offeringIdentifier, serverDescription: serverDescription,
return Offering(identifier: offering.identifier,
serverDescription: offering.description,
availablePackages: availablePackages)
}

func createPackage(with data: [String: Any],
storeProductsByID: [String: StoreProduct],
offeringIdentifier: String) -> Package? {
guard let platformProductIdentifier = data["platform_product_identifier"] as? String,
let product = storeProductsByID[platformProductIdentifier],
let identifier = data["identifier"] as? String else {
func createPackage(
with data: OfferingsResponse.Offering.Package,
productsByID: [String: StoreProduct],
offeringIdentifier: String
) -> Package? {
guard let product = productsByID[data.platformProductIdentifier] else {
return nil
}

let packageType = Package.packageType(from: identifier)
return Package(identifier: identifier,
packageType: packageType,
storeProduct: product,
offeringIdentifier: offeringIdentifier)
return .init(package: data,
product: product,
offeringIdentifier: offeringIdentifier)
}

}

private extension Package {

convenience init(
package: OfferingsResponse.Offering.Package,
product: StoreProduct,
offeringIdentifier: String
) {
self.init(identifier: package.identifier,
packageType: Package.packageType(from: package.identifier),
storeProduct: product,
offeringIdentifier: offeringIdentifier)
}

}
93 changes: 22 additions & 71 deletions Sources/Purchasing/OfferingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class OfferingsManager {
self.productsManager = productsManager
}

func offerings(appUserID: String, completion: ((Offerings?, Error?) -> Void)?) {
func offerings(appUserID: String, completion: ((Result<Offerings, Error>) -> Void)?) {
guard let cachedOfferings = deviceCache.cachedOfferings else {
Logger.debug(Strings.offering.no_cached_offerings_fetching_from_network)
systemInfo.isApplicationBackgrounded { isAppBackgrounded in
Expand All @@ -49,9 +49,7 @@ class OfferingsManager {
}

Logger.debug(Strings.offering.vending_offerings_cache)
dispatchCompletionOnMainThreadIfPossible(completion,
offerings: cachedOfferings,
error: nil)
dispatchCompletionOnMainThreadIfPossible(completion, result: .success(cachedOfferings))

systemInfo.isApplicationBackgrounded { isAppBackgrounded in
if self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: isAppBackgrounded) {
Expand All @@ -68,13 +66,17 @@ class OfferingsManager {
}
}

func updateOfferingsCache(appUserID: String, isAppBackgrounded: Bool, completion: ((Offerings?, Error?) -> Void)?) {
func updateOfferingsCache(
appUserID: String,
isAppBackgrounded: Bool,
completion: ((Result<Offerings, Error>) -> Void)?
) {
deviceCache.setOfferingsCacheTimestampToNow()
operationDispatcher.dispatchOnWorkerThread(withRandomDelay: isAppBackgrounded) {
self.backend.getOfferings(appUserID: appUserID) { result in
switch result {
case let .success(data):
self.handleOfferingsBackendResult(with: data, completion: completion)
case let .success(response):
self.handleOfferingsBackendResult(with: response, completion: completion)

case let .failure(error):
self.handleOfferingsUpdateError(.backendError(error), completion: completion)
Expand All @@ -96,8 +98,12 @@ class OfferingsManager {

private extension OfferingsManager {

func handleOfferingsBackendResult(with data: [String: Any], completion: ((Offerings?, Error?) -> Void)?) {
let productIdentifiers = extractProductIdentifiers(fromOfferingsData: data)
func handleOfferingsBackendResult(
with response: OfferingsResponse,
completion: ((Result<Offerings, Error>) -> Void)?
) {
let productIdentifiers = response.productIdentifiers

guard !productIdentifiers.isEmpty else {
let errorMessage = Strings.offering.configuration_error_no_products_for_offering.description
self.handleOfferingsUpdateError(.configurationError(errorMessage),
Expand Down Expand Up @@ -125,42 +131,26 @@ private extension OfferingsManager {
)
}

if let createdOfferings = self.offeringsFactory.createOfferings(from: productsByID,
data: data) {
if let createdOfferings = self.offeringsFactory.createOfferings(from: productsByID, data: response) {
self.deviceCache.cache(offerings: createdOfferings)
self.dispatchCompletionOnMainThreadIfPossible(completion,
offerings: createdOfferings,
error: nil)
self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(createdOfferings))
} else {
self.handleOfferingsUpdateError(.noOfferingsFound(), completion: completion)
}
}
}

func handleOfferingsUpdateError(_ error: Error, completion: ((Offerings?, Error?) -> Void)?) {
func handleOfferingsUpdateError(_ error: Error, completion: ((Result<Offerings, Error>) -> Void)?) {
Logger.appleError(Strings.offering.fetching_offerings_error(error: error.localizedDescription))
deviceCache.clearOfferingsCacheTimestamp()
dispatchCompletionOnMainThreadIfPossible(completion,
offerings: nil,
error: error)
dispatchCompletionOnMainThreadIfPossible(completion, result: .failure(error))
}

func extractProductIdentifiers(fromOfferingsData offeringsData: [String: Any]) -> Set<String> {
// Fixme: parse Data directly instead of converting from Data to Dictionary back to Data
guard let data = try? JSONSerialization.data(withJSONObject: offeringsData),
let response: OfferingsResponse = try? JSONDecoder.default.decode(jsonData: data) else {
return []
}

return Set(response.productIdentifiers)
}

func dispatchCompletionOnMainThreadIfPossible(_ completion: ((Offerings?, Error?) -> Void)?,
offerings: Offerings?,
error: Error?) {
func dispatchCompletionOnMainThreadIfPossible(_ completion: ((Result<Offerings, Error>) -> Void)?,
result: Result<Offerings, Error>) {
if let completion = completion {
operationDispatcher.dispatchOnMainThread {
completion(offerings, error)
completion(result)
}
}
}
Expand Down Expand Up @@ -217,42 +207,3 @@ extension OfferingsManager.Error: ErrorCodeConvertible {
}

}

// swiftlint:disable nesting

private struct OfferingsResponse {

struct Offering {

struct Package {

let identifier: String
let platformProductIdentifier: String

}

let description: String
let identifier: String
let packages: [Package]

}

let currentOfferingId: String
let offerings: [Offering]

}

extension OfferingsResponse {

var productIdentifiers: [String] {
return self.offerings
.lazy
.flatMap { $0.packages }
.map { $0.platformProductIdentifier }
}

}

extension OfferingsResponse.Offering.Package: Decodable {}
extension OfferingsResponse.Offering: Decodable {}
extension OfferingsResponse: Decodable {}
4 changes: 2 additions & 2 deletions Sources/Purchasing/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -869,8 +869,8 @@ public extension Purchases {
* - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products)
*/
@objc func getOfferings(completion: @escaping (Offerings?, Error?) -> Void) {
offeringsManager.offerings(appUserID: appUserID) { offerings, error in
completion(offerings, error?.asPurchasesError)
offeringsManager.offerings(appUserID: appUserID) { result in
completion(result.value, result.error?.asPurchasesError)
}
}

Expand Down
Loading

0 comments on commit 7456ec6

Please sign in to comment.