Skip to content

Commit

Permalink
Model Account and AccountService as Spezi Module (#60)
Browse files Browse the repository at this point in the history
# Model `Account` and `AccountService` as Spezi Module

## ♻️ Current situation & Problem
The `Account` model and `AccountService`s were introduced before Spezi
had sophisticated support for Modules. Therefore, SpeziAccount provides
infrastructure like the `@AccountReference` to access the `Account`
model from certain places like an Account Service or from a Standard
that conforms to a SpeziAccount constraint. Other than that, the
`Account` model can only be accessed from within the SwiftUI view
hierarchy.
This design is generally relatively inflexible and one has to find
workarounds to (a) access `Account` from a Spezi Module and (b) access
Spezi Modules from within an `AccountService`. Typically this involves
nesting Account Services into basically empty Spezi Modules or injecting
ViewModifiers into the global SwiftUI view hierarchy to get access to
the `Account`.

This PR addresses the problem by making `Account` and `AccountServices`
Spezi Modules that can be used with all Spezi infrastructure like the
`@Dependency` property wrapper. This greatly improves flexibility when
developing with SpeziAccount.

## ⚙️ Release Notes 
* Account is now a Spezi Module and can be accessed from Modules using
the `@Dependency` property wrapper.
* `AccountService`s are now Spezi Modules that can use Spezi
infrastructure.


## 📚 Documentation
_TBA_


## ✅ Testing
--


## 📝 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 Jul 22, 2024
1 parent 8ac95d7 commit e3344aa
Show file tree
Hide file tree
Showing 29 changed files with 229 additions and 259 deletions.
10 changes: 5 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ let package = Package(
.library(name: "SpeziAccount", targets: ["SpeziAccount"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "1.0.0"),
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0"),
.package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"),
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "1.1.3"),
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.5.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.5.0"),
.package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.1.1"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.2"),
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0")
] + swiftLintPackage(),
targets: [
Expand Down
32 changes: 14 additions & 18 deletions Sources/SpeziAccount/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ public final class Account {
///
/// - Note: This array also contains ``IdentityProvider``s that need to be treated differently due to differing
/// ``AccountSetupViewStyle`` implementations (see ``IdentityProviderViewStyle``).
public let registeredAccountServices: [any AccountService]

@MainActor public private(set) var registeredAccountServices: [any AccountService]

/// Initialize a new `Account` object by providing all properties individually.
/// - Parameters:
Expand All @@ -109,7 +108,7 @@ public final class Account {
self._details = details

self.configuration = supportedConfiguration
self.registeredAccountServices = services
self._registeredAccountServices = services

if supportedConfiguration[UserIdKey.self] == nil {
logger.warning(
Expand All @@ -120,10 +119,6 @@ public final class Account {
"""
)
}

for service in registeredAccountServices {
injectWeakAccount(into: service)
}
}

/// Initializes a new `Account` object without a logged in user for usage within a `PreviewProvider`.
Expand All @@ -132,6 +127,7 @@ public final class Account {
/// - Parameters:
/// - services: A collection of ``AccountService`` that are used to handle account-related functionality.
/// - configuration: The ``AccountValueConfiguration`` to user intends to support.
@available(*, deprecated, message: "Use the AccountConfiguration(building:active:configuration) and previewWith(_:) modifier for previews.")
public convenience init(
services: [any AccountService],
configuration: AccountValueConfiguration = .default
Expand All @@ -145,6 +141,7 @@ public final class Account {
/// - Parameters:
/// - services: A collection of ``AccountService`` that are used to handle account-related functionality.
/// - configuration: The ``AccountValueConfiguration`` to user intends to support.
@available(*, deprecated, message: "Use the AccountConfiguration(building:active:configuration) and previewWith(_:) modifier for previews.")
public convenience init(
_ services: any AccountService...,
configuration: AccountValueConfiguration = .default
Expand All @@ -168,18 +165,14 @@ public final class Account {
self.init(services: [accountService], supportedConfiguration: configuration, details: builder.build(owner: accountService))
}

/// Initialize an empty Account module.
public convenience init() {
self.init(services: [], supportedConfiguration: .default)
}

func injectWeakAccount(into value: Any) {
let mirror = Mirror(reflecting: value)

for (_, value) in mirror.children {
if let weakReference = value as? _WeakInjectable<Account> { // see AccountService.AccountReference
weakReference.inject(self)
} else if let accountService = value as? any AccountService {
// allow for nested injection like in the case of `StandardBackedAccountService`
injectWeakAccount(into: accountService)
}
}
@MainActor
func configureServices(_ services: [any AccountService]) {
registeredAccountServices.append(contentsOf: services)
}

/// Supply the ``AccountDetails`` of the currently logged in user.
Expand Down Expand Up @@ -273,3 +266,6 @@ public final class Account {


extension Account: Sendable {}


extension Account: Module, EnvironmentAccessible, DefaultInitializable {}
117 changes: 83 additions & 34 deletions Sources/SpeziAccount/AccountConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,24 @@ import XCTRuntimeAssertions
/// - Note: For more information on how to provide an ``AccountService`` if you are implementing your own Spezi `Component`
/// refer to the <doc:Creating-your-own-Account-Service> article.
public final class AccountConfiguration: Module {
private let logger = LoggerKey.defaultValue
@Application(\.logger) private var logger
@Application(\.spezi) private var spezi

/// The user-defined configuration of account values that all user accounts need to support.
private let configuredAccountKeys: AccountValueConfiguration
/// An array of ``AccountService``s provided directly in the initializer of the configuration object.
private let providedAccountServices: [any AccountService]

@Model private(set) var account: Account
@Dependency private var account: Account?

@StandardActor private var standard: any Standard

/// The array of ``AccountService``s provided through other Spezi `Components`.
@Collect private var accountServices: [any AccountService]
/// Default active Account Details provided for previewing opportunities.
private let defaultActiveDetails: AccountDetails?
/// An array of ``AccountService``s provided directly in the initializer of the configuration object.
@Dependency @ObservationIgnored private var providedAccountServices: [any Module]


/// Initializes a `AccountConfiguration` without directly providing any ``AccountService`` instances.
///
/// ``AccountService`` instances might be automatically collected from other Spezi `Component`s that provide some.
///
/// - Parameter configuration: The user-defined configuration of account values that all user accounts need to support.
public init(configuration: AccountValueConfiguration = .default) {
self.configuredAccountKeys = configuration
self.providedAccountServices = []
self.defaultActiveDetails = nil
public convenience init(configuration: AccountValueConfiguration = .default) {
self.init(configuration: configuration, defaultActiveDetails: nil) {}
}

/// Initializes a `AccountConfiguration` by directly providing a set of ``AccountService`` instances.
Expand All @@ -59,13 +51,11 @@ public final class AccountConfiguration: Module {
/// - Parameters:
/// - configuration: The user-defined configuration of account values that all user accounts need to support.
/// - accountServices: Account Services provided through a ``AccountServiceBuilder``.
public init(
public convenience init(
configuration: AccountValueConfiguration = .default,
@AccountServiceBuilder _ accountServices: () -> [any AccountService]
@AccountServiceBuilder _ accountServices: () -> DependencyCollection
) {
self.configuredAccountKeys = configuration
self.providedAccountServices = accountServices()
self.defaultActiveDetails = nil
self.init(configuration: configuration, defaultActiveDetails: nil, accountServices)
}

/// Configure the Account Module for previewing purposes with default `AccountDetails`.
Expand All @@ -74,23 +64,41 @@ public final class AccountConfiguration: Module {
/// - builder: The ``AccountDetails`` Builder for the account details that you want to supply.
/// - accountService: The ``AccountService`` that is responsible for the supplied account details.
/// - configuration: The user-defined configuration of account values that all user accounts need to support.
public init<Service: AccountService>(
public convenience init<Service: AccountService>(
building builder: AccountDetails.Builder,
active accountService: Service,
configuration: AccountValueConfiguration = .default
) {
self.configuredAccountKeys = configuration
self.providedAccountServices = [accountService]
self.defaultActiveDetails = builder.build(owner: accountService)
let details = builder.build(owner: accountService)
self.init(configuration: configuration, defaultActiveDetails: details) {
accountService
}
}

init(
configuration: AccountValueConfiguration = .default,
defaultActiveDetails: AccountDetails? = nil,
@AccountServiceBuilder _ accountServices: () -> DependencyCollection
) {
self._providedAccountServices = Dependency(using: accountServices())

self._account = Dependency(wrappedValue: Account(
services: [],
supportedConfiguration: configuration,
details: defaultActiveDetails
))
}

public func configure() {
// assemble the final array of account services
let accountServices = (providedAccountServices + self.accountServices).map { service in
guard let account else {
preconditionFailure("Failed to initialize Account module as part of Account configuration.")
}

let accountServices = (providedAccountServices.compactMap { $0 as? any AccountService }).map { service in
// Verify account service can store all configured account keys.
// If applicable, wraps the service into an StandardBackedAccountService
let service = verifyConfigurationRequirements(against: service)
let service = verify(configurationRequirements: account.configuration, against: service)

if let notifyStandard = standard as? any AccountNotifyConstraint {
return service.backedBy(standard: notifyStandard)
Expand All @@ -99,16 +107,57 @@ public final class AccountConfiguration: Module {
return service
}

self.account = Account(
services: accountServices,
supportedConfiguration: configuredAccountKeys,
details: defaultActiveDetails
)
account.configureServices(accountServices)

let servicesYetToLoad: [any AccountService] = accountServices.reduce(into: []) { partialResult, service in
guard var standardBacked = service as? any _StandardBacked else {
return
}
partialResult.append(standardBacked)

while let nestedBacked = standardBacked.accountService as? any _StandardBacked {
partialResult.append(nestedBacked)
standardBacked = nestedBacked
}
}

if !servicesYetToLoad.isEmpty {
Task.detached { @MainActor in
// we cannot load additional modules within configure() so delay module loading a bit
for service in servicesYetToLoad {
self.spezi.loadModule(service)
}
}
}
}

@MainActor
private func configureAccountServices() {
// assemble the final array of account services
guard let account else {
preconditionFailure("Failed to initialize Account module as part of Account configuration.")
}

let accountServices = (providedAccountServices.compactMap { $0 as? any AccountService }).map { service in
// Verify account service can store all configured account keys.
// If applicable, wraps the service into an StandardBackedAccountService
let service = verify(configurationRequirements: account.configuration, against: service)

if let notifyStandard = standard as? any AccountNotifyConstraint {
return service.backedBy(standard: notifyStandard)
}

return service
}

self.account.injectWeakAccount(into: standard)
account.configureServices(accountServices)
}

private func verifyConfigurationRequirements(against service: any AccountService) -> any AccountService {
@MainActor
private func verify(
configurationRequirements configuration: AccountValueConfiguration,
against service: any AccountService
) -> any AccountService {
logger.debug("Checking \(service.description) against the configured account keys.")

// if account service states exact supported keys, AccountIdKey must be one of them
Expand All @@ -126,7 +175,7 @@ public final class AccountConfiguration: Module {

// collect all values that cannot be handled by the account service
let unmappedAccountKeys: [any AccountKeyConfiguration] = service.configuration
.unsupportedAccountKeys(basedOn: configuredAccountKeys)
.unsupportedAccountKeys(basedOn: configuration)

guard !unmappedAccountKeys.isEmpty else {
return service // we are fine, nothing unsupported
Expand Down
6 changes: 5 additions & 1 deletion Sources/SpeziAccount/AccountHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ public struct AccountHeader: View {

#Preview {
AccountHeader(caption: Text(verbatim: "Email, Password, Preferences"))
.environment(Account(MockUserIdPasswordAccountService()))
.previewWith {
AccountConfiguration {
MockUserIdPasswordAccountService()
}
}
}

#Preview {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// SPDX-License-Identifier: MIT
//

import Spezi


extension AccountService {
/// A property wrapper that can be used within ``AccountService`` instances to request
Expand All @@ -17,5 +19,6 @@ extension AccountService {
/// @AccountReference var account
/// }
/// ```
public typealias AccountReference = _WeakInjectable<Account>
@available(*, deprecated, renamed: "Dependency", message: "Account is now a module. Please use the @Dependency property wrapper from Spezi.")
public typealias AccountReference = Dependency<Account>
}
3 changes: 2 additions & 1 deletion Sources/SpeziAccount/AccountService/AccountService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import Spezi
import SwiftUI


Expand All @@ -28,7 +29,7 @@ import SwiftUI
///
/// ### Result Builder
/// - ``AccountServiceBuilder``
public protocol AccountService: AnyObject, Hashable, CustomStringConvertible, Sendable {
public protocol AccountService: Module, Hashable, CustomStringConvertible, Sendable {
/// The ``AccountSetupViewStyle`` will be used to customized the look and feel of the ``AccountSetup`` view.
associatedtype ViewStyle: AccountSetupViewStyle

Expand Down
42 changes: 6 additions & 36 deletions Sources/SpeziAccount/AccountService/AccountServiceBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,14 @@
// SPDX-License-Identifier: MIT
//

import Spezi


/// A result builder to build a collection of ``AccountService``s.
@resultBuilder
public enum AccountServiceBuilder {
public enum AccountServiceBuilder: DependencyCollectionBuilder {
/// Build a single ``AccountService`` expression.
public static func buildExpression<Service: AccountService>(_ service: Service) -> [any AccountService] {
[service]
}

/// Build a block of ``AccountService``s.
public static func buildBlock(_ components: [any AccountService]...) -> [any AccountService] {
buildArray(components)
}

/// Build the first block of an conditional ``AccountService`` component.
public static func buildEither(first component: [any AccountService]) -> [any AccountService] {
component
}

/// Build the second block of an conditional ``AccountService`` component.
public static func buildEither(second component: [any AccountService]) -> [any AccountService] {
component
}

/// Build an optional ``AccountService`` component.
public static func buildOptional(_ component: [any AccountService]?) -> [any AccountService] {
// swiftlint:disable:previous discouraged_optional_collection
component ?? []
}

/// Build an ``AccountService`` component with limited availability.
public static func buildLimitedAvailability(_ component: [any AccountService]) -> [any AccountService] {
component
}

/// Build an array of ``AccountService`` components.
public static func buildArray(_ components: [[any AccountService]]) -> [any AccountService] {
components.reduce(into: []) { result, services in
result.append(contentsOf: services)
}
public static func buildExpression(_ service: @escaping @autoclosure () -> some AccountService) -> DependencyCollection {
DependencyCollection(singleEntry: service)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Spezi


actor NotifyStandardBackedAccountService<Service: AccountService, Standard: AccountNotifyConstraint>: AccountService, _StandardBacked {
@AccountReference private var account
@Dependency private var account: Account

let accountService: Service
let standard: Standard
Expand Down
Loading

0 comments on commit e3344aa

Please sign in to comment.