This repository contains all utility functions that simplify transactions with Firebase products like Cloud Firestore, Cloud Storage or Authentication, wrapped in a Swift Package.
It is made by SPACE SQUAD! We make great software with
- CRUD-Transactions for collections and documents stored in Firebase Cloud Firestore
- CR-Transactions for documents stored in Firebase Cloud Storage
- Handling Authentication and Authorization using SignInWithApple
- iOS 14.0+ / macOS 10.14+
- Xcode 13+
- Swift 5+
In Xcode, go to File > Add Packages
and add https://github.com/space-squad/swift-firebase-manager
. Add the package to all your targets.
The package is separated into three targets and you need to import the one that fits your needs:
Target for all transactions with the Firebase Cloud Firestore.
- CRUD-Transactions for collections and documents
- Error Handling
- Support for nested collections
- Snapshot Listeners
- Batch writing for creating multiple documents
In the first step you need to define your collections by implementing the ReferenceProtocol. In case of nested collections, you need to pass the top-level document Id as associated value. Example definition for two top-level collections (countries, notes), where every country-document has a cities-collection associated:
import FirebaseFirestoreManager
enum FirestoreReference: ReferenceProtocol {
case country, city(countryId: String), notes, users
var rawValue: String {
switch self {
case .country: return "countries"
case .city: return "cities"
case .notes: return "notes"
}
}
func parent() -> ParentReference? {
switch self {
case .city(let countryId):
// path is /country/{countryId}/city
return ParentReference(reference: FirestoreReference.country, id: countryId)
case .country, .users: return nil
case .notes:
// Notes is a subdirectory of the current user (users/{userId}/notes), so in case we don't have a userId, we cannot generate the correct path
guard let userId = UserRepository.userId else {
throw FirestoreError.incompleteReference(reference: self)
}
return ParentReference(reference: FirestoreReference.users, id: userId)
}
}
}
Next you need to define all your model classes, conforming to Codable-protocol:
public struct City: Codable {
let name: String
let state: String?
let isCapital: Bool?
let population: Int64?
enum CodingKeys: String, CodingKey {
case name
case state
case isCapital = "capital"
case population
}
}
Afterwards you can use all FirebaseFirestoreManager
-classes for creating, reading, updating or deleting data.:
import FirebaseFirestoreManager
// Create new document
let country = Country(....)
FirestoreManager.createDocument(country, reference: FirestoreReference.country) { _ in }
// Read all documents from a collection
FirestoreManager.fetchCollection(FirestoreReference.country) { (result: Result<[Country], FirestoreError>) in
switch result {
case .success(let countries):
// Here you can use the fetched countries!
print(countries)
case .failure(let error):
print(error.localizedDescription)
}
}
// Read specific city-document
FirestoreManager.fetchDocument(id: "LA", reference: FirestoreReference.city(countryId: "USA")) { (result: Result<City, FirestoreError>) in
switch result {
case .success(let city):
print(city)
case .failure(let error):
print(error.localizedDescription)
}
}
// Update document
country.name = "USA"
FirestoreManager.updateDocument(country, reference: FirestoreReference.country, with: country.id) { _ in }
// Delete document
FirestoreManager.deleteDocument(reference: FirestoreReference.country, with: country.id)
Target for all transactions with the Firebase Cloud Storage, using the Combine-framework.
- Create files
- Read files
- Delete files
- Error Handling
- Hierarchical Organization
- Multiple Buckets
- Support more file types
// Create new file in bucket in folder "directory"
let data = Data(base64Encoded: someString)!
FirebaseStorageManager.uploadData(data: data, path: "directory", fileName: "fileName", fileType: .csv)
.sink { _ in }
receiveValue: { url in
print(url.absoluteURL)
}
// Read File from bucket
FirebaseStorageManager.fetchFile(path: "directory", fileName: "fileName", fileType: .csv)
.tryMap { url in
try String(contentsOf: url)
}
.replaceError(with: "Error")
.sink { print($0) }
Target for all authentication related operations.
- Anonymous Authentication
- Authentication by Apple Id
- Error Handling
- Link Anonymous and authenticated Accounts
- Authentication by Email and Password
- Sign out and Deleting an Account
- UIKit-View for handling SignInWithApple-Requests
import FirebaseAuthenticationManager
AuthenticationManager.hasUser // returns true if user is authenticated
AuthenticationManager.userId // returns the id of the currently authenticated user, nil if the user is unauthenticated
AuthenticationManager.userIsAuthenticated // returns true if user is authenticated and not anonymous
AuthenticationManager.userName // returns concatenated name ("John Doe" or "John") if User provided details during Sign In with Apple or was set manually
AuthenticationManager.userName = "Jane Doe" // userName is overwritten and cannot be restored
AuthenticationManager.email // returns email if User provided details during Sign In with Apple or was set manually
AuthenticationManager.email = "[email protected]" // email is overwritten and cannot be restored
By default, the AuthenticationManager
allows three authentication methods: Sign in with Email and Password, by using the Apple ID and anonymous login. If you want to restrict the providers, you can use a custom configuration object. With that, the AuthenticationManager needs to be initialized on App Start.
You can also link a repository where you manage your users details. If you subclass the UserRepositoryProtocol
your user's details with the user details you get during the authentication process.
If you don't need custom settings, you don't need to call the .setup(:_)
-Function and can start using the Manager wherever you need it.
import SwiftUI
import Firebase
import FirebaseAuthenticationManager
@main
struct MyApp: App {
init() {
FirebaseApp.configure()
var authenticationConfiguration = Configuration()
authenticationConfiguration.authProvider = [.signInWithApple, .anonymous, .emailPassword]
authenticationConfiguration.userRepository = MyRepository.shared
AuthenticationManager.setup(authenticationConfiguration)
}
}
import UIKit
import Firebase
import FirebaseAuthenticationManager
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
var authenticationConfiguration = Configuration()
authenticationConfiguration.authProvider = [.signInWithApple, .anonymous, .emailPassword]
authenticationConfiguration.userRepository = MyRepository.shared
AuthenticationManager.setup(authenticationConfiguration)
return true
}
// Create Account
AuthenticationManager.signUpWithEmail(email: "[email protected]", password: "123") { error in
if let error {
// Check detailed Error Response
} else {
// Account was created and User logged in successfully
}
}
// Login
AuthenticationManager.loginWithEmail(email: "[email protected]", password: "123") { error in
if let error {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
// Update User Information
AuthenticationManager.resetPassword(for: "[email protected]")
AuthenticationManager.updateMail(currentPassword: "123", newMail: "[email protected]") { error in }
AuthenticationManager.updatePassword(currentPassword: "123", newPassword: "456") { error in }
The Authentication Manager controls the whole authentication flow and returns the handled error without any further work.
import FirebaseAuthenticationManager
import AuthenticationServices
struct SignInButton: View {
var body: some View {
SignInWithAppleButton(
onRequest: { request in
_ = AuthenticationManager.editRequest(request, scopes: [.email])
}, onCompletion : { result in
AuthenticationManager.handleAuthorizationResult(result) { error in
if let error {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
})
}
}
import FirebaseAuthenticationManager
import AuthenticationServices
class ViewController: UIViewController {
func authenticateBySignInWithApple() {
let request = AuthenticationManager.editRequest(scopes: [.email])
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.performRequests()
}
}
extension ViewController: ASAuthorizationControllerDelegate {
public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
AuthenticationManager.handleAuthorizationResult(.success(authorization)) { error in
if let error {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
}
public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
AuthenticationManager.handleAuthorizationResult(.failure(error)) { handledError in
if let handledError {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
}
}
We wrapped the above code in a custom SignInWithAppleAuthenticationView
to simplify the delegation. To receive the final result, you can implement the SignInWithAppleAuthenticationDelegate
:
import FirebaseAuthenticationManager
class ViewController: UIViewController {
// Create a reference to the custom view
lazy var authenticationView = SignInWithAppleAuthenticationView(view: self.view, delegate: self)
override func viewDidLoad() {
super.viewDidLoad()
let signupWithAppleButton = ASAuthorizationAppleIDButton()
signupWithAppleButton.addTarget(self, action: #selector(signupWithApple), for: .touchUpInside)
view.addSubview(signupWithAppleButton)
}
@objc
func signupWithApple() {
authenticationView?.authenticateBySignInWithApple()
}
}
extension ViewController: SignInWithAppleAuthenticationDelegate {
func signInWithAppleCompleted(error: Error?) {
if error {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
}
Firebase gives the opportunitiy to sign up anonymously so users can use your app without any account information while staying identifiable.
AuthenticationManager.authenticateAnonymously { error in
if let error {
// Check detailed Error Response
} else {
// Authentication was successful
}
}
AuthenticationManager.signOut { error in
if let error {
// Check detailed Error Response
} else {
// Sign Out was successful
}
}
You can delete a user's account in Firebase. This can require a reauthentication before executing this method which is done automatically for users who signed in with email and password.
If your user signed in with its Apple-Account, this requires involving the UI to receive the status of authentication. We don't handle this error (yet), so we advise you to execute an additional login manually before accessing this function.
// Account is related to Email and Password
AuthenticationManager.deleteAccount(currentPassword: "123") { error in
if let error {
// Check detailed Error Response
} else {
// Deletion of Account was successful
// If you stored your user's data in a database, don't forget to implement deleting it separately.
}
}
// Account is related to Apple ID
AuthenticationManager.deleteAccount { error in
if let error {
// Check detailed Error Response
} else {
// Deletion of Account was successful
// If you stored your user's data in a database, don't forget to implement deleting it separately.
}
}
By subclassing the UserRepositoryProtocol
, you get a direct access to the userId if a user is authenticated.
class UserRepository: UserRepositoryProtocol {
static let shared = UserRepository()
}
// Use it anywhere in your app where you need it
UserRepository.shared.userId
During the Authentication Process with Apple the User is asked to provide some details like its name and/or email-address. You can access these information by creating a Repository-Class conforming to the UserRepositoryProtocol
.
class UserRepository: UserRepositoryProtocol {
func receivedUserDetails(email: String?, name: String?, completion: @escaping (Error?) -> Void) {
// Depending on your requested scope you receive the user's details in here and can store or update them in a database of your choice, e.g. your UserDefaults or in the Firestore.
// Attention: If a user signs in multiple times on the same device, we don't receive any values in here. So keep that in mind to prevent overwriting any already stored values in your database.
if let name {
UserDefaults.standard.set(name, forKey: "username")
}
// Call completion to proceed with the Authentication flow. You can pass any errors that occur during your database transactions to notify that the authentication procedure included errors
completion(nil)
}
}
It is recommended to check a user's authorization status on app start, or possibly before sensitive transactions, since these could be changed outside of your app.
You can simplify this Authorization-Flow by implementing the UserRepositoryProtocol
.
If you stored your users details in a remote database, you might want to fetch it after the authorization was successful.
For this you can implement the fetchCurrentUser
-Method, which is executed automatically on successful authorization.
class UserRepository: UserRepositoryProtocol {
// Custom Function for checking Authorization -> can be called anywhere in your app where you need it
func checkAuthState() {
self.checkAuthorization { error in
if let error {
// Handle Error
// We recommend signing out
AuthenticationManager.signOut { _ in }
}
}
}
// Is called automatically after successful authorization
func fetchCurrentUser() {
// your database transactions for fetching already stored value, e.g. from Firestore
guard let userId else { return }
FirestoreManager.fetchDocument(id: userId, reference: FirestoreReference.users) { result in
if let user = try? result.get() {
print("I am \(user) and my UID is \(userId)")
}
}
}
}