diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index ac19924b45..49d0c5be7d 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -251,6 +251,7 @@ 4F83F6BA2A5DB807003F90A5 /* CurrentTestCaseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A17AA2773A59300AA6F22 /* CurrentTestCaseTracker.swift */; }; 4F83F6BB2A5DB80B003F90A5 /* OSVersionEquivalent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE80AD28075D77008D6C6F /* OSVersionEquivalent.swift */; }; 4F8452682A5756CC00084550 /* HTTPRequestBody+Signing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8452672A5756CC00084550 /* HTTPRequestBody+Signing.swift */; }; + 4F8929192A65EF3000A91EA2 /* EnsureNonEmptyArrayDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8929182A65EF3000A91EA2 /* EnsureNonEmptyArrayDecodable.swift */; }; 4F8A58172A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; }; 4F8A58182A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; }; 4F90AFCB2A3915340047E63F /* TestMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90AFCA2A3915340047E63F /* TestMessage.swift */; }; @@ -964,6 +965,7 @@ 4F7DBFBC2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TransactionFetcher.swift; sourceTree = ""; }; 4F8038322A1EA7C300D21039 /* TransactionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPoster.swift; sourceTree = ""; }; 4F8452672A5756CC00084550 /* HTTPRequestBody+Signing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPRequestBody+Signing.swift"; sourceTree = ""; }; + 4F8929182A65EF3000A91EA2 /* EnsureNonEmptyArrayDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureNonEmptyArrayDecodable.swift; sourceTree = ""; }; 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOfflineCustomerInfoCreator.swift; sourceTree = ""; }; 4F90AFCA2A3915340047E63F /* TestMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMessage.swift; sourceTree = ""; }; 4F98E9D22A465A4400DB6EAB /* TestStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStoreProduct.swift; sourceTree = ""; }; @@ -2437,6 +2439,7 @@ 57EAE52C274468900060EB74 /* RawDataContainer.swift */, 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */, 4FBBC5672A61E42F0077281F /* NonEmptyStringDecodable.swift */, + 4F8929182A65EF3000A91EA2 /* EnsureNonEmptyArrayDecodable.swift */, ); path = Codable; sourceTree = ""; @@ -3331,6 +3334,7 @@ 2D9C7BB326D838FC006838BE /* UIApplication+RCExtensions.swift in Sources */, F56E2E7727622B5E009FED5B /* TransactionsManager.swift in Sources */, B34605CC279A6E380031CA74 /* LogInOperation.swift in Sources */, + 4F8929192A65EF3000A91EA2 /* EnsureNonEmptyArrayDecodable.swift in Sources */, 35F82BB626A9B8040051DF03 /* AttributionDataMigrator.swift in Sources */, A55D08302722368600D919E0 /* SK2BeginRefundRequestHelper.swift in Sources */, 35D832CD262A5B7500E60AC5 /* ETagManager.swift in Sources */, diff --git a/Sources/Misc/Codable/EnsureNonEmptyArrayDecodable.swift b/Sources/Misc/Codable/EnsureNonEmptyArrayDecodable.swift new file mode 100644 index 0000000000..3bc17bc7f3 --- /dev/null +++ b/Sources/Misc/Codable/EnsureNonEmptyArrayDecodable.swift @@ -0,0 +1,69 @@ +// +// 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 +// +// EnsureNonEmptyArrayDecodable.swift +// +// Created by Nacho Soto on 7/17/23. + +import Foundation + +/// A property wrapper that ensures decoded arrays aren't empty. +/// - Example: +/// ``` +/// struct Data { +/// @EnsureNonEmptyArrayDecodable var values: [String] // fails to decode if array is empty +/// } +/// ``` +@propertyWrapper +struct EnsureNonEmptyArrayDecodable { + + struct Error: Swift.Error {} + + var wrappedValue: [Value] + +} + +extension EnsureNonEmptyArrayDecodable: Equatable where Value: Equatable {} +extension EnsureNonEmptyArrayDecodable: Hashable where Value: Hashable {} + +extension EnsureNonEmptyArrayDecodable: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let array = try container.decode([Value].self) + + if array.isEmpty { + throw Error() + } else { + self.wrappedValue = array + } + } + +} + +extension EnsureNonEmptyArrayDecodable: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} + +extension KeyedDecodingContainer { + + func decode( + _ type: EnsureNonEmptyArrayDecodable.Type, + forKey key: Key + ) throws -> EnsureNonEmptyArrayDecodable { + return try self.decodeIfPresent(type, forKey: key) + .orThrow(EnsureNonEmptyArrayDecodable.Error()) + } + +} diff --git a/Tests/UnitTests/FoundationExtensions/DecoderExtensionTests.swift b/Tests/UnitTests/FoundationExtensions/DecoderExtensionTests.swift index c140c1bcb3..300c0fbb8a 100644 --- a/Tests/UnitTests/FoundationExtensions/DecoderExtensionTests.swift +++ b/Tests/UnitTests/FoundationExtensions/DecoderExtensionTests.swift @@ -290,6 +290,38 @@ class DecoderExtensionsNonEmptyStringTests: TestCase { } +class DecoderExtensionsNonEmptyArrayTests: TestCase { + + private struct Data: Codable, Equatable { + @EnsureNonEmptyArrayDecodable var value: [String] + + init(value: [String]) { + self.value = value + } + } + + func testDecodesOneValues() throws { + let data = Data(value: ["1"]) + expect(try data.encodeAndDecode()) == data + } + + func testDecodesMultipleValues() throws { + let data = Data(value: ["1", "2"]) + expect(try data.encodeAndDecode()) == data + } + + func testEncodesEmptyValues() throws { + expect(try Data(value: []).encodedJSON) == "{\"value\":[]}" + } + + func testThrowsWhenDecodingEmptyArray() throws { + expect { + try Data.decode("{\"value\": []}") + }.to(throwError(EnsureNonEmptyArrayDecodable.Error())) + } + +} + // MARK: - Extensions extension Decodable where Self: Encodable {