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: [
|
targets: [
|
||||||
.target(name: "APIService"),
|
.target(name: "APIService"),
|
||||||
.target(name: "DependencyService"),
|
.target(name: "DependencyService"),
|
||||||
|
.target(
|
||||||
|
name: "KeychainStorage",
|
||||||
|
dependencies: [
|
||||||
|
"KeychainAccess"
|
||||||
|
]
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "APIServiceTests",
|
name: "APIServiceTests",
|
||||||
dependencies: ["APIService"]
|
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