Skip to content

Commit

Permalink
Introduce FirestoreAccountStorage (#24)
Browse files Browse the repository at this point in the history
# Introduce FirestoreAccountStorage

## ♻️ 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.


## ⚙️ Release Notes 
* Introduce `FirestoreAccountStorage` to easily storage additional user
data in firestore


## 📚 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.


## ✅ Testing
_TBA_


## 📝 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).
  • Loading branch information
Supereg authored Nov 18, 2023
1 parent 084d697 commit 21419af
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 27 deletions.
3 changes: 2 additions & 1 deletion .spi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ builder:
- platform: ios
documentation_targets:
- SpeziFirebaseAccount
- SpeziFirebaseAccountStorage
- SpeziFirebaseConfiguration
- SpeziFirebaseStorage
- SpeziFirestore
- SpeziFirestorePrefixUserIdAdapter
14 changes: 12 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
],
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}

Expand All @@ -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
Expand Down
167 changes: 167 additions & 0 deletions Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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: AccountKey>(_ 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<Void, Error> {
if let error {
return .failure(error)
} else {
return .success(())
}
}
}
Original file line number Diff line number Diff line change
@@ -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: AccountKey>(_ 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<Data, Error> {
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)
}
}
}
Loading

0 comments on commit 21419af

Please sign in to comment.