Implemented the KeychainStorage dynamic property wrapper.
This commit is contained in:
parent
a3bfced62a
commit
4a8fff4c16
@ -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<Model: Codable>: 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<Value?> {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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?
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user