From 21419af5e4e1cb0040aa18dacf73adb5d90e84d3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 17 Nov 2023 16:03:14 -0800 Subject: [PATCH] Introduce FirestoreAccountStorage (#24) # Introduce FirestoreAccountStorage ## :recycle: Current situation & Problem This PR introduces the new FirestoreAccountStorage target that allows you to easily implement an [AccountStorageStandard](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountstoragestandard) using Firestore. ## :gear: Release Notes * Introduce `FirestoreAccountStorage` to easily storage additional user data in firestore ## :books: Documentation Extensive documentation was added. Further, I noticed that our documentation builds are not up to date in the Swift package index. This is probably due to an outdated spi.yml. This file was updated as part of this PR. ## :white_check_mark: Testing _TBA_ ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --- .spi.yml | 3 +- Package.swift | 14 +- ...rebaseIdentityProviderAccountService.swift | 23 ++- .../FirestoreAccountStorage.swift | 167 ++++++++++++++++++ .../Visitor/FirestoreDecodeVisitor.swift | 45 +++++ .../Visitor/FirestoreEncodeVisitor.swift | 39 ++++ .../xcshareddata/swiftpm/Package.resolved | 31 ++-- 7 files changed, 295 insertions(+), 27 deletions(-) create mode 100644 Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift create mode 100644 Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift create mode 100644 Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift diff --git a/.spi.yml b/.spi.yml index f67f1b0..d724888 100644 --- a/.spi.yml +++ b/.spi.yml @@ -12,6 +12,7 @@ builder: - platform: ios documentation_targets: - SpeziFirebaseAccount + - SpeziFirebaseAccountStorage - SpeziFirebaseConfiguration + - SpeziFirebaseStorage - SpeziFirestore - - SpeziFirestorePrefixUserIdAdapter diff --git a/Package.swift b/Package.swift index 83d684d..ecdf938 100644 --- a/Package.swift +++ b/Package.swift @@ -21,12 +21,13 @@ let package = Package( .library(name: "SpeziFirebaseAccount", targets: ["SpeziFirebaseAccount"]), .library(name: "SpeziFirebaseConfiguration", targets: ["SpeziFirebaseConfiguration"]), .library(name: "SpeziFirestore", targets: ["SpeziFirestore"]), - .library(name: "SpeziFirebaseStorage", targets: ["SpeziFirebaseStorage"]) + .library(name: "SpeziFirebaseStorage", targets: ["SpeziFirebaseStorage"]), + .library(name: "SpeziFirebaseAccountStorage", targets: ["SpeziFirebaseAccountStorage"]) ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.8.0")), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", .upToNextMinor(from: "0.6.1")), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.7.1")), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.8.0")), .package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.5.0")), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], @@ -67,6 +68,15 @@ let package = Package( .product(name: "FirebaseStorage", package: "firebase-ios-sdk") ] ), + .target( + name: "SpeziFirebaseAccountStorage", + dependencies: [ + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), + .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziAccount", package: "SpeziAccount"), + .target(name: "SpeziFirestore") + ] + ), .testTarget( name: "SpeziFirebaseTests", dependencies: [ diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift index c4fa9cf..b647f3d 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift @@ -14,16 +14,15 @@ import SwiftUI struct FirebaseIdentityProviderViewStyle: IdentityProviderViewStyle { - let service: FirebaseIdentityProviderAccountService - - - init(service: FirebaseIdentityProviderAccountService) { - self.service = service - } - - - func makeSignInButton() -> some View { - FirebaseSignInWithAppleButton(service: service) + func makeSignInButton(_ provider: any IdentityProvider) -> some View { + if let backed = provider as? any _StandardBacked, + let underlyingService = backed.underlyingService as? FirebaseIdentityProviderAccountService { + FirebaseSignInWithAppleButton(service: underlyingService) + } else if let service = provider as? FirebaseIdentityProviderAccountService { + FirebaseSignInWithAppleButton(service: service) + } else { + preconditionFailure("Unexpected account service found: \(provider)") + } } } @@ -37,9 +36,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS \.name } - nonisolated var viewStyle: FirebaseIdentityProviderViewStyle { - FirebaseIdentityProviderViewStyle(service: self) - } + let viewStyle = FirebaseIdentityProviderViewStyle() let configuration: AccountServiceConfiguration let firebaseModel: FirebaseAccountModel diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift new file mode 100644 index 0000000..97e432a --- /dev/null +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -0,0 +1,167 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import FirebaseFirestore +import Spezi +import SpeziAccount +import SpeziFirestore + + +/// Store additional account details directly in firestore. +/// +/// Certain account services, like the account services provided by Firebase, can only store certain account details. +/// The `FirestoreAccountStorage` can be used to store additional account details, that are not supported out of the box by your account services, +/// inside Firestore in a custom user collection. +/// +/// - Note: The `FirestoreAccountStorage` relies on the primary [AccountId](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountidkey) +/// as the document identifier. Fore Firebase-based account service, this is the primary, firebase user identifier. Make sure to configure your firestore security rules respectively. +/// +/// Once you have [AccountConfiguration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) +/// and the [FirebaseAccountConfiguration](https://swiftpackageindex.com/stanfordspezi/spezifirebase/documentation/spezifirebaseaccount/firebaseaccountconfiguration) +/// set up, you can adopt the [AccountStorageStandard](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountstoragestandard) +/// protocol to provide a custom storage for SpeziAccount. +/// +/// - Important: In order to use the `FirestoreAccountStorage`, you must have [Firestore](https://swiftpackageindex.com/stanfordspezi/spezifirebase/main/documentation/spezifirestore/firestore) +/// configured in your app. Refer to the documentation page for more information. +/// +/// ```swift +/// import FirebaseFirestore +/// import Spezi +/// import SpeziAccount +/// import SpeziFirebaseAccountStorage +/// +/// +/// actor ExampleStandard: Standard, AccountStorageStandard { +/// // Define the collection where you want to store your additional user data, ... +/// static var collection: CollectionReference { +/// Firestore.firestore().collection("users") +/// } +/// +/// // ... define and initialize the `FirestoreAccountStorage` dependency ... +/// @Dependency private var accountStorage = FirestoreAccountStorage(storedIn: Self.collection) +/// +/// +/// // ... and forward all implementations of `AccountStorageStandard` to the `FirestoreAccountStorage`. +/// +/// public func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { +/// try await accountStorage.create(identifier, details) +/// } +/// +/// public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { +/// try await accountStorage.load(identifier, keys) +/// } +/// +/// public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { +/// try await accountStorage.modify(identifier, modifications) +/// } +/// +/// public func clear(_ identifier: AdditionalRecordId) async { +/// await accountStorage.clear(identifier) +/// } +/// +/// public func delete(_ identifier: AdditionalRecordId) async throws { +/// try await accountStorage.delete(identifier) +/// } +/// } +/// ``` +public actor FirestoreAccountStorage: Module, AccountStorageStandard { + @Dependency private var firestore: SpeziFirestore.Firestore // ensure firestore is configured + + private let collection: CollectionReference + + + public init(storeIn collection: CollectionReference) { + self.collection = collection + } + + private func userDocument(for accountId: String) -> DocumentReference { + collection.document(accountId) + } + + public func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { + let result = details.acceptAll(FirestoreEncodeVisitor()) + + do { + switch result { + case let .success(data): + try await userDocument(for: identifier.accountId) + .setData(data) + case let .failure(error): + throw error + } + } catch { + throw FirestoreError(error) + } + } + + public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { + let builder = PartialAccountDetails.Builder() + + let document = userDocument(for: identifier.accountId) + + do { + let data = try await document + .getDocument() + .data() + + if let data { + for key in keys { + guard let value = data[key.identifier] else { + continue + } + + let visitor = FirestoreDecodeVisitor(value: value, builder: builder, in: document) + key.accept(visitor) + if case let .failure(error) = visitor.final() { + throw error + } + } + } + } catch { + throw FirestoreError(error) + } + + return builder.build() + } + + public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { + let result = modifications.modifiedDetails.acceptAll(FirestoreEncodeVisitor()) + + do { + switch result { + case let .success(data): + try await userDocument(for: identifier.accountId) + .setData(data, merge: true) + case let .failure(error): + throw error + } + + let removedFields: [String: Any] = modifications.removedAccountDetails.keys.reduce(into: [:]) { result, key in + result[key.identifier] = FieldValue.delete() + } + + try await userDocument(for: identifier.accountId) + .updateData(removedFields) + } catch { + throw FirestoreError(error) + } + } + + public func clear(_ identifier: AdditionalRecordId) async { + // nothing we can do ... + } + + public func delete(_ identifier: AdditionalRecordId) async throws { + do { + try await userDocument(for: identifier.accountId) + .delete() + } catch { + throw FirestoreError(error) + } + } +} diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift new file mode 100644 index 0000000..48339e9 --- /dev/null +++ b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import FirebaseFirestore +import SpeziAccount + + +class FirestoreDecodeVisitor: AccountKeyVisitor { + private let builder: PartialAccountDetails.Builder + private let value: Any + private let reference: DocumentReference + + private var error: Error? + + + init(value: Any, builder: PartialAccountDetails.Builder, in reference: DocumentReference) { + self.value = value + self.builder = builder + self.reference = reference + } + + + func visit(_ key: Key.Type) { + let decoder = Firestore.Decoder() + + do { + try builder.set(key, value: decoder.decode(Key.Value.self, from: value, in: reference)) + } catch { + self.error = error + } + } + + func final() -> Result { + if let error { + return .failure(error) + } else { + return .success(()) + } + } +} diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift new file mode 100644 index 0000000..38ef4b8 --- /dev/null +++ b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import FirebaseFirestore +import SpeziAccount + + +class FirestoreEncodeVisitor: AccountValueVisitor { + typealias Data = [String: Any] + + private var values: Data = [:] + private var errors: [String: Error] = [:] + + init() {} + + func visit(_ key: Key.Type, _ value: Key.Value) { + let encoder = Firestore.Encoder() + do { + values["\(Key.self)"] = try encoder.encode(value) + } catch { + errors["\(Key.self)"] = error + } + } + + func final() -> Result { + if let first = errors.first { + // we just report the first error, like in a typical do-catch setup + return .failure(first.value) + } else { + return .success(values) + } + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a43621c..1ada950 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,13 +9,22 @@ "version" : "1.2022062300.0" } }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "5746b2d35c91c50581590ed97abe4c06b5037274", + "version" : "10.18.0" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8872dbd7d947acf757abab933da10e83c1842280", - "version" : "10.17.0" + "revision" : "5de0369ee79ad096c164eb3afeb7921d92a43b58", + "version" : "10.18.0" } }, { @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "f6532c8d65f8308cfdf2288cbe1971a509822680", - "version" : "7.12.0" + "revision" : "bc27fad73504f3d4af235de451f02ee22586ebd3", + "version" : "7.12.1" } }, { @@ -104,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "b82fb371ab7f0446846ae8aeb56ffac56377890a", - "version" : "0.8.0" + "revision" : "092eabc50a3600d8a03b43ad0d2dcd02914b223f", + "version" : "0.8.1" } }, { @@ -113,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount", "state" : { - "revision" : "3de5633e3bdaa2bdb19f478587d7df528299477a", - "version" : "0.7.1" + "revision" : "901cf9d11cc3e4ea3704c65982866fc278e50790", + "version" : "0.8.0" } }, { @@ -138,10 +147,10 @@ { "identity" : "speziviews", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziViews.git", + "location" : "https://github.com/StanfordSpezi/SpeziViews", "state" : { - "revision" : "5cef2980e8309b74501759cdb2cce8a1b9c34502", - "version" : "0.6.1" + "revision" : "eac443080926649d09a703483a6dd6f5a8bb7d51", + "version" : "0.6.2" } }, {