diff --git a/Package.swift b/Package.swift index 8046263..a92e1f3 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,12 @@ let package = Package( targets: [ .target( name: "SCInject", + dependencies: [ + .target(name: "SCInjectObjc"), + ] + ), + .target( + name: "SCInjectObjc", dependencies: [] ), .testTarget( diff --git a/Sources/SCInject/Container.swift b/Sources/SCInject/Container.swift index 303cb23..2f0eb0c 100644 --- a/Sources/SCInject/Container.swift +++ b/Sources/SCInject/Container.swift @@ -72,9 +72,8 @@ public final class DefaultContainer: Container { public func resolve(_ type: T.Type) -> T { guard let instance = tryResolve(type) else { - let message = "Failed to resolve given type -- TYPE=\(type)" - Exception.raise(reason: message) - fatalError(message) + ContainerError.raise(reason: "Failed to resolve given type", type: "\(type)", name: nil) + fatalError() } return instance } @@ -85,9 +84,8 @@ public final class DefaultContainer: Container { public func resolve(_ type: T.Type, name: RegistrationName) -> T { guard let instance = tryResolve(type, name: name) else { - let message = "Failed to resolve given type -- TYPE=\(type) NAME=\(name.rawValue)" - Exception.raise(reason: message) - fatalError(message) + ContainerError.raise(reason: "Failed to resolve given type", type: "\(type)", name: name.rawValue) + fatalError() } return instance } @@ -106,6 +104,34 @@ public final class DefaultContainer: Container { tryResolve(type: type, name: name, container: self) } + /// Validates the dependency graph to ensure that all dependencies + /// can be successfully resolved. + /// + /// This method attempts to resolve each dependency registered in the container, + /// verifying that all dependencies can be satisfied without errors. If any + /// dependency cannot be resolved, an error is thrown to indicate + /// the issue. + /// + /// The validation process is performed recursively. If the container has a + /// parent container, the method will also validate the parent container's + /// dependency graph. + /// + /// - Throws: + /// - `ContainerError` if any dependency within the graph cannot be resolved. + /// + /// - Note: + /// This method is useful for ensuring that the dependency injection setup + /// is correct. It can be particularly valuable during development or testing + /// to catch configuration issues early. + public func validate() throws { + try ContainerError.rethrow { + for resolver in resolvers { + _ = resolver.value.resolve(with: self) + } + } + try parent?.validate() + } + // MARK: - Private private func register( @@ -117,9 +143,8 @@ public final class DefaultContainer: Container { lock.lock(); defer { lock.unlock() } let identifier = identifier(of: type, name: name) if resolvers[identifier] != nil { - let message = "Given type is already registered -- TYPE=\(type) NAME=\(name?.rawValue ?? "nil")" - Exception.raise(reason: message) - fatalError(message) + ContainerError.raise(reason: "Given type is already registered", type: "\(type)", name: name?.rawValue) + fatalError() } resolvers[identifier] = makeResolver(scope ?? defaultScope, closure: closure) } diff --git a/Sources/SCInject/ContainerError.swift b/Sources/SCInject/ContainerError.swift new file mode 100644 index 0000000..2f075f3 --- /dev/null +++ b/Sources/SCInject/ContainerError.swift @@ -0,0 +1,62 @@ +// +// Copyright 2024 Marcin Iwanicki and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SCInjectObjc + +/// A `ContainerError` represents an error that occurs during the registration +/// or resolution in the dependency injection container. +/// +/// This error type is used to encapsulate specific details about the failure, +/// including the reason for the error, the type of the service that caused the +/// error, and an optional name associated with the service. +public struct ContainerError: Error { + /// A description of the reason why the error occurred. + public let reason: String + + /// The type of the dependency that caused the error. + public let type: String + + /// The name associated with the dependency. + public let name: String? + + static func rethrow(_ closure: () -> Void) throws { + try rethrow { + try ContainerException.catch(closure) + } + } + + static func raise(reason: String, type: String, name: String?) { + ContainerException.raise(reason: reason, type: type, name: nil) + } + + // MARK: - Private + + private static func rethrow(_ closure: () throws -> Void) rethrows { + do { + try closure() + } catch { + let nsError = error as NSError + // swiftlint:disable:next force_cast + let reason = nsError.userInfo[NSLocalizedDescriptionKey] as! String + + // swiftlint:disable:next force_cast + let type = nsError.userInfo[CSContainerExceptionTypeKey] as! String + let name = nsError.userInfo[CSContainerExceptionNameKey] as? String + throw ContainerError(reason: reason, type: type, name: name) + } + } +} diff --git a/Sources/SCInjectObjc/ContainerException.m b/Sources/SCInjectObjc/ContainerException.m new file mode 100644 index 0000000..af97b5c --- /dev/null +++ b/Sources/SCInjectObjc/ContainerException.m @@ -0,0 +1,59 @@ +// +// Copyright 2024 Marcin Iwanicki and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "ContainerException.h" + +NSString* const CSContainerExceptionTypeKey = @"SCContainerExceptionTypeKey"; +NSString* const CSContainerExceptionNameKey = @"SCContainerExceptionNameKey"; + +@implementation ContainerException + ++ (void)raiseWithReason:(NSString *)reason type:(NSString *)type name:(nullable NSString *)name { + NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:@{ + CSContainerExceptionTypeKey: type + }]; + if (name) { + userInfo[CSContainerExceptionNameKey] = name; + } + @throw [NSException exceptionWithName:@"SCBasicObjc.Exception" + reason:reason + userInfo:userInfo]; +} + ++ (void)catchException:(__attribute__((noescape)) void (^)(void))operation + withError:(NSError **)error { + @try { + operation(); + } + @catch (NSException *exception) { + if (error) { + NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:@{ + NSLocalizedDescriptionKey: exception.reason, + NSLocalizedFailureReasonErrorKey: exception.name, + CSContainerExceptionTypeKey: exception.userInfo[CSContainerExceptionTypeKey] + }]; + NSString *name = exception.userInfo[CSContainerExceptionNameKey]; + if (name) { + userInfo[CSContainerExceptionNameKey] = name; + } + *error = [NSError errorWithDomain:@"SCBasicObjc.Exception" + code:1 + userInfo:userInfo]; + } + } +} + +@end diff --git a/Sources/SCInject/Exception.swift b/Sources/SCInjectObjc/include/ContainerException.h similarity index 53% rename from Sources/SCInject/Exception.swift rename to Sources/SCInjectObjc/include/ContainerException.h index 0f6ac62..99e70c4 100644 --- a/Sources/SCInject/Exception.swift +++ b/Sources/SCInjectObjc/include/ContainerException.h @@ -14,14 +14,19 @@ // limitations under the License. // -import Foundation +#import -final class Exception: NSException { - static func raise(reason: String, userInfo: [String: String] = [:]) { - Exception( - name: NSExceptionName(rawValue: "SCInject.Exception"), - reason: reason, - userInfo: userInfo - ).raise() - } -} +NS_ASSUME_NONNULL_BEGIN + +extern NSString* const CSContainerExceptionTypeKey; +extern NSString* const CSContainerExceptionNameKey; + +@interface ContainerException : NSObject + ++ (void)raiseWithReason:(NSString *)reason type:(NSString *)type name:(nullable NSString *)name NS_SWIFT_NAME(raise(reason:type:name:)); ++ (void)catchException:(__attribute__((noescape)) void (^)(void))operation + withError:(NSError **)error __attribute__((swift_error(nonnull_error))); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Tests/SCInject/ContainerTests.swift b/Tests/SCInject/ContainerTests.swift index 2a3cb8e..2985762 100644 --- a/Tests/SCInject/ContainerTests.swift +++ b/Tests/SCInject/ContainerTests.swift @@ -83,6 +83,44 @@ final class ContainerTests: XCTestCase { XCTAssertTrue(class1 !== class1_name) XCTAssertTrue(class2?.value !== class1_name) } + + func testValidate() { + // Given + let second: RegistrationName = .init(rawValue: "second") + let container = DefaultContainer() + container.register(TestClass1.self) { _ in + TestClass1(value: "TestClass1_Instance") + } + container.register(TestClass1.self, name: second) { _ in + TestClass1(value: "TestClass1_Second_Instance") + } + container.register(TestClass2.self) { r in + TestClass2(value: r.resolve(TestClass1.self, name: second)) + } + + // When / Then + XCTAssertNoThrow(try container.validate()) + } + + func testValidate_missingNamedType() { + // Given + let second: RegistrationName = .init(rawValue: "second") + let container = DefaultContainer() + container.register(TestClass1.self) { _ in + TestClass1(value: "TestClass1_Instance") + } + container.register(TestClass2.self) { r in + TestClass2(value: r.resolve(TestClass1.self, name: second)) + } + + // When / Then + XCTAssertThrowsError(try container.validate()) { error in + let error = error as? ContainerError + XCTAssertEqual(error?.reason, "Failed to resolve given type") + XCTAssertEqual(error?.type, "TestClass1") + XCTAssertNil(error?.name) + } + } } // swiftlint:enable identifier_name