diff --git a/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme b/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme
new file mode 100644
index 0000000..d847438
--- /dev/null
+++ b/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/Package.swift b/Libraries/Package.swift
index 8e572f0..9620c9d 100644
--- a/Libraries/Package.swift
+++ b/Libraries/Package.swift
@@ -14,13 +14,28 @@ let package = Package(
]
),
],
- dependencies: [],
+ dependencies: [
+ .package(
+ url: "https://github.com/kishikawakatsumi/KeychainAccess.git",
+ from: "4.0.0"
+ ),
+ ],
targets: [
.target(name: "APIService"),
.target(name: "DependencyService"),
+ .target(
+ name: "KeychainStorage",
+ dependencies: [
+ "KeychainAccess"
+ ]
+ ),
.testTarget(
name: "APIServiceTests",
dependencies: ["APIService"]
),
+ .testTarget(
+ name: "KeychainStorageTests",
+ dependencies: ["KeychainStorage"]
+ )
]
)
diff --git a/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift b/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift
new file mode 100644
index 0000000..e4bf596
--- /dev/null
+++ b/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift
@@ -0,0 +1,11 @@
+//
+// Keychain+Keychainable.swift
+// KeychainStorage
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import KeychainAccess
+
+extension Keychain: Keychainable {}
diff --git a/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift
new file mode 100644
index 0000000..11fc43f
--- /dev/null
+++ b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift
@@ -0,0 +1,84 @@
+//
+// KeychainStorage.swift
+// KeychainStorage
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import Foundation
+import KeychainAccess
+import SwiftUI
+
+@propertyWrapper
+public struct KeychainStorage: DynamicProperty {
+
+ // MARK: Type aliases
+
+ public typealias Value = Model
+
+ // MARK: States
+
+ @State private var value: Value?
+
+ // MARK: Properties
+
+ private let key: String
+ private let keychain: Keychainable
+ private let decoder: JSONDecoder = .init()
+ private let encoder: JSONEncoder = .init()
+
+ public var wrappedValue: Value? {
+ get { value }
+ nonmutating set {
+ value = newValue
+
+ do {
+ if let newValue {
+ let encodedValue = try encoder.encode(newValue)
+
+ try keychain.set(encodedValue, key: key, ignoringAttributeSynchronizable: true)
+ } else {
+ try keychain.remove(key, ignoringAttributeSynchronizable: true)
+ }
+ } catch {
+ assertionFailure("The '\(key)' key should have either be set or removed from the keychain storage.")
+ }
+ }
+ }
+
+ public var projectedValue: Binding {
+ .init { wrappedValue } set: { wrappedValue = $0 }
+ }
+
+ // MARK: Initialisers
+
+ public init(
+ key: String,
+ defaultValue: Value? = nil,
+ keychain: Keychainable = Keychain()
+ ) {
+ self.key = key
+ self.keychain = keychain
+
+ do {
+ let data = try keychain.getData(key, ignoringAttributeSynchronizable: true)
+
+ guard let data else {
+ assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.")
+ return
+ }
+
+ do {
+ let model = try decoder.decode(Value.self, from: data)
+
+ self._value = .init(initialValue: model)
+ } catch {
+ assertionFailure("The data for the '\(key)' key should have been decoded properly.")
+ }
+ } catch {
+ self._value = .init(initialValue: defaultValue)
+ }
+ }
+
+}
diff --git a/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift b/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift
new file mode 100644
index 0000000..7b0b55c
--- /dev/null
+++ b/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift
@@ -0,0 +1,27 @@
+//
+// Keychainable.swift
+// KeychainStorage
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import Foundation
+
+public protocol Keychainable {
+ func getData(
+ _ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws -> Data?
+
+ func set(
+ _ value: Data,
+ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws
+
+ func remove(
+ _ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws
+}
diff --git a/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift b/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift
new file mode 100644
index 0000000..5a4d719
--- /dev/null
+++ b/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift
@@ -0,0 +1,105 @@
+//
+// KeychainStorage+InitTests.swift
+// KeychainStorageTests
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import Foundation
+import KeychainStorage
+import XCTest
+
+final class KeychainStorageInitTests: XCTestCase {
+
+ // MARK: Properties
+
+ private var value: TestModel?
+
+ // MARK: Test cases
+
+ func testValue_whenNoDefaultValue_andEmptyStorage() throws {
+ // GIVEN
+ let keychainStorage = TestKeychainStorage_withNoDefaultValue_andEmptyStorage()
+
+ // WHEN
+ value = keychainStorage.keychainValue
+
+ // THEN
+ XCTAssertNil(value)
+ }
+
+ func testValue_whenDefaultValue_andEmptyStorage() throws {
+ // GIVEN
+ let keychainStorage = TestKeychainStorage_withDefaultValue_andEmptyStorage()
+
+ // WHEN
+ value = keychainStorage.keychainValue
+
+ // THEN
+ XCTAssertNotNil(value)
+ }
+
+ func testValue_whenNoDefaultValue_andValueInStorage() throws {
+ // GIVEN
+ let keychainStorage = TestKeychainStorage_withNoDefaultValue_andValueInStorage()
+
+ // WHEN
+ value = keychainStorage.keychainValue
+
+ // THEN
+ XCTAssertNotNil(value)
+ }
+
+ func testValue_whenNoDefaultValue_andNoValueInStorage() throws {
+ // GIVEN
+ let keychainStorage = TestKeychainStorage_withNoDefaultValue_andNoValueInStorage()
+
+ // WHEN
+ value = keychainStorage.keychainValue
+
+ // THEN
+ XCTAssertNil(value)
+ }
+
+}
+
+// MARK: - Test classes
+
+private final class TestKeychainStorage_withNoDefaultValue_andEmptyStorage {
+ @KeychainStorage(
+ key: .Keys.someKey,
+ keychain: KeychainStorageMock()
+ )
+ var keychainValue: TestModel?
+}
+
+
+private final class TestKeychainStorage_withDefaultValue_andEmptyStorage {
+ @KeychainStorage(
+ key: .Keys.someKey,
+ defaultValue: TestModel(),
+ keychain: KeychainStorageMock()
+ )
+ var keychainValue: TestModel?
+}
+
+private final class TestKeychainStorage_withNoDefaultValue_andValueInStorage {
+ @KeychainStorage(
+ key: .Keys.someKey,
+ keychain: KeychainStorageMock(storage: [
+ .Keys.someKey: try! JSONEncoder().encode(TestModel())
+ ])
+ )
+ var keychainValue: TestModel?
+}
+
+private final class TestKeychainStorage_withNoDefaultValue_andNoValueInStorage {
+ @KeychainStorage(
+ key: .Keys.someKey,
+ keychain: KeychainStorageMock(storage: [
+ .Keys.someOtherKey: try! JSONEncoder().encode(TestModel())
+ ])
+ )
+ var keychainValue: TestModel?
+}
diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift
new file mode 100644
index 0000000..26fe8d6
--- /dev/null
+++ b/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift
@@ -0,0 +1,14 @@
+//
+// String+KeychainStorageKeys.swift
+// KeychainStorageTests
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+extension String {
+ enum Keys {
+ static let someKey = "com.rockncode.app.assignment.be-real.library.keychain-storage.test.key.some-key"
+ static let someOtherKey = "com.rockncode.app.assignment.be-real.library.keychain-storage.test.key.some-other-key"
+ }
+}
diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift
new file mode 100644
index 0000000..e9c6344
--- /dev/null
+++ b/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift
@@ -0,0 +1,78 @@
+//
+// KeychainStorageMock.swift
+// KeychainStorageTests
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import Foundation
+import KeychainStorage
+
+class KeychainStorageMock {
+
+ // MARK: Properties
+
+ private var storage: [String: Data]
+ private let concurrentQueue = DispatchQueue(
+ label: "com.rockncode.app.assignment.be-real.library.keychain-storage.test.queue.keychain-storage",
+ attributes: .concurrent
+ )
+
+ // MARK: Initialisers
+
+ init(storage: [String: Data] = [:]) {
+ self.storage = storage
+ }
+
+}
+
+// MARK: - Keychainable
+
+extension KeychainStorageMock: Keychainable {
+ func getData(
+ _ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws -> Data? {
+ var data: Data?
+
+ try concurrentQueue.sync {
+ guard let dataValue = storage[key] else {
+ throw KeychainStorageMockError.keyIsNotDefined
+ }
+
+ data = dataValue
+ }
+
+ return data
+ }
+
+ func set(
+ _ value: Data,
+ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws {
+ concurrentQueue.async(flags: .barrier) {
+ self.storage[key] = value
+ }
+ }
+
+ func remove(
+ _ key: String,
+ ignoringAttributeSynchronizable: Bool
+ ) throws {
+ guard storage.keys.contains(key) else {
+ throw KeychainStorageMockError.keyIsNotDefined
+ }
+
+ concurrentQueue.async(flags: .barrier) {
+ self.storage.removeValue(forKey: key)
+ }
+ }
+}
+
+// MARK: - KeychainStorageSpyError
+
+enum KeychainStorageMockError: Error {
+ case keyIsNotDefined
+}
diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift
new file mode 100644
index 0000000..08c698e
--- /dev/null
+++ b/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift
@@ -0,0 +1,17 @@
+//
+// TestModel.swift
+// KeychainStorageTests
+//
+// Created by Javier Cicchelli on 11/12/2022.
+// Copyright © 2022 Röck+Cöde. All rights reserved.
+//
+
+import Foundation
+
+struct TestModel: Codable, Equatable {
+ var uuid: String = UUID().uuidString
+ var text: String = "Some text goes in here..."
+ var number: Int = .random(in: 1 ... 100)
+ var boolean: Bool = .random()
+ var date: Date = .now
+}