diff --git a/Sources/Networking/Responses/OfferingsResponse.swift b/Sources/Networking/Responses/OfferingsResponse.swift index 5fd39d7ded..d513b2d5a4 100644 --- a/Sources/Networking/Responses/OfferingsResponse.swift +++ b/Sources/Networking/Responses/OfferingsResponse.swift @@ -29,6 +29,8 @@ struct OfferingsResponse { let identifier: String let description: String let packages: [Package] + @DefaultDecodable.EmptyDictionary + var metadata: [String: AnyDecodable] } diff --git a/Sources/Purchasing/Offering.swift b/Sources/Purchasing/Offering.swift index fed7c3f09b..5638dab1e6 100644 --- a/Sources/Purchasing/Offering.swift +++ b/Sources/Purchasing/Offering.swift @@ -38,6 +38,13 @@ import Foundation */ @objc public let serverDescription: String + private let _metadata: Metadata + + /** + Offering metadata defined in RevenueCat dashboard. + */ + @objc public var metadata: [String: Any] { self._metadata.data } + /** Array of ``Package`` objects available for purchase. */ @@ -110,10 +117,11 @@ import Foundation } // swiftlint:disable:next cyclomatic_complexity - init(identifier: String, serverDescription: String, availablePackages: [Package]) { + init(identifier: String, serverDescription: String, metadata: [String: Any], availablePackages: [Package]) { self.identifier = identifier self.serverDescription = serverDescription self.availablePackages = availablePackages + self._metadata = Metadata(data: metadata) var foundPackages: [PackageType: Package] = [:] @@ -165,6 +173,21 @@ import Foundation } +extension Offering { + + /** + - Returns: the `metadata` value associated to `key` for the expected type, + or `defaultValue` if not found, or it's not the expected type. + */ + public func getMetadataValue(for key: String, default: T) -> T { + guard let rawValue = self.metadata[key], let value = rawValue as? T else { + return `default` + } + return value + } + +} + extension Offering: Identifiable { /// The stable identity of the entity associated with this instance. @@ -176,6 +199,14 @@ extension Offering: Sendable {} // MARK: - Private +private extension Offering { + + struct Metadata { + let data: [String: Any] + } + +} + private extension Offering { static func checkForNilAndLogReplacement(previousPackages: [PackageType: Package], newPackage: Package) { diff --git a/Sources/Purchasing/OfferingsFactory.swift b/Sources/Purchasing/OfferingsFactory.swift index 203a6d71a8..f929ec744c 100644 --- a/Sources/Purchasing/OfferingsFactory.swift +++ b/Sources/Purchasing/OfferingsFactory.swift @@ -49,6 +49,7 @@ class OfferingsFactory { return Offering(identifier: offering.identifier, serverDescription: offering.description, + metadata: offering.metadata.mapValues(\.asAny), availablePackages: availablePackages) } diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCOfferingAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCOfferingAPI.m index 2f416f8aac..96d63fe9e2 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCOfferingAPI.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCOfferingAPI.m @@ -32,8 +32,9 @@ + (void)checkAPI { RCPackage *p = [o packageWithIdentifier:nil]; p = [o packageWithIdentifier:@""]; RCPackage *ok = [o objectForKeyedSubscript:@""]; + NSDictionary *md = o.metadata; - NSLog(o, i, sd, a, l, an, s, t, tm, m, w, p, ok); + NSLog(o, i, sd, a, l, an, s, t, tm, m, w, p, ok, md); } @end diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/OfferingAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/OfferingAPI.swift index a61f6463cf..577f1423c0 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/OfferingAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/OfferingAPI.swift @@ -29,6 +29,11 @@ func checkOfferingAPI() { var pPack: Package? = off.package(identifier: "") pPack = off.package(identifier: nil) let package: Package? = off[""] + let metadata: [String: Any] = off.metadata + let metadataString: String = off.getMetadataValue(for: "", default: "") + let metadataInt: Int = off.getMetadataValue(for: "", default: 0) + let metadataOptionalInt: Int? = off.getMetadataValue(for: "", default: nil) - print(off!, ident, sDesc, aPacks, lPack!, annPack!, smPack!, thmPack!, twmPack!, mPack!, wPack!, pPack!, package!) + print(off!, ident, sDesc, aPacks, lPack!, annPack!, smPack!, thmPack!, twmPack!, + mPack!, wPack!, pPack!, package!, metadata, metadataString, metadataInt, metadataOptionalInt!) } diff --git a/Tests/BackendIntegrationTests/__Snapshots__/LoadShedderIntegrationTests/testCanGetOfferings.1.json b/Tests/BackendIntegrationTests/__Snapshots__/LoadShedderIntegrationTests/testCanGetOfferings.1.json index c990bd9a5e..f4fef31596 100644 --- a/Tests/BackendIntegrationTests/__Snapshots__/LoadShedderIntegrationTests/testCanGetOfferings.1.json +++ b/Tests/BackendIntegrationTests/__Snapshots__/LoadShedderIntegrationTests/testCanGetOfferings.1.json @@ -4,6 +4,9 @@ { "description" : "standard set of packages", "identifier" : "default", + "metadata" : { + "meta" : "data" + }, "packages" : [ { "identifier" : "$rc_monthly", diff --git a/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json b/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json index cfe077fe91..89e9ac31b7 100644 --- a/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json +++ b/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json @@ -4,6 +4,9 @@ { "description" : "standard set of packages", "identifier" : "default", + "metadata" : { + "meta" : "data" + }, "packages" : [ { "identifier" : "$rc_monthly", @@ -22,6 +25,9 @@ { "description" : "Coins", "identifier" : "coins", + "metadata" : { + + }, "packages" : [ { "identifier" : "10.coins", diff --git a/Tests/UnitTests/Mocks/MockOfferingsFactory.swift b/Tests/UnitTests/Mocks/MockOfferingsFactory.swift index c660592280..41bfbadf03 100644 --- a/Tests/UnitTests/Mocks/MockOfferingsFactory.swift +++ b/Tests/UnitTests/Mocks/MockOfferingsFactory.swift @@ -30,6 +30,7 @@ class MockOfferingsFactory: OfferingsFactory { "base": Offering( identifier: "base", serverDescription: "This is the base offering", + metadata: [:], availablePackages: [ Package(identifier: "$rc_monthly", packageType: PackageType.monthly, diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json index e815753a5b..9b84d11293 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json @@ -24,6 +24,37 @@ "platform_product_identifier": "com.revenuecat.other_product" } ] + }, + { + "description": "offering with metadata", + "identifier": "metadata", + "metadata": { + "int": 5, + "double": 5.5, + "boolean": true, + "string": "five", + "array": ["five"], + "dictionary": { + "string": "five" + } + }, + "packages": [ + { + "identifier": "$rc_lifetime", + "platform_product_identifier": "com.revenuecat.other_product" + } + ] + }, + { + "description": "offering with null metadata", + "identifier": "nullmetadata", + "metadata": null, + "packages": [ + { + "identifier": "$rc_lifetime", + "platform_product_identifier": "com.revenuecat.other_product" + } + ] } ] } diff --git a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift index fbbea51267..fd54d95ba7 100644 --- a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift +++ b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift @@ -27,7 +27,7 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { func testDecodesAllOfferings() throws { expect(self.response.currentOfferingId) == "default" - expect(self.response.offerings).to(haveCount(2)) + expect(self.response.offerings).to(haveCount(4)) } func testDecodesFirstOffering() throws { @@ -35,10 +35,11 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { expect(offering.identifier) == "default" expect(offering.description) == "standard set of packages" + expect(offering.metadata) == [:] expect(offering.packages).to(haveCount(2)) - let package1 = offering.packages[0] - let package2 = offering.packages[1] + let package1 = try XCTUnwrap(offering.packages.first) + let package2 = try XCTUnwrap(offering.packages[safe: 1]) expect(package1.identifier) == PackageType.monthly.description expect(package1.platformProductIdentifier) == "com.revenuecat.monthly_4.99.1_week_intro" @@ -48,13 +49,51 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { } func testDecodesSecondOffering() throws { - let offering = try XCTUnwrap(self.response.offerings.last) + let offering = try XCTUnwrap(self.response.offerings[safe: 1]) expect(offering.identifier) == "alternate" expect(offering.description) == "alternate offering" + expect(offering.metadata) == [:] expect(offering.packages).to(haveCount(1)) - let package = offering.packages[0] + let package = try XCTUnwrap(offering.packages.first) + + expect(package.identifier) == PackageType.lifetime.description + expect(package.platformProductIdentifier) == "com.revenuecat.other_product" + } + + func testDecodesMetadataOffering() throws { + let offering = try XCTUnwrap(self.response.offerings[safe: 2]) + + expect(offering.identifier) == "metadata" + expect(offering.description) == "offering with metadata" + expect(offering.metadata) == [ + "int": 5, + "double": 5.5, + "boolean": true, + "string": "five", + "array": ["five"], + "dictionary": [ + "string": "five" + ] + ] + expect(offering.packages).to(haveCount(1)) + + let package = try XCTUnwrap(offering.packages.first) + + expect(package.identifier) == PackageType.lifetime.description + expect(package.platformProductIdentifier) == "com.revenuecat.other_product" + } + + func testDecodesNullMetadataOffering() throws { + let offering = try XCTUnwrap(self.response.offerings[safe: 3]) + + expect(offering.identifier) == "nullmetadata" + expect(offering.description) == "offering with null metadata" + expect(offering.metadata) == [:] + expect(offering.packages).to(haveCount(1)) + + let package = try XCTUnwrap(offering.packages.first) expect(package.identifier) == PackageType.lifetime.description expect(package.platformProductIdentifier) == "com.revenuecat.other_product" @@ -65,3 +104,12 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { } } + +private extension Collection { + + /// Returns the element at the specified index if it exists, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } + +} diff --git a/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift b/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift index 86c76eb3e0..e7519a83e7 100644 --- a/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift +++ b/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift @@ -495,6 +495,7 @@ private extension OfferingsManagerTests { Offering( identifier: offering.identifier, serverDescription: offering.description, + metadata: offering.metadata, availablePackages: offering.packages.map { package in .init( identifier: package.identifier, diff --git a/Tests/UnitTests/Purchasing/OfferingsTests.swift b/Tests/UnitTests/Purchasing/OfferingsTests.swift index 020750304b..e64ba672b0 100644 --- a/Tests/UnitTests/Purchasing/OfferingsTests.swift +++ b/Tests/UnitTests/Purchasing/OfferingsTests.swift @@ -153,6 +153,68 @@ class OfferingsTests: TestCase { expect(offerings.current) == offerings["offering_a"] } + func testOfferingsWithMetadataIsCreated() throws { + let metadata: [String: AnyDecodable] = [ + "int": 5, + "double": 5.5, + "boolean": true, + "string": "five", + "array": ["five"], + "dictionary": [ + "string": "five" + ] + ] + + let annualProduct = MockSK1Product(mockProductIdentifier: "com.myproduct.annual") + let monthlyProduct = MockSK1Product(mockProductIdentifier: "com.myproduct.monthly") + let products = [ + "com.myproduct.annual": StoreProduct(sk1Product: annualProduct), + "com.myproduct.monthly": StoreProduct(sk1Product: monthlyProduct) + ] + let offerings = try XCTUnwrap( + self.offeringsFactory.createOfferings( + from: products, + data: .init( + currentOfferingId: "offering_a", + offerings: [ + .init(identifier: "offering_a", + description: "This is the base offering", + packages: [ + .init(identifier: "$rc_six_month", platformProductIdentifier: "com.myproduct.annual") + ], + metadata: .init( + wrappedValue: metadata + )), + .init(identifier: "offering_b", + description: "This is the base offering b", + packages: [ + .init(identifier: "$rc_monthly", platformProductIdentifier: "com.myproduct.monthly") + ]) + ] + ) + ) + ) + + expect(offerings["offering_a"]).toNot(beNil()) + expect(offerings["offering_b"]).toNot(beNil()) + expect(offerings.current) == offerings["offering_a"] + + let offeringA = try XCTUnwrap(offerings["offering_a"]) + expect(offeringA.metadata).to(haveCount(6)) + expect(offeringA.getMetadataValue(for: "int", default: 0)) == 5 + expect(offeringA.getMetadataValue(for: "double", default: 0.0)) == 5.5 + expect(offeringA.getMetadataValue(for: "boolean", default: false)) == true + expect(offeringA.getMetadataValue(for: "string", default: "")) == "five" + + expect(offeringA.getMetadataValue(for: "pizza", default: "no pizza")) == "no pizza" + + let optionalInt: Int? = offeringA.getMetadataValue(for: "optionalInt", default: nil) + expect(optionalInt).to(beNil()) + + let wrongMetadataType = offeringA.getMetadataValue(for: "string", default: 5.5) + expect(wrongMetadataType) == 5.5 + } + func testLifetimePackage() throws { try testPackageType(packageType: PackageType.lifetime) }