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