Merge pull request #7 from rock-n-code/feature/keychain-storage
Feature: Keychain store
This commit is contained in:
commit
97ad38bbc0
@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1410"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "APIServiceTests"
|
||||
BuildableName = "APIServiceTests"
|
||||
BlueprintName = "APIServiceTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "KeychainStorageTests"
|
||||
BuildableName = "KeychainStorageTests"
|
||||
BlueprintName = "KeychainStorageTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
@ -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 {}
|
@ -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,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
|
||||
}
|
@ -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