Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Async init #19

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.5
// swift-tools-version:5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -23,7 +23,8 @@ let package = Package(
dependencies: [],
path: "Sources",
swiftSettings: [
.define("APPLICATION_EXTENSION_API_ONLY")
.define("APPLICATION_EXTENSION_API_ONLY"),
.enableUpcomingFeature("StrictConcurrency")
]
),
.testTarget(
Expand Down
154 changes: 154 additions & 0 deletions Sources/Container/AsyncContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// AsyncContainer.swift
// DependencyInjection
//
// Created by Róbert Oravec on 17.12.2024.
//

import Foundation

/// Dependency Injection Container where dependencies are registered and from where they are consequently retrieved (i.e. resolved)
public actor AsyncContainer: AsyncDependencyResolving, AsyncDependencyRegistering {
/// Shared singleton
public static let shared: AsyncContainer = {
AsyncContainer()
}()

private var registrations = [RegistrationIdentifier: AsyncRegistration]()
private var sharedInstances = [RegistrationIdentifier: Any]()

/// Create new instance of ``Container``
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Create new instance of ``Container``
/// Create new instance of ``AsyncContainer``

public init() {}

/// Remove all registrations and already instantiated shared instances from the container
public func clean() {
registrations.removeAll()

releaseSharedInstances()
}

/// Remove already instantiated shared instances from the container
public func releaseSharedInstances() {
sharedInstances.removeAll()
}

// MARK: Register dependency, Autoregister dependency


/// Register a dependency
Comment on lines +37 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Register a dependency
/// Register a dependency

///
/// - Parameters:
/// - type: Type of the dependency to register
/// - scope: Scope of the dependency. If `.new` is used, the `factory` closure is called on each `resolve` call. If `.shared` is used, the `factory` closure is called only the first time, the instance is cached and it is returned for all subsequent `resolve` calls, i.e. it is a singleton
/// - factory: Closure that is called when the dependency is being resolved
public func register<Dependency>(
type: Dependency.Type,
in scope: DependencyScope,
factory: @escaping Factory<Dependency>
) async {
let registration = AsyncRegistration(type: type, scope: scope, factory: factory)

registrations[registration.identifier] = registration

// With a new registration we should clean all shared instances
// because the new registered factory most likely returns different objects and we have no way to tell
sharedInstances[registration.identifier] = nil
}

// MARK: Register dependency with argument, Autoregister dependency with argument

/// Register a dependency with an argument
///
/// The argument is typically a parameter in an initiliazer of the dependency that is not registered in the same container,
/// therefore, it needs to be passed in `resolve` call
///
/// DISCUSSION: This registration method doesn't have any scope parameter for a reason.
/// The container should always return a new instance for dependencies with arguments as the behaviour for resolving shared instances with arguments is undefined.
/// Should the argument conform to ``Equatable`` to compare the arguments to tell whether a shared instance with a given argument was already resolved?
/// Shared instances are typically not dependent on variable input parameters by definition.
/// If you need to support this usecase, please, keep references to the variable singletons outside of the container.
///
/// - Parameters:
/// - type: Type of the dependency to register
/// - factory: Closure that is called when the dependency is being resolved
public func register<Dependency, Argument>(type: Dependency.Type, factory: @escaping FactoryWithArgument<Dependency, Argument>) async {
let registration = AsyncRegistration(type: type, scope: .new, factory: factory)

registrations[registration.identifier] = registration
}

// MARK: Resolve dependency

/// Resolve a dependency that was previously registered with `register` method
///
/// If a dependency of the given type with the given argument wasn't registered before this method call
/// the method throws ``ResolutionError.dependencyNotRegistered``
///
/// - Parameters:
/// - type: Type of the dependency that should be resolved
/// - argument: Argument that will passed as an input parameter to the factory method that was defined with `register` method
public func tryResolve<Dependency: Sendable, Argument: Sendable>(type: Dependency.Type, argument: Argument) async throws -> Dependency {
let identifier = RegistrationIdentifier(type: type, argument: Argument.self)

let registration = try getRegistration(with: identifier)

let dependency: Dependency = try await getDependency(from: registration, with: argument)

return dependency
}

/// Resolve a dependency that was previously registered with `register` method
///
/// If a dependency of the given type wasn't registered before this method call
/// the method throws ``ResolutionError.dependencyNotRegistered``
///
/// - Parameters:
/// - type: Type of the dependency that should be resolved
public func tryResolve<Dependency: Sendable>(type: Dependency.Type) async throws -> Dependency {
let identifier = RegistrationIdentifier(type: type)

let registration = try getRegistration(with: identifier)

let dependency: Dependency = try await getDependency(from: registration)

return dependency
}
}

// MARK: Private methods
private extension AsyncContainer {
func getRegistration(with identifier: RegistrationIdentifier) throws -> AsyncRegistration {
guard let registration = registrations[identifier] else {
throw ResolutionError.dependencyNotRegistered(
message: "Dependency of type \(identifier.description) wasn't registered in container \(self)"
)
}

return registration
}

func getDependency<Dependency: Sendable>(from registration: AsyncRegistration, with argument: (any Sendable)? = nil) async throws -> Dependency {
switch registration.scope {
case .shared:
if let dependency = sharedInstances[registration.identifier] as? Dependency {
return dependency
}
case .new:
break
}

// We use force cast here because we are sure that the type-casting always succeed
// The reason why the `factory` closure returns ``Any`` is that we have to erase the generic type in order to store the registration
// When the registration is created it can be initialized just with a `factory` that returns the matching type
let dependency = try await registration.asyncRegistrationFactory(self, argument) as! Dependency

switch registration.scope {
case .shared:
sharedInstances[registration.identifier] = dependency
case .new:
break
}

return dependency
}
}
2 changes: 1 addition & 1 deletion Sources/Container/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

/// Dependency Injection Container where dependencies are registered and from where they are consequently retrieved (i.e. resolved)
open class Container: DependencyWithArgumentAutoregistering, DependencyAutoregistering, DependencyWithArgumentResolving {
open class Container: DependencyWithArgumentAutoregistering, DependencyAutoregistering, DependencyWithArgumentResolving, @unchecked Sendable {
/// Shared singleton
public static let shared: Container = {
Container()
Expand Down
39 changes: 39 additions & 0 deletions Sources/Models/AsyncRegistration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// AsyncRegistration.swift
// DependencyInjection
//
// Created by Róbert Oravec on 16.12.2024.
//

import Foundation

typealias AsyncRegistrationFactory = @Sendable (any AsyncDependencyResolving, (any Sendable)?) async throws -> any Sendable

/// Object that represents a registered dependency and stores a closure, i.e. a factory that returns the desired dependency
struct AsyncRegistration: Sendable {
let identifier: RegistrationIdentifier
let scope: DependencyScope
let asyncRegistrationFactory: AsyncRegistrationFactory

/// Initializer for registrations that don't need any variable argument
init<T: Sendable>(type: T.Type, scope: DependencyScope, factory: @Sendable @escaping (any AsyncDependencyResolving) async -> T) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

factory parameter I found a bit confusing because when initializing AsyncRegistration in AsyncContainer we pass argument of type Factory defined in AsyncDependencyRegistering, not sure if we can optimize those typealiases

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just copied the naming as it was in sync implementation, it did make sense to me. I will revisit it 😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, as I look into it, it kinda makes sense, because AsyncDependencyResolving has different factory then AsyncRegistration, due to the fact that there is an optional parameter in AsyncRegistrationFactory. I would keep it as it is, since nothing better comes to my mind at the moment 😄
I only changed property name, so it's not confusing and we can now distinguish between asyncRegistrationFactory and factory parameter 😄

self.identifier = RegistrationIdentifier(type: type)
self.scope = scope
self.asyncRegistrationFactory = { resolver, _ in await factory(resolver) }
}

/// Initializer for registrations that expect a variable argument passed to the factory closure when the dependency is being resolved
init<T: Sendable, Argument: Sendable>(type: T.Type, scope: DependencyScope, factory: @Sendable @escaping (any AsyncDependencyResolving, Argument) async -> T) {
let registrationIdentifier = RegistrationIdentifier(type: type, argument: Argument.self)

self.identifier = registrationIdentifier
self.scope = scope
self.asyncRegistrationFactory = { resolver, arg in
guard let argument = arg as? Argument else {
throw ResolutionError.unmatchingArgumentType(message: "Registration of type \(registrationIdentifier.description) doesn't accept an argument of type \(Argument.self)")
}

return await factory(resolver, argument)
}
}
}
2 changes: 1 addition & 1 deletion Sources/Models/DependencyScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

/// Scope of a dependency
public enum DependencyScope {
public enum DependencyScope: Sendable {
/// A new instance of the dependency is created each time the dependency is resolved from the container.
case new

Expand Down
11 changes: 11 additions & 0 deletions Sources/Protocols/AsyncModuleRegistration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// AsyncModuleRegistration.swift
// DependencyInjection
//
// Created by Róbert Oravec on 17.12.2024.
//

/// Protocol used to enforce common naming of registration in a module.
public protocol AsyncModuleRegistration {
static func registerDependencies(in container: AsyncContainer) async
}
11 changes: 11 additions & 0 deletions Sources/Protocols/ModuleRegistration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// ModuleRegistration.swift
// DependencyInjection
//
// Created by Róbert Oravec on 19.12.2024.
//

/// Protocol used to enforce common naming of registration in a module.
public protocol ModuleRegistration {
static func registerDependencies(in container: Container)
}
94 changes: 94 additions & 0 deletions Sources/Protocols/Registration/AsyncDependencyRegistering.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// AsyncDependencyRegistering.swift
// DependencyInjection
//
// Created by Róbert Oravec on 17.12.2024.
//

import Foundation

/// A type that is able to register a dependency
public protocol AsyncDependencyRegistering {
/// Factory closure that instantiates the required dependency
typealias Factory<Dependency: Sendable> = @Sendable (any AsyncDependencyResolving) async -> Dependency

/// Factory closure that instantiates the required dependency with the given variable argument
typealias FactoryWithArgument<Dependency: Sendable, Argument: Sendable> = @Sendable (any AsyncDependencyResolving, Argument) async -> Dependency

/// Register a dependency
///
/// - Parameters:
/// - type: Type of the dependency to register
/// - scope: Scope of the dependency. If `.new` is used, the `factory` closure is called on each `resolve` call. If `.shared` is used, the `factory` closure is called only the first time, the instance is cached and it is returned for all subsequent `resolve` calls, i.e. it is a singleton
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable>(type: Dependency.Type, in scope: DependencyScope, factory: @escaping Factory<Dependency>) async

/// Register a dependency with a variable argument
///
/// The argument is typically a parameter in an initiliazer of the dependency that is not registered in the same resolver (i.e. container),
/// therefore, it needs to be passed in `resolve` call
///
/// DISCUSSION: This registration method doesn't have any scope parameter for a reason.
/// The container should always return a new instance for dependencies with arguments as the behaviour for resolving shared instances with arguments is undefined.
/// Should the argument conform to ``Equatable`` to compare the arguments to tell whether a shared instance with a given argument was already resolved?
/// Shared instances are typically not dependent on variable input parameters by definition.
/// If you need to support this usecase, please, keep references to the variable singletons outside of the container.
///
/// - Parameters:
/// - type: Type of the dependency to register
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable, Argument: Sendable>(type: Dependency.Type, factory: @escaping FactoryWithArgument<Dependency, Argument>) async
}

// MARK: Overloaded factory methods
public extension AsyncDependencyRegistering {
/// Default ``DependencyScope`` value
///
/// The default value is `shared`
static var defaultScope: DependencyScope {
DependencyScope.shared
}

/// Register a dependency in the default ``DependencyScope``, i.e. in the `shared` scope
///
/// - Parameters:
/// - type: Type of the dependency to register
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable>(type: Dependency.Type, factory: @escaping Factory<Dependency>) async {
await register(type: type, in: Self.defaultScope, factory: factory)
}

/// Register a dependency with an implicit type determined by the factory closure return type
///
/// - Parameters:
/// - scope: Scope of the dependency. If `.new` is used, the `factory` closure is called on each `resolve` call. If `.shared` is used, the `factory` closure is called only the first time, the instance is cached and it is returned for all subsequent `resolve` calls, i.e. it is a singleton
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable>(in scope: DependencyScope, factory: @escaping Factory<Dependency>) async {
await register(type: Dependency.self, in: scope, factory: factory)
}

/// Register a dependency with an implicit type determined by the factory closure return type and in the default ``DependencyScope``, i.e. in the `shared` scope
///
/// - Parameters:
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable>(factory: @escaping Factory<Dependency>) async {
await register(type: Dependency.self, in: Self.defaultScope, factory: factory)
}

/// Register a dependency with a variable argument. The type of the dependency is determined implicitly based on the factory closure return type
///
/// The argument is typically a parameter in an initializer of the dependency that is not registered in the same resolver (i.e. container),
/// therefore, it needs to be passed in `resolve` call
///
/// DISCUSSION: This registration method doesn't have any scope parameter for a reason.
/// The container should always return a new instance for dependencies with arguments as the behaviour for resolving shared instances with arguments is undefined.
/// Should the argument conform to ``Equatable`` to compare the arguments to tell whether a shared instance with a given argument was already resolved?
/// Shared instances are typically not dependent on variable input parameters by definition.
/// If you need to support this usecase, please, keep references to the variable singletons outside of the container.
///
/// - Parameters:
/// - factory: Closure that is called when the dependency is being resolved
func register<Dependency: Sendable, Argument: Sendable>(factory: @escaping FactoryWithArgument<Dependency, Argument>) async {
await register(type: Dependency.self, factory: factory)
}
}
Loading
Loading