diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Cartfile b/Cartfile index 1a59c8b..4b66c06 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1 @@ -github "Swinject/Swinject" ~> 2.0.0 +github "Swinject/Swinject" ~> 2.9.1 diff --git a/Cartfile.resolved b/Cartfile.resolved index 5eb601e..564bec2 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ -github "Swinject/Swinject" "2.7.1" +github "Swinject/Swinject" "2.9.1" github "jspahrsummers/xcconfigs" "1.1" diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..aad0f6d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swinject", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Swinject/Swinject.git", + "state" : { + "revision" : "b685b549fe4d8ae265fc7a2f27d0789720425d69", + "version" : "2.10.0" + } + }, + { + "identity" : "tomlkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LebJe/TOMLKit.git", + "state" : { + "revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f", + "version" : "0.6.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8810d84 --- /dev/null +++ b/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SwinjectPropertyLoader", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8) + ], + products: [ + .library( + name: "SwinjectPropertyLoader", + targets: ["SwinjectPropertyLoader"]), + ], + dependencies: [ + .package(url: "https://github.com/Swinject/Swinject.git", from: "2.9.1"), + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0") + ], + targets: [ + .target( + name: "SwinjectPropertyLoader", + dependencies: ["Swinject", "TOMLKit"], + path: "Sources"), + .testTarget( + name: "SwinjectPropertyLoaderTests", + dependencies: ["SwinjectPropertyLoader"], + path: "Tests", + resources: [ + .process("Resources") + ]), + ], + swiftLanguageVersions: [.v5, .version("6")] +) diff --git a/README.md b/README.md index bc5cdbb..8e93330 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,41 @@ SwinjectPropertyLoader [![CocoaPods Version](https://img.shields.io/cocoapods/v/SwinjectPropertyLoader.svg?style=flat)](http://cocoapods.org/pods/SwinjectPropertyLoader) [![License](https://img.shields.io/cocoapods/l/SwinjectPropertyLoader.svg?style=flat)](http://cocoapods.org/pods/SwinjectPropertyLoader) [![Platform](https://img.shields.io/cocoapods/p/SwinjectPropertyLoader.svg?style=flat)](http://cocoapods.org/pods/SwinjectPropertyLoader) -[![Swift Version](https://img.shields.io/badge/Swift-2.2--3.0.x-F16D39.svg?style=flat)](https://developer.apple.com/swift) +[![Swift Version](https://img.shields.io/badge/Swift-6.0-F16D39.svg?style=flat)](https://developer.apple.com/swift) SwinjectPropertyLoader is an extension of Swinject to load property values from resources that are bundled with your application or framework. ## Requirements -- iOS 8.0+ / Mac OS X 10.10+ / watchOS 2.0+ / tvOS 9.0+ -- Swift 2.2 or 2.3 - - Xcode 7.0+ -- Swift 3.0.x - - Xcode 8.0+ -- Carthage 0.18+ (if you use) -- CocoaPods 1.1.1+ (if you use) +- iOS 15.0+ / macOS 12.0+ / watchOS 8.0+ / tvOS 15.0+ +- Swift 5.9+ +- Xcode 15.0+ ## Installation -Swinject is available through [Carthage](https://github.com/Carthage/Carthage) or [CocoaPods](https://cocoapods.org). +### Swift Package Manager + +To install SwinjectPropertyLoader using Swift Package Manager, add the following to your `Package.swift` file: + +```swift +dependencies: [ + .package(url: "https://github.com/Swinject/SwinjectPropertyLoader.git", from: "2.0.0") +] +``` + +Or add it through Xcode: +1. File > Add Package Dependencies... +2. Enter package URL: `https://github.com/Swinject/SwinjectPropertyLoader.git` +3. Select version 2.0.0 or later ### Carthage -To install Swinject with Carthage, add the following line to your `Cartfile`. +To install SwinjectPropertyLoader with Carthage, add the following line to your `Cartfile`: ``` -github "Swinject/Swinject" ~> 2.0.0 -github "Swinject/SwinjectPropertyLoader" ~> 1.0.0 +github "Swinject/Swinject" ~> 2.9.1 +github "Swinject/SwinjectPropertyLoader" ~> 2.0.0 ``` Then run `carthage update --no-use-binaries` command or just `carthage update`. For details of the installation and usage of Carthage, visit [its project page](https://github.com/Carthage/Carthage). @@ -39,15 +48,15 @@ Then run `carthage update --no-use-binaries` command or just `carthage update`. ### CocoaPods -To install Swinject with CocoaPods, add the following lines to your `Podfile`. +To install SwinjectPropertyLoader with CocoaPods, add the following lines to your `Podfile`: ```ruby source 'https://github.com/CocoaPods/Specs.git' -platform :ios, '8.0' # or platform :osx, '10.10' if your target is OS X. +platform :ios, '15.0' # or platform :osx, '12.0' for macOS use_frameworks! -pod 'Swinject', '~> 2.0.0' -pod 'SwinjectPropertyLoader', '~> 1.0.0' +pod 'Swinject', '~> 2.9.1' +pod 'SwinjectPropertyLoader', '~> 2.0.0' ``` Then run `pod install` command. For details of the installation and usage of CocoaPods, visit [its official website](https://cocoapods.org). @@ -57,19 +66,23 @@ Then run `pod install` command. For details of the installation and usage of Coc Properties are values that can be loaded from resources that are bundled with your application/framework. Properties can then be used when assembling definitions in your container. -There are 2 types of support property formats: +There are 4 types of supported property loaders: - - JSON (`JsonPropertyLoader`) - - Plist (`PlistPropertyLoader`) + - JSON (`JsonPropertyLoader`) - Load from JSON files + - Plist (`PlistPropertyLoader`) - Load from Plist files + - TOML (`TomlPropertyLoader`) - Load from TOML files with dot notation + - Struct (`StructPropertyLoader`) - Load from Swift struct/class instances using reflection -Each format supports the types specified by the format itself. If JSON format is used -then your basic types: `Bool`, `Int`, `Double`, `String`, `Array` and `Dictionary` are -supported. For Plist, all types supported by the Plist are supported which include all -JSON types plus `NSDate` and `NSData`. +Each loader supports different value types: +- **JSON**: `Bool`, `Int`, `Double`, `String`, `Array`, `Dictionary` (with comment support) +- **Plist**: All JSON types plus `NSDate` and `NSData` +- **TOML**: All TOML types (integers, floats, booleans, strings, dates, arrays, tables) - nested tables are auto-flattened to dot notation +- **Struct**: All Swift types via reflection - nested structs/classes are auto-flattened to dot notation -JSON property files also support comments which allow you to provide more context to -your properties besides your property key names. For example: +JSON and TOML property files support comments which allow you to provide more context to +your properties besides your property key names. +**JSON comments:** ```js { // Comment type 1 @@ -85,17 +98,65 @@ your properties besides your property key names. For example: } ``` +**TOML comments and nested tables:** +```toml +# TOML natively supports comments +foo = "bar" +baz = 100 + +# Nested tables are automatically flattened to dot notation +[api] +base_url = "https://api.example.com" +timeout = 30 + +[packages.unlimited] +cost = 99.99 +features = ["feature1", "feature2"] +``` + +TOML nested tables are automatically flattened, so `[api]` with `base_url = "..."` becomes +accessible as `r.property("api.base_url")` in your container. + Loading properties into the container is as simple as: ```swift let container = Container() -// will load "properties.json" from the main app bundle -let loader = JsonPropertyLoader(bundle: .mainBundle(), name: "properties") - -try! container.applyPropertyLoader(loader) +// Load from bundle (traditional approach) +let jsonLoader = JsonPropertyLoader(bundle: .main, name: "properties") +try container.applyPropertyLoader(jsonLoader) + +// Or load from a URL (decoupled from bundle) +let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] +let configURL = documentsURL.appendingPathComponent("config.json") +let urlLoader = JsonPropertyLoader(url: configURL) +try container.applyPropertyLoader(urlLoader) + +// Load TOML with automatic dot notation for nested tables +let tomlLoader = TomlPropertyLoader(bundle: .main, name: "config") +try container.applyPropertyLoader(tomlLoader) + +// TOML from URL +let tomlURL = documentsURL.appendingPathComponent("config.toml") +let tomlURLLoader = TomlPropertyLoader(url: tomlURL) +try container.applyPropertyLoader(tomlURLLoader) + +// Load from Swift struct/class using reflection (no file needed!) +struct AppConfig { + let apiKey = "secret123" + let timeout = 30 +} +let config = AppConfig() +let structLoader = StructPropertyLoader(config) +try container.applyPropertyLoader(structLoader) ``` +The URL-based loading allows you to load properties from anywhere in the file system, making it useful for: +- Downloaded configuration files +- User-specific settings stored in Documents +- Temporary configuration files +- Files in Application Support directory + Now you can inject properties into definitions registered into the container. Consider the following definition: @@ -189,6 +250,136 @@ And: The resulting value for `items` would be: `[ "hello from B" ]` +### TOML Dot Notation Example + +TOML's nested table structure is particularly useful for organizing hierarchical configuration: + +```toml +# config.toml +[api] +base_url = "https://api.example.com" +timeout = 30 +api_key = "secret123" + +[database] +host = "localhost" +port = 5432 +name = "myapp" + +[packages.unlimited] +cost = 99.99 +features = ["feature1", "feature2", "feature3"] + +[packages.basic] +cost = 9.99 +features = ["feature1"] +``` + +This automatically becomes accessible via dot notation: + +```swift +container.register(APIClient.self) { r in + let client = APIClient() + client.baseURL = r.property("api.base_url") // "https://api.example.com" + client.timeout = r.property("api.timeout") // 30 + client.apiKey = r.property("api.api_key") // "secret123" + return client +} + +container.register(Database.self) { r in + let db = Database() + db.host = r.property("database.host")! // "localhost" + db.port = r.property("database.port")! // 5432 + db.name = r.property("database.name")! // "myapp" + return db +} + +container.register(PricingService.self) { r in + let service = PricingService() + service.unlimitedCost = r.property("packages.unlimited.cost")! // 99.99 + return service +} +``` + +### Struct Reflection Example + +For type-safe, programmatic configuration without external files, use `StructPropertyLoader`: + +```swift +// Define a configuration struct +struct AppConfig { + struct API { + let baseURL = "https://api.example.com" + let timeout = 30 + let apiKey = "secret123" + } + + let api = API() + let appName = "MyApp" + let debugMode = false +} + +// Load properties from struct instance +let config = AppConfig() +let loader = StructPropertyLoader(config) +try container.applyPropertyLoader(loader) + +// Access with dot notation (nested structs are auto-flattened) +container.register(APIClient.self) { r in + let client = APIClient() + client.baseURL = r.property("api.baseURL")! // "https://api.example.com" + client.timeout = r.property("api.timeout")! // 30 + client.apiKey = r.property("api.apiKey")! // "secret123" + return client +} + +let appName: String? = container.property("appName") // "MyApp" +let debugMode: Bool? = container.property("debugMode") // false +``` + +**Benefits of StructPropertyLoader:** +- **Type-safe**: Compile-time checking of property types +- **No files**: Pure Swift configuration, no external resources +- **Dot notation**: Nested structs automatically flatten (like TOML) +- **Testing**: Perfect for default configs and test fixtures +- **Optionals**: Nil optionals are automatically skipped + +### Type-Safe Property Keys + +For better autocomplete, compile-time safety, and refactoring support, use `PropertyKey` instead of strings: + +```swift +// Define your keys (anywhere in any module) +extension PropertyKey { + // Recommended: Explicit constructor (self-documenting) + static let apiBaseURL = PropertyKey("api.baseURL") + static let apiTimeout = PropertyKey("api.timeout") + static let apiKey = PropertyKey("api.key") + + // Alternative: String literal with type annotation (also valid) + static let debugMode: PropertyKey = "debug.enabled" +} + +// Type-safe access with autocomplete +container.register(APIClient.self) { r in + let client = APIClient() + + // Type-safe property access + client.baseURL = r.property(forKey: .apiBaseURL) + client.timeout = r.property(forKey: .apiTimeout) ?? 30 // Use ?? for defaults + + return client +} +``` + +**Benefits:** +- ✅ **Autocomplete**: All defined keys appear in Xcode autocomplete +- ✅ **Type-safe**: Compiler catches typos and missing keys +- ✅ **Refactoring**: Rename works correctly across your codebase +- ✅ **Extensible**: Define keys in any module via extensions +- ✅ **Backward compatible**: String-based API still works + + ## Contributors SwinjectPropertyLoader has been originally written by [Mike Owens](https://github.com/mowens). diff --git a/Sources/JsonPropertyLoader.swift b/Sources/JsonPropertyLoader.swift index c199d2a..dc3cf15 100644 --- a/Sources/JsonPropertyLoader.swift +++ b/Sources/JsonPropertyLoader.swift @@ -10,24 +10,39 @@ import Foundation /// The JsonPropertyLoader will load properties from JSON resources -final public class JsonPropertyLoader { - +final public class JsonPropertyLoader: Sendable { + /// the bundle where the resource exists (defaults to mainBundle) - fileprivate let bundle: Bundle - + fileprivate let bundle: Bundle? + /// the name of the JSON resource. For example, if your resource is "properties.json" then this value will be set to "properties" - fileprivate let name: String - + fileprivate let name: String? + + /// the URL where the resource exists (used instead of bundle if provided) + fileprivate let url: URL? + /// - /// Will create a JSON property loader + /// Will create a JSON property loader from a bundle resource /// /// - parameter bundle: the bundle where the resource exists (defaults to mainBundle) - /// - parameter name: the name of the JSON resource. For example, if your resource is "properties.json" + /// - parameter name: the name of the JSON resource. For example, if your resource is "properties.json" /// then this value will be set to "properties" /// - public init(bundle: Bundle? = Bundle.main, name: String) { - self.bundle = bundle! + public init(bundle: Bundle = .main, name: String) { + self.bundle = bundle self.name = name + self.url = nil + } + + /// + /// Will create a JSON property loader from a URL + /// + /// - parameter url: the URL where the JSON resource exists + /// + public init(url: URL) { + self.bundle = nil + self.name = nil + self.url = url } /// Will strip the provide string of comments. This allows JSON property files to contain comments as it @@ -59,15 +74,35 @@ final public class JsonPropertyLoader { // MARK: - PropertyLoadable extension JsonPropertyLoader: PropertyLoader { public func load() throws -> [String: Any] { - let contents = try loadStringFromBundle(bundle, withName: name, ofType: "json") + let contents: String + + if let url = url { + // Load from URL + contents = try loadStringFromURL(url) + } else if let bundle = bundle, let name = name { + // Load from bundle + contents = try loadStringFromBundle(bundle, withName: name, ofType: "json") + } else { + fatalError("JsonPropertyLoader must be initialized with either a URL or bundle+name") + } + let jsonWithoutComments = stringWithoutComments(contents) - let data = jsonWithoutComments.data(using: String.Encoding.utf8) - - let json = try? JSONSerialization.jsonObject(with: data!, options: []) + guard let data = jsonWithoutComments.data(using: .utf8) else { + if let url = url { + throw PropertyLoaderError.invalidJSONFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidJSONFormat(bundle: bundle!, name: name!) + } + } + + let json = try JSONSerialization.jsonObject(with: data, options: []) guard let props = json as? [String: Any] else { - throw PropertyLoaderError.invalidJSONFormat(bundle: bundle, name: name) + if let url = url { + throw PropertyLoaderError.invalidJSONFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidJSONFormat(bundle: bundle!, name: name!) + } } return props - } } diff --git a/Sources/PlistPropertyLoader.swift b/Sources/PlistPropertyLoader.swift index 5ccfe07..3de376e 100644 --- a/Sources/PlistPropertyLoader.swift +++ b/Sources/PlistPropertyLoader.swift @@ -10,34 +10,64 @@ import Foundation /// The PlistPropertyLoader will load properties from plist resources -final public class PlistPropertyLoader { - +final public class PlistPropertyLoader: Sendable { + /// the bundle where the resource exists (defaults to mainBundle) - fileprivate let bundle: Bundle - - /// the name of the JSON resource. For example, if your resource is "properties.json" then this value will be set to "properties" - fileprivate let name: String - + fileprivate let bundle: Bundle? + + /// the name of the plist resource. For example, if your resource is "properties.plist" then this value will be set to "properties" + fileprivate let name: String? + + /// the URL where the resource exists (used instead of bundle if provided) + fileprivate let url: URL? + /// - /// Will create a plist property loader + /// Will create a plist property loader from a bundle resource /// /// - parameter bundle: the bundle where the resource exists (defaults to mainBundle) - /// - parameter name: the name of the JSON resource. For example, if your resource is "properties.plist" + /// - parameter name: the name of the plist resource. For example, if your resource is "properties.plist" /// then this value will be set to "properties" /// - public init(bundle: Bundle? = Bundle.main, name: String) { - self.bundle = bundle! + public init(bundle: Bundle = .main, name: String) { + self.bundle = bundle self.name = name + self.url = nil + } + + /// + /// Will create a plist property loader from a URL + /// + /// - parameter url: the URL where the plist resource exists + /// + public init(url: URL) { + self.bundle = nil + self.name = nil + self.url = url } } // MARK: - PropertyLoadable extension PlistPropertyLoader: PropertyLoader { public func load() throws -> [String: Any] { - let data = try loadDataFromBundle(bundle, withName: name, ofType: "plist") - let plist = try PropertyListSerialization.propertyList(from: data, options: PropertyListSerialization.MutabilityOptions(), format: nil) + let data: Data + + if let url = url { + // Load from URL + data = try loadDataFromURL(url) + } else if let bundle = bundle, let name = name { + // Load from bundle + data = try loadDataFromBundle(bundle, withName: name, ofType: "plist") + } else { + fatalError("PlistPropertyLoader must be initialized with either a URL or bundle+name") + } + + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) guard let props = plist as? [String: Any] else { - throw PropertyLoaderError.invalidPlistFormat(bundle: bundle, name: name) + if let url = url { + throw PropertyLoaderError.invalidPlistFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidPlistFormat(bundle: bundle!, name: name!) + } } return props } diff --git a/Sources/PropertyKey.swift b/Sources/PropertyKey.swift new file mode 100644 index 0000000..bf0b1e2 --- /dev/null +++ b/Sources/PropertyKey.swift @@ -0,0 +1,86 @@ +// +// PropertyKey.swift +// Swinject +// +// Copyright © 2025 Swinject Contributors. All rights reserved. +// + +import Foundation + +/// A type-safe wrapper for property keys used with Swinject PropertyLoader. +/// +/// PropertyKey follows the same extensible pattern as NotificationCenter.Name, +/// allowing modules to define their own property keys in a type-safe manner. +/// +/// Example: +/// ```swift +/// extension PropertyKey { +/// static let apiBaseURL = PropertyKey("api.baseURL") +/// static let apiTimeout = PropertyKey("api.timeout") +/// } +/// +/// // Usage +/// let url: String? = resolver.property(.apiBaseURL) +/// let timeout: Int? = resolver.property(.apiTimeout) +/// ``` +public struct PropertyKey: + Hashable, + Equatable, + RawRepresentable, + ExpressibleByStringLiteral, + Sendable +{ + /// The raw string value of the property key + public let rawValue: String + + /// Creates a PropertyKey with the specified string value. + /// + /// - Parameter rawValue: The string value for this key + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Creates a PropertyKey with the specified string value. + /// + /// This is a convenience initializer that allows cleaner syntax: + /// ```swift + /// static let myKey = PropertyKey("my.key") + /// ``` + /// + /// - Parameter value: The string value for this key + public init(_ value: String) { + self.rawValue = value + } + + // MARK: - ExpressibleByStringLiteral + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static func == (lhs: PropertyKey, rhs: PropertyKey) -> Bool { + return lhs.rawValue == rhs.rawValue + } + + // MARK: - Hashable & Equatable + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} + +// MARK: CustomStringConvertible + +extension PropertyKey: CustomStringConvertible { + public var description: String { + return rawValue + } +} + +// MARK: CustomDebugStringConvertible + +extension PropertyKey: CustomDebugStringConvertible { + public var debugDescription: String { + return "PropertyKey(\"\(rawValue)\")" + } +} diff --git a/Sources/PropertyLoader.swift b/Sources/PropertyLoader.swift index dc490c2..6416c62 100644 --- a/Sources/PropertyLoader.swift +++ b/Sources/PropertyLoader.swift @@ -54,3 +54,67 @@ func loadDataFromBundle(_ bundle: Bundle, withName name: String, ofType type: St } throw PropertyLoaderError.missingResource(bundle: bundle, name: name) } + +/// Helper function to load the contents of a URL into a string. +/// +/// - Parameter url: the URL where the resource exists +/// +/// - Returns: the contents of the resource as a string +/// - Throws: PropertyLoaderError if the resource doesn't exist or cannot be read +func loadStringFromURL(_ url: URL) throws -> String { + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + if (error as NSError).code == NSFileReadNoSuchFileError { + throw PropertyLoaderError.missingResourceURL(url: url) + } + throw PropertyLoaderError.invalidResourceDataFormatURL(url: url) + } +} + +/// Helper function to load the contents of a URL into data. +/// +/// - Parameter url: the URL where the resource exists +/// +/// - Returns: the contents of the resource as data +/// - Throws: PropertyLoaderError if the resource doesn't exist or cannot be read +func loadDataFromURL(_ url: URL) throws -> Data { + do { + return try Data(contentsOf: url) + } catch { + if (error as NSError).code == NSFileReadNoSuchFileError { + throw PropertyLoaderError.missingResourceURL(url: url) + } + throw PropertyLoaderError.invalidResourceDataFormatURL(url: url) + } +} + +/// Helper function to flatten a nested dictionary into dot-notation keys. +/// Used by TOML loader to convert nested tables into flat property keys. +/// +/// Example: +/// Input: ["api": ["base_url": "https://example.com", "timeout": 30]] +/// Output: ["api.base_url": "https://example.com", "api.timeout": 30] +/// +/// - Parameter dict: the nested dictionary to flatten +/// - Parameter prefix: the current key prefix (used for recursion) +/// +/// - Returns: flattened dictionary with dot-notation keys +func flattenDictionary(_ dict: [String: Any], prefix: String = "") -> [String: Any] { + var result = [String: Any]() + + for (key, value) in dict { + let newKey = prefix.isEmpty ? key : "\(prefix).\(key)" + + if let nestedDict = value as? [String: Any] { + // Recursively flatten nested dictionaries + let flattened = flattenDictionary(nestedDict, prefix: newKey) + result.merge(flattened) { _, new in new } + } else { + // Store the value with flattened key + result[newKey] = value + } + } + + return result +} diff --git a/Sources/PropertyLoaderError.swift b/Sources/PropertyLoaderError.swift index 608a412..12e8806 100644 --- a/Sources/PropertyLoaderError.swift +++ b/Sources/PropertyLoaderError.swift @@ -13,14 +13,23 @@ import Foundation /// /// - InvalidJSONFormat: The JSON format of the properties file is incorrect. Must be top-level dictionary /// - InvalidPlistFormat: The Plist format of the properties file is incorrect. Must be top-level dictionary -/// - MissingResource: The resource is missing from the bundle -/// - InvalidResourceDataFormat: The resource cannot be converted to NSData +/// - InvalidTOMLFormat: The TOML format of the properties file is incorrect. Must be top-level table +/// - MissingResource: The resource is missing from the bundle or URL +/// - InvalidResourceDataFormat: The resource cannot be converted to Data /// -public enum PropertyLoaderError: Error { +public enum PropertyLoaderError: Error, Sendable { case invalidJSONFormat(bundle: Bundle, name: String) case invalidPlistFormat(bundle: Bundle, name: String) + case invalidTOMLFormat(bundle: Bundle, name: String) case missingResource(bundle: Bundle, name: String) case invalidResourceDataFormat(bundle: Bundle, name: String) + + // URL-based errors + case invalidJSONFormatURL(url: URL) + case invalidPlistFormatURL(url: URL) + case invalidTOMLFormatURL(url: URL) + case missingResourceURL(url: URL) + case invalidResourceDataFormatURL(url: URL) } // MARK: - CustomStringConvertible @@ -31,10 +40,22 @@ extension PropertyLoaderError: CustomStringConvertible { return "Invalid JSON format for bundle: \(bundle), name: \(name). Must be top-level dictionary" case .invalidPlistFormat(let bundle, let name): return "Invalid Plist format for bundle: \(bundle), name: \(name). Must be top-level dictionary" + case .invalidTOMLFormat(let bundle, let name): + return "Invalid TOML format for bundle: \(bundle), name: \(name). Must be top-level table" case .missingResource(let bundle, let name): return "Missing resource for bundle: \(bundle), name: \(name)" case .invalidResourceDataFormat(let bundle, let name): return "Invalid resource format for bundle: \(bundle), name: \(name)" + case .invalidJSONFormatURL(let url): + return "Invalid JSON format for URL: \(url). Must be top-level dictionary" + case .invalidPlistFormatURL(let url): + return "Invalid Plist format for URL: \(url). Must be top-level dictionary" + case .invalidTOMLFormatURL(let url): + return "Invalid TOML format for URL: \(url). Must be top-level table" + case .missingResourceURL(let url): + return "Missing resource at URL: \(url)" + case .invalidResourceDataFormatURL(let url): + return "Invalid resource format at URL: \(url)" } } } diff --git a/Sources/Resolver+Properties.swift b/Sources/Resolver+Properties.swift index a8b81a4..e3b64e4 100644 --- a/Sources/Resolver+Properties.swift +++ b/Sources/Resolver+Properties.swift @@ -6,10 +6,12 @@ // Copyright © 2016 Swinject Contributors. All rights reserved. // +import Foundation import Swinject +import ObjectiveC private struct AssociatedKeys { - fileprivate static var properties: UInt8 = 0 + fileprivate nonisolated(unsafe) static var properties: UInt8 = 0 } extension Resolver { @@ -52,4 +54,53 @@ extension Resolver { public func property(_ name: String) -> Property? { return properties[name] as? Property } + + // MARK: - Type-Safe Property Access + + /// Retrieves a property for the given PropertyKey where the receiving property is optional. + /// + /// This is the type-safe version that provides autocomplete and compile-time checking. + /// + /// Example: + /// ```swift + /// extension PropertyKey { + /// static let apiTimeout = PropertyKey("api.timeout") + /// } + /// + /// // Usage + /// let timeout: Int? = resolver.property(forKey: .apiTimeout) + /// ``` + /// + /// - Parameter key: The PropertyKey for the property + /// - Returns: The value for the property key, or nil if not found + public func property(forKey key: PropertyKey) -> Property? { + return properties[key.rawValue] as? Property + } + + /// A string description of all loaded properties for debugging purposes. + /// + /// Example output: + /// ``` + /// Properties: [ + /// "api.baseURL": "https://api.example.com" (String), + /// "api.timeout": 30 (Int), + /// "debug.enabled": true (Bool) + /// ] + /// ``` + public var propertiesDescription: String { + let props = properties + + guard !props.isEmpty else { + return "Properties: []" + } + + let sortedKeys = props.keys.sorted() + let descriptions = sortedKeys.map { key in + let value = props[key]! + let typeName = type(of: value) + return " \"\(key)\": \(value) (\(typeName))" + } + + return "Properties: [\n" + descriptions.joined(separator: ",\n") + "\n]" + } } diff --git a/Sources/StructPropertyLoader.swift b/Sources/StructPropertyLoader.swift new file mode 100644 index 0000000..1238c74 --- /dev/null +++ b/Sources/StructPropertyLoader.swift @@ -0,0 +1,135 @@ +// +// StructPropertyLoader.swift +// Swinject +// +// Created for SwinjectPropertyLoader +// Copyright © 2025 Swinject Contributors. All rights reserved. +// + +import Foundation + + +/// The StructPropertyLoader will load properties from a Swift struct or class instance using reflection +/// Nested structs/classes are automatically flattened to dot-notation keys +final public class StructPropertyLoader: PropertyLoader { + + /// The instance to reflect properties from + private let instance: T + + /// + /// Will create a struct property loader from any Swift struct or class instance + /// + /// - parameter instance: the struct or class instance to extract properties from + /// + public init(_ instance: T) { + self.instance = instance + } + + /// + /// Loads properties from the struct/class instance using Mirror reflection + /// + /// - returns: the key-value pair properties extracted from the instance + /// + public func load() throws -> [String: Any] { + return reflectProperties(instance, prefix: "") + } + + /// + /// Recursively reflects on a struct/class instance and extracts properties + /// Nested types are flattened to dot notation (e.g., "api.baseURL") + /// + /// - parameter instance: the instance to reflect on + /// - parameter prefix: the current key prefix for nested properties + /// + /// - returns: flattened dictionary of properties + /// + private func reflectProperties(_ instance: Any, prefix: String) -> [String: Any] { + let mirror = Mirror(reflecting: instance) + var properties: [String: Any] = [:] + + for child in mirror.children { + guard let label = child.label else { continue } + + let key = prefix.isEmpty ? label : "\(prefix).\(label)" + let value = child.value + + // Unwrap optionals + let unwrappedValue = unwrapOptional(value) + + // Skip nil optionals + guard let nonNilValue = unwrappedValue else { continue } + + // Check if this is a struct/class that should be flattened + if shouldFlatten(nonNilValue) { + // Recursively flatten nested struct/class + let nestedProperties = reflectProperties(nonNilValue, prefix: key) + properties.merge(nestedProperties) { _, new in new } + } else { + // Store the value directly + properties[key] = nonNilValue + } + } + + return properties + } + + /// + /// Unwraps an optional value to get the underlying value or nil + /// + /// - parameter value: the value to unwrap + /// + /// - returns: the unwrapped value or nil if the optional is nil + /// + private func unwrapOptional(_ value: Any) -> Any? { + let mirror = Mirror(reflecting: value) + + // Check if this is an Optional type + if mirror.displayStyle == .optional { + // Optional has one child if it has a value, zero if nil + if let firstChild = mirror.children.first { + return firstChild.value + } else { + return nil // Optional is nil + } + } + + return value // Not an optional + } + + /// + /// Determines if a value should be flattened (i.e., it's a custom struct/class) + /// + /// - parameter value: the value to check + /// + /// - returns: true if the value should be recursively flattened + /// + private func shouldFlatten(_ value: Any) -> Bool { + let mirror = Mirror(reflecting: value) + + // Flatten if it's a struct or class with properties + if mirror.displayStyle == .struct || mirror.displayStyle == .class { + // Don't flatten Foundation types or standard library types + let typeName = String(describing: type(of: value)) + + // Exclude common Foundation and stdlib types that we want as-is + // Check if the type name CONTAINS (not just starts with) these keywords + let excludedTypes = ["String", "Int", "Double", "Float", "Bool", "Date", + "URL", "UUID", "Data", "Array", "Dictionary", "Set", + "Optional", "__"] + + for excludedType in excludedTypes { + // Check both prefix and if type name is exactly the excluded type + if typeName == excludedType || typeName.hasPrefix(excludedType + "<") || + typeName.hasPrefix("Swift." + excludedType) || + typeName.hasPrefix("Foundation." + excludedType) { + return false + } + } + + // If it has children (properties), flatten it + return mirror.children.count > 0 + } + + return false + } +} diff --git a/Sources/TomlPropertyLoader.swift b/Sources/TomlPropertyLoader.swift new file mode 100644 index 0000000..1022332 --- /dev/null +++ b/Sources/TomlPropertyLoader.swift @@ -0,0 +1,101 @@ +// +// TomlPropertyLoader.swift +// Swinject +// +// Created for SwinjectPropertyLoader +// Copyright © 2025 Swinject Contributors. All rights reserved. +// + +import Foundation +import TOMLKit + + +/// The TomlPropertyLoader will load properties from TOML resources +/// Nested TOML tables are automatically flattened to dot-notation keys for property access +final public class TomlPropertyLoader: Sendable { + + /// the bundle where the resource exists (defaults to mainBundle) + fileprivate let bundle: Bundle? + + /// the name of the TOML resource. For example, if your resource is "properties.toml" then this value will be set to "properties" + fileprivate let name: String? + + /// the URL where the resource exists (used instead of bundle if provided) + fileprivate let url: URL? + + /// + /// Will create a TOML property loader from a bundle resource + /// + /// - parameter bundle: the bundle where the resource exists (defaults to mainBundle) + /// - parameter name: the name of the TOML resource. For example, if your resource is "properties.toml" + /// then this value will be set to "properties" + /// + public init(bundle: Bundle = .main, name: String) { + self.bundle = bundle + self.name = name + self.url = nil + } + + /// + /// Will create a TOML property loader from a URL + /// + /// - parameter url: the URL where the TOML resource exists + /// + public init(url: URL) { + self.bundle = nil + self.name = nil + self.url = url + } +} + +// MARK: - PropertyLoader +extension TomlPropertyLoader: PropertyLoader { + public func load() throws -> [String: Any] { + let contents: String + + if let url = url { + // Load from URL + contents = try loadStringFromURL(url) + } else if let bundle = bundle, let name = name { + // Load from bundle + contents = try loadStringFromBundle(bundle, withName: name, ofType: "toml") + } else { + fatalError("TomlPropertyLoader must be initialized with either a URL or bundle+name") + } + + // Parse TOML + let table: TOMLTable + do { + table = try TOMLTable(string: contents) + } catch { + // Handle TOML parsing error + if let url = url { + throw PropertyLoaderError.invalidTOMLFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidTOMLFormat(bundle: bundle!, name: name!) + } + } + + // Convert TOML to JSON, then to [String: Any] + let jsonString = table.convert(to: .json) + guard let jsonData = jsonString.data(using: .utf8) else { + if let url = url { + throw PropertyLoaderError.invalidTOMLFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidTOMLFormat(bundle: bundle!, name: name!) + } + } + + let json = try JSONSerialization.jsonObject(with: jsonData, options: []) + guard let nestedDict = json as? [String: Any] else { + if let url = url { + throw PropertyLoaderError.invalidTOMLFormatURL(url: url) + } else { + throw PropertyLoaderError.invalidTOMLFormat(bundle: bundle!, name: name!) + } + } + + // Flatten nested dictionary to dot-notation keys + return flattenDictionary(nestedDict) + } +} diff --git a/SwinjectPropertyLoader.podspec b/SwinjectPropertyLoader.podspec index eedec11..be9a6af 100644 --- a/SwinjectPropertyLoader.podspec +++ b/SwinjectPropertyLoader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SwinjectPropertyLoader" - s.version = "1.1.0" + s.version = "2.0.0" s.summary = "Swinject extension to load property values from resources" s.description = <<-DESC SwinjectPropertyLoader is an extension of Swinject to load property values from resources that are bundled with your application/framework. @@ -11,10 +11,10 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/Swinject/SwinjectPropertyLoader.git", :tag => s.version.to_s } s.source_files = 'Sources/**/*.{swift,h}' - s.ios.deployment_target = '8.0' - s.osx.deployment_target = '10.10' - s.watchos.deployment_target = '2.0' - s.tvos.deployment_target = '9.0' - s.dependency 'Swinject', '~> 2.0.0' + s.ios.deployment_target = '15.0' + s.osx.deployment_target = '12.0' + s.watchos.deployment_target = '8.0' + s.tvos.deployment_target = '15.0' + s.dependency 'Swinject', '~> 2.9.1' s.requires_arc = true end diff --git a/SwinjectPropertyLoader.xcodeproj/project.pbxproj b/SwinjectPropertyLoader.xcodeproj/project.pbxproj index 3ff40d3..cf0296e 100644 --- a/SwinjectPropertyLoader.xcodeproj/project.pbxproj +++ b/SwinjectPropertyLoader.xcodeproj/project.pbxproj @@ -989,7 +989,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 4; }; name = Debug; @@ -1005,7 +1005,7 @@ INFOPLIST_FILE = Sources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 4; }; name = Release; @@ -1017,15 +1017,15 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_TESTABILITY = YES; GCC_NO_COMMON_BLOCKS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MACOSX_DEPLOYMENT_TARGET = 10.10; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 2.0; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Debug; }; @@ -1035,15 +1035,15 @@ buildSettings = { CURRENT_PROJECT_VERSION = 1; GCC_NO_COMMON_BLOCKS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MACOSX_DEPLOYMENT_TARGET = 10.10; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 2.0; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Release; }; @@ -1059,7 +1059,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1074,7 +1074,7 @@ INFOPLIST_FILE = Sources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1085,7 +1085,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1096,7 +1096,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1112,7 +1112,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1127,7 +1127,7 @@ INFOPLIST_FILE = Sources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1138,7 +1138,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1149,7 +1149,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1165,7 +1165,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 3; }; name = Debug; @@ -1181,7 +1181,7 @@ INFOPLIST_FILE = Sources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoader"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 3; }; name = Release; @@ -1193,7 +1193,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1204,7 +1204,7 @@ INFOPLIST_FILE = Tests/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = "com.el-eleven.SwinjectPropertyLoaderTests"; PRODUCT_NAME = "$(PROJECT_NAME)Tests"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; diff --git a/Tests/Assembler+PropertiesTests.swift b/Tests/Assembler+PropertiesTests.swift index 30a9a78..020c2f1 100644 --- a/Tests/Assembler+PropertiesTests.swift +++ b/Tests/Assembler+PropertiesTests.swift @@ -15,30 +15,30 @@ class Assembler_PropertiesTests: XCTestCase { let assembler = try! Assembler(assemblies: [ PropertyAsssembly() ], propertyLoaders: [ - PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") + PlistPropertyLoader(bundle: .test, name: "first") ]) - + let cat = assembler.resolver.resolve(Animal.self) XCTAssertNotNil(cat) XCTAssertEqual(cat!.name, "first") } - + func testAssemblerWithPropertiesCanNotAssembleWithMissingProperties() { XCTAssertThrowsError(try Assembler(assemblies: [PropertyAsssembly()], propertyLoaders: [ - PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "noexist") + PlistPropertyLoader(bundle: .test, name: "noexist") ])) { error in XCTAssert(error is PropertyLoaderError) } } - + func testEmptyAssemblerCanCreateEmptyAsemblerAndBuildIt() { let assembler = Assembler() - - let loader = PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") + + let loader = PlistPropertyLoader(bundle: .test, name: "first") try! assembler.applyPropertyLoader(loader) - + assembler.apply(assembly: PropertyAsssembly()) - + let cat = assembler.resolver.resolve(Animal.self) XCTAssertNotNil(cat) XCTAssertEqual(cat!.name, "first") diff --git a/Tests/JsonPropertyLoaderTests.swift b/Tests/JsonPropertyLoaderTests.swift index 68e5ea4..d59bfd6 100644 --- a/Tests/JsonPropertyLoaderTests.swift +++ b/Tests/JsonPropertyLoaderTests.swift @@ -13,14 +13,14 @@ import SwinjectPropertyLoader class JsonPropertyLoaderTests: XCTestCase { func testMissingResourrcesCanBeHandled() { - let loader = JsonPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "noexist") + let loader = JsonPropertyLoader(bundle: .test, name: "noexist") XCTAssertThrowsError(try loader.load()) { error in XCTAssert(error is PropertyLoaderError) } } - + func testInvalidResourcesCanBeHandled() { - let loader = JsonPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "invalid") + let loader = JsonPropertyLoader(bundle: .test, name: "invalid") XCTAssertThrowsError(try loader.load()) { error in XCTAssert(error is PropertyLoaderError) } diff --git a/Tests/PlistPropertyLoaderTests.swift b/Tests/PlistPropertyLoaderTests.swift index 5d96683..7c70ad9 100644 --- a/Tests/PlistPropertyLoaderTests.swift +++ b/Tests/PlistPropertyLoaderTests.swift @@ -13,7 +13,7 @@ import SwinjectPropertyLoader class PlistPropertyLoaderTests: XCTestCase { func testMissingResourcesAreHandled() { - let loader = PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "noexist") + let loader = PlistPropertyLoader(bundle: .test, name: "noexist") XCTAssertThrowsError(try loader.load()) { error in XCTAssert(error is PropertyLoaderError) } diff --git a/Tests/PropertyKeyTests.swift b/Tests/PropertyKeyTests.swift new file mode 100644 index 0000000..dc3a004 --- /dev/null +++ b/Tests/PropertyKeyTests.swift @@ -0,0 +1,183 @@ +// +// PropertyKeyTests.swift +// SwinjectPropertyLoader +// +// Tests for type-safe PropertyKey system +// + +import XCTest +import Swinject +import SwinjectPropertyLoader + +// MARK: - Test Property Keys + +extension PropertyKey { + static let testString = PropertyKey("test.string") + static let testInt = PropertyKey("test.int") + static let testDouble = PropertyKey("test.double") + static let testBool = PropertyKey("test.bool") + static let testArray = PropertyKey("test.array") + static let testMissing = PropertyKey("test.missing") + + // API keys for testing + static let apiBaseURL = PropertyKey("api.baseURL") + static let apiTimeout = PropertyKey("api.timeout") + static let apiKey = PropertyKey("api.key") + + // Simulated Module 1: API keys + static let module1Key1 = PropertyKey("module1.key1") + static let module1Key2 = PropertyKey("module1.key2") + + // Simulated Module 2: Feature flags + static let module2FeatureA = PropertyKey("module2.featureA") + static let module2FeatureB = PropertyKey("module2.featureB") +} + +class PropertyKeyTests: XCTestCase { + + // MARK: - PropertyKey Basics + + func testPropertyKeyCreation() { + let key1 = PropertyKey("my.key") + let key2 = PropertyKey(rawValue: "my.key") + + XCTAssertEqual(key1.rawValue, "my.key") + XCTAssertEqual(key2.rawValue, "my.key") + } + + func testPropertyKeyStringLiteral() { + let key: PropertyKey = "string.literal" + XCTAssertEqual(key.rawValue, "string.literal") + } + + func testPropertyKeyEquality() { + let key1 = PropertyKey("test.key") + let key2 = PropertyKey("test.key") + let key3: PropertyKey = "test.key" + let key4 = PropertyKey("different.key") + + XCTAssertEqual(key1, key2) + XCTAssertEqual(key1, key3) + XCTAssertNotEqual(key1, key4) + } + + func testPropertyKeyHashable() { + let key1 = PropertyKey("test.key") + let key2 = PropertyKey("test.key") + let key3 = PropertyKey("other.key") + + var set = Set() + set.insert(key1) + set.insert(key2) // Should not add duplicate + set.insert(key3) + + XCTAssertEqual(set.count, 2) + XCTAssertTrue(set.contains(key1)) + XCTAssertTrue(set.contains(key3)) + } + + func testPropertyKeyDescription() { + let key = PropertyKey("my.key") + XCTAssertEqual(key.description, "my.key") + XCTAssertEqual(key.debugDescription, "PropertyKey(\"my.key\")") + } + + // MARK: - Type-Safe Property Access + + func testPropertyKeyAccess() throws { + let container = Container() + let loader = JsonPropertyLoader(bundle: .test, name: "first") + try container.applyPropertyLoader(loader) + + // Type-safe access with PropertyKey + let stringValue: String? = container.property(forKey: .testString) + let intValue: Int? = container.property(forKey: .testInt) + let doubleValue: Double? = container.property(forKey: .testDouble) + let boolValue: Bool? = container.property(forKey: .testBool) + + XCTAssertEqual(stringValue, "first") + XCTAssertEqual(intValue, 100) + XCTAssertEqual(doubleValue, 30.50) + XCTAssertEqual(boolValue, true) + } + + func testPropertyKeyAccessWithArrays() throws { + let container = Container() + let loader = JsonPropertyLoader(bundle: .test, name: "first") + try container.applyPropertyLoader(loader) + + let arrayValue: [String]? = container.property(forKey: .testArray) + XCTAssertEqual(arrayValue, ["item1", "item2"]) + } + + // MARK: - Backward Compatibility + + func testBackwardCompatibilityWithStringAPI() throws { + let container = Container() + let loader = JsonPropertyLoader(bundle: .test, name: "first") + try container.applyPropertyLoader(loader) + + // Old string-based API still works + let stringValue: String? = container.property("test.string") + XCTAssertEqual(stringValue, "first") + + let intValue: Int? = container.property("test.int") + XCTAssertEqual(intValue, 100) + } + + func testPropertyKeyAndStringProduceSameResult() throws { + let container = Container() + let loader = JsonPropertyLoader(bundle: .test, name: "first") + try container.applyPropertyLoader(loader) + + // Both approaches should return the same value + let viaKey: String? = container.property(forKey: .testString) + let viaString: String? = container.property("test.string") + + XCTAssertEqual(viaKey, viaString) + XCTAssertEqual(viaKey, "first") + } + + // MARK: - Real-World Usage Patterns + + func testAPIConfigurationPattern() throws { + // Simulate loading API configuration + struct Config { + struct API { + let baseURL = "https://api.example.com" + let timeout = 30 + let key = "secret123" + } + let api = API() + } + + let container = Container() + let config = Config() + let loader = StructPropertyLoader(config) + try container.applyPropertyLoader(loader) + + // Use PropertyKey for type-safe access + let baseURL: String? = container.property(forKey: .apiBaseURL) + let timeout: Int? = container.property(forKey: .apiTimeout) + + XCTAssertEqual(baseURL, "https://api.example.com") + XCTAssertEqual(timeout, 30) + } + + func testMultipleModuleKeyDefinitions() { + // Keys from different modules are defined at file level (see below this class) + // This test verifies they are all accessible + XCTAssertEqual(PropertyKey.module1Key1.rawValue, "module1.key1") + XCTAssertEqual(PropertyKey.module2FeatureA.rawValue, "module2.featureA") + } + + func testPropertyKeyInDictionary() { + var dict = [PropertyKey: Any]() + dict[.testString] = "value1" + dict[.testInt] = 42 + + XCTAssertEqual(dict[.testString] as? String, "value1") + XCTAssertEqual(dict[.testInt] as? Int, 42) + XCTAssertEqual(dict.count, 2) + } +} diff --git a/Tests/Resolver+PropertiesTests.swift b/Tests/Resolver+PropertiesTests.swift index 136f56a..97c333f 100644 --- a/Tests/Resolver+PropertiesTests.swift +++ b/Tests/Resolver+PropertiesTests.swift @@ -20,7 +20,7 @@ class Resolver_PropertiesTests: XCTestCase { // MARK: JSON properties" func testJsonPropertiesCanLoadFromSingleLoader() { - let loader = JsonPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") + let loader = JsonPropertyLoader(bundle: .test, name: "first") try! container.applyPropertyLoader(loader) container.register(Properties.self) { r in @@ -91,8 +91,8 @@ class Resolver_PropertiesTests: XCTestCase { } func testJsonPropertiesCanLoadFromMultipleLoaders() { - let loader = JsonPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") - let loader2 = JsonPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "second") + let loader = JsonPropertyLoader(bundle: .test, name: "first") + let loader2 = JsonPropertyLoader(bundle: .test, name: "second") try! container.applyPropertyLoader(loader) try! container.applyPropertyLoader(loader2) @@ -112,7 +112,7 @@ class Resolver_PropertiesTests: XCTestCase { // MARK: Plist properties func testPlistPropertiesCanLoadFromSingleLoader() { - let loader = PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") + let loader = PlistPropertyLoader(bundle: .test, name: "first") try! container.applyPropertyLoader(loader) container.register(Properties.self) { r in @@ -183,8 +183,8 @@ class Resolver_PropertiesTests: XCTestCase { } func testPlistPropertiesCanLoadFromMultipleLoaders() { - let loader = PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "first") - let loader2 = PlistPropertyLoader(bundle: Bundle(for: type(of: self).self), name: "second") + let loader = PlistPropertyLoader(bundle: .test, name: "first") + let loader2 = PlistPropertyLoader(bundle: .test, name: "second") try! container.applyPropertyLoader(loader) try! container.applyPropertyLoader(loader2) diff --git a/Tests/Resources/first.toml b/Tests/Resources/first.toml new file mode 100644 index 0000000..e7d0960 --- /dev/null +++ b/Tests/Resources/first.toml @@ -0,0 +1,25 @@ +# First TOML configuration file +# This file demonstrates nested tables and dot notation + +[test] +string = "first" +int = 100 +double = 30.50 +bool = true +array = ["item1", "item2"] + +[test.dict] +key1 = "item1" +key2 = "item2" + +[api] +base_url = "https://api.example.com" +timeout = 30 + +[packages.unlimited] +cost = 99.99 +features = ["feature1", "feature2", "feature3"] + +[packages.basic] +cost = 9.99 +features = ["feature1"] diff --git a/Tests/Resources/invalid.json b/Tests/Resources/invalid.json index d8e308a..4ea649f 100644 --- a/Tests/Resources/invalid.json +++ b/Tests/Resources/invalid.json @@ -1,14 +1 @@ - - - - - - - - +["not", "a", "dictionary"] diff --git a/Tests/Resources/invalid.toml b/Tests/Resources/invalid.toml new file mode 100644 index 0000000..103e75f --- /dev/null +++ b/Tests/Resources/invalid.toml @@ -0,0 +1,5 @@ +# Invalid TOML - malformed syntax +# This should cause a TOML parsing error + +[section +invalid = "missing closing bracket" diff --git a/Tests/Resources/second.toml b/Tests/Resources/second.toml new file mode 100644 index 0000000..b1e9609 --- /dev/null +++ b/Tests/Resources/second.toml @@ -0,0 +1,14 @@ +# Second TOML configuration file +# Used for testing property merging + +[test] +string = "second" +new_value = "added" + +[api] +base_url = "https://override.example.com" +api_key = "secret123" + +[database] +host = "localhost" +port = 5432 diff --git a/Tests/StructPropertyLoaderTests.swift b/Tests/StructPropertyLoaderTests.swift new file mode 100644 index 0000000..f76345b --- /dev/null +++ b/Tests/StructPropertyLoaderTests.swift @@ -0,0 +1,225 @@ +// +// StructPropertyLoaderTests.swift +// SwinjectPropertyLoader +// +// Tests for reflection-based struct property loading +// + +import XCTest +import Swinject +import SwinjectPropertyLoader + +// MARK: - Test Fixtures + +struct BasicConfig { + let apiKey: String = "secret123" + let timeout: Int = 30 + let debugMode: Bool = true + let maxRetries: Double = 3.5 + let features: [String] = ["feature1", "feature2"] +} + +struct NestedConfig { + struct API { + let baseURL: String = "https://api.example.com" + let timeout: Int = 30 + let apiKey: String = "secret123" + } + + struct Database { + let host: String = "localhost" + let port: Int = 5432 + let name: String = "myapp" + } + + let api = API() + let database = Database() + let appName: String = "MyApp" +} + +struct DeeplyNestedConfig { + struct Packages { + struct Unlimited { + let cost: Double = 99.99 + let features: [String] = ["feature1", "feature2", "feature3"] + } + + struct Basic { + let cost: Double = 9.99 + let features: [String] = ["feature1"] + } + + let unlimited = Unlimited() + let basic = Basic() + } + + let packages = Packages() +} + +struct OptionalConfig { + let requiredValue: String = "required" + let optionalValue: String? = "optional" + let nilValue: String? = nil + let optionalInt: Int? = 42 +} + +final class ConfigClass { + let setting1: String = "value1" + let setting2: Int = 100 +} + +// MARK: - Tests + +class StructPropertyLoaderTests: XCTestCase { + + // MARK: - Basic Reflection Tests + + func testStructPropertyLoaderCanLoadBasicProperties() throws { + let config = BasicConfig() + let loader = StructPropertyLoader(config) + let properties = try loader.load() + + XCTAssertEqual(properties["apiKey"] as? String, "secret123") + XCTAssertEqual(properties["timeout"] as? Int, 30) + XCTAssertEqual(properties["debugMode"] as? Bool, true) + XCTAssertEqual(properties["maxRetries"] as? Double, 3.5) + XCTAssertEqual(properties["features"] as? [String], ["feature1", "feature2"]) + } + + func testStructPropertyLoaderFlattensNestedStructs() throws { + let config = NestedConfig() + let loader = StructPropertyLoader(config) + let properties = try loader.load() + + // Test top-level property + XCTAssertEqual(properties["appName"] as? String, "MyApp") + + // Test nested API properties with dot notation + XCTAssertEqual(properties["api.baseURL"] as? String, "https://api.example.com") + XCTAssertEqual(properties["api.timeout"] as? Int, 30) + XCTAssertEqual(properties["api.apiKey"] as? String, "secret123") + + // Test nested Database properties with dot notation + XCTAssertEqual(properties["database.host"] as? String, "localhost") + XCTAssertEqual(properties["database.port"] as? Int, 5432) + XCTAssertEqual(properties["database.name"] as? String, "myapp") + + // Ensure no un-flattened nested structs exist + XCTAssertNil(properties["api"] as? NestedConfig.API) + XCTAssertNil(properties["database"] as? NestedConfig.Database) + } + + func testStructPropertyLoaderFlattensDeeplyNestedStructs() throws { + let config = DeeplyNestedConfig() + let loader = StructPropertyLoader(config) + let properties = try loader.load() + + // Test deeply nested properties (3 levels) + XCTAssertEqual(properties["packages.unlimited.cost"] as? Double, 99.99) + XCTAssertEqual(properties["packages.unlimited.features"] as? [String], + ["feature1", "feature2", "feature3"]) + + XCTAssertEqual(properties["packages.basic.cost"] as? Double, 9.99) + XCTAssertEqual(properties["packages.basic.features"] as? [String], ["feature1"]) + } + + func testStructPropertyLoaderHandlesOptionals() throws { + let config = OptionalConfig() + let loader = StructPropertyLoader(config) + let properties = try loader.load() + + // Required value should be present + XCTAssertEqual(properties["requiredValue"] as? String, "required") + + // Non-nil optional should be unwrapped and present + XCTAssertEqual(properties["optionalValue"] as? String, "optional") + XCTAssertEqual(properties["optionalInt"] as? Int, 42) + + // Nil optional should NOT be in the dictionary + XCTAssertNil(properties["nilValue"]) + XCTAssertFalse(properties.keys.contains("nilValue")) + } + + func testStructPropertyLoaderWorksWithClasses() throws { + let config = ConfigClass() + let loader = StructPropertyLoader(config) + let properties = try loader.load() + + XCTAssertEqual(properties["setting1"] as? String, "value1") + XCTAssertEqual(properties["setting2"] as? Int, 100) + } + + // MARK: - Container Integration Tests + + func testCanUseStructLoaderWithContainer() throws { + let config = BasicConfig() + let container = Container() + let loader = StructPropertyLoader(config) + try container.applyPropertyLoader(loader) + + let apiKey: String? = container.property("apiKey") + XCTAssertEqual(apiKey, "secret123") + + let timeout: Int? = container.property("timeout") + XCTAssertEqual(timeout, 30) + + let debugMode: Bool? = container.property("debugMode") + XCTAssertEqual(debugMode, true) + } + + func testCanUseNestedStructLoaderWithContainer() throws { + let config = NestedConfig() + let container = Container() + let loader = StructPropertyLoader(config) + try container.applyPropertyLoader(loader) + + // Test dot notation access + let baseURL: String? = container.property("api.baseURL") + XCTAssertEqual(baseURL, "https://api.example.com") + + let dbHost: String? = container.property("database.host") + XCTAssertEqual(dbHost, "localhost") + + let dbPort: Int? = container.property("database.port") + XCTAssertEqual(dbPort, 5432) + } + + func testStructPropertiesMergeWithOtherLoaders() throws { + let bundle = Bundle.test + let container = Container() + + // First load from struct + let structConfig = BasicConfig() + let structLoader = StructPropertyLoader(structConfig) + try container.applyPropertyLoader(structLoader) + + // Then load from JSON (should override) + let jsonLoader = JsonPropertyLoader(bundle: bundle, name: "first") + try container.applyPropertyLoader(jsonLoader) + + // JSON values should override struct values + let testString: String? = container.property("test.string") + XCTAssertEqual(testString, "first") // From JSON + + // Struct-only values should still exist + let apiKey: String? = container.property("apiKey") + XCTAssertEqual(apiKey, "secret123") // From struct + } + + func testDifferentStructInstancesCanHaveDifferentValues() throws { + struct ConfigWithValue { + let value: String + } + + let config1 = ConfigWithValue(value: "first") + let loader1 = StructPropertyLoader(config1) + let properties1 = try loader1.load() + + let config2 = ConfigWithValue(value: "second") + let loader2 = StructPropertyLoader(config2) + let properties2 = try loader2.load() + + XCTAssertEqual(properties1["value"] as? String, "first") + XCTAssertEqual(properties2["value"] as? String, "second") + } +} diff --git a/Tests/TestBundle.swift b/Tests/TestBundle.swift new file mode 100644 index 0000000..10e4b90 --- /dev/null +++ b/Tests/TestBundle.swift @@ -0,0 +1,20 @@ +// +// TestBundle.swift +// SwinjectPropertyLoader +// +// Created for SPM and Xcode compatibility +// + +import Foundation + +extension Bundle { + static var test: Bundle { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: TestBundleMarker.self) + #endif + } +} + +private class TestBundleMarker {} diff --git a/Tests/TomlPropertyLoaderTests.swift b/Tests/TomlPropertyLoaderTests.swift new file mode 100644 index 0000000..d04c200 --- /dev/null +++ b/Tests/TomlPropertyLoaderTests.swift @@ -0,0 +1,191 @@ +// +// TomlPropertyLoaderTests.swift +// SwinjectPropertyLoader +// +// Tests for TOML property loading with dot notation support +// + +import XCTest +import Swinject +import SwinjectPropertyLoader + +class TomlPropertyLoaderTests: XCTestCase { + + // MARK: - Bundle-based TOML Tests + + func testTomlPropertyLoaderCanLoadFromBundle() throws { + let bundle = Bundle.test + let loader = TomlPropertyLoader(bundle: bundle, name: "first") + let properties = try loader.load() + + // Test basic values with dot notation + XCTAssertEqual(properties["test.string"] as? String, "first") + XCTAssertEqual(properties["test.int"] as? Int, 100) + XCTAssertEqual(properties["test.double"] as? Double, 30.50) + XCTAssertEqual(properties["test.bool"] as? Bool, true) + + // Test array + let array = properties["test.array"] as? [String] + XCTAssertEqual(array, ["item1", "item2"]) + + // Test nested dict flattening + XCTAssertEqual(properties["test.dict.key1"] as? String, "item1") + XCTAssertEqual(properties["test.dict.key2"] as? String, "item2") + } + + func testTomlPropertyLoaderFlattensDeeplyNestedTables() throws { + let bundle = Bundle.test + let loader = TomlPropertyLoader(bundle: bundle, name: "first") + let properties = try loader.load() + + // Test API section + XCTAssertEqual(properties["api.base_url"] as? String, "https://api.example.com") + XCTAssertEqual(properties["api.timeout"] as? Int, 30) + + // Test deeply nested packages + XCTAssertEqual(properties["packages.unlimited.cost"] as? Double, 99.99) + let unlimitedFeatures = properties["packages.unlimited.features"] as? [String] + XCTAssertEqual(unlimitedFeatures, ["feature1", "feature2", "feature3"]) + + XCTAssertEqual(properties["packages.basic.cost"] as? Double, 9.99) + let basicFeatures = properties["packages.basic.features"] as? [String] + XCTAssertEqual(basicFeatures, ["feature1"]) + } + + func testTomlPropertyLoaderThrowsErrorForMissingResource() { + let bundle = Bundle.test + let loader = TomlPropertyLoader(bundle: bundle, name: "nonexistent") + + XCTAssertThrowsError(try loader.load()) { error in + guard case PropertyLoaderError.missingResource(let errorBundle, let name) = error else { + XCTFail("Expected missingResource error, got \(error)") + return + } + XCTAssertEqual(errorBundle, bundle) + XCTAssertEqual(name, "nonexistent") + } + } + + func testTomlPropertyLoaderThrowsErrorForInvalidFormat() { + let bundle = Bundle.test + let loader = TomlPropertyLoader(bundle: bundle, name: "invalid") + + XCTAssertThrowsError(try loader.load()) { error in + // TOML with top-level array should cause invalid format error + XCTAssert(error is PropertyLoaderError) + if case PropertyLoaderError.invalidTOMLFormat(let errorBundle, let name) = error { + XCTAssertEqual(errorBundle, bundle) + XCTAssertEqual(name, "invalid") + } else { + XCTFail("Expected invalidTOMLFormat error, got \(error)") + } + } + } + + // MARK: - URL-based TOML Tests + + func testTomlPropertyLoaderCanLoadFromURL() throws { + let bundle = Bundle.test + guard let url = bundle.url(forResource: "first", withExtension: "toml") else { + XCTFail("Could not find first.toml in test bundle") + return + } + + let loader = TomlPropertyLoader(url: url) + let properties = try loader.load() + + // Verify dot notation access works + XCTAssertEqual(properties["test.string"] as? String, "first") + XCTAssertEqual(properties["api.base_url"] as? String, "https://api.example.com") + XCTAssertEqual(properties["packages.unlimited.cost"] as? Double, 99.99) + } + + func testTomlPropertyLoaderThrowsErrorForMissingURL() { + let tempDir = FileManager.default.temporaryDirectory + let missingURL = tempDir.appendingPathComponent("nonexistent.toml") + + let loader = TomlPropertyLoader(url: missingURL) + XCTAssertThrowsError(try loader.load()) { error in + guard case PropertyLoaderError.missingResourceURL(let url) = error else { + XCTFail("Expected missingResourceURL error, got \(error)") + return + } + XCTAssertEqual(url, missingURL) + } + } + + func testTomlPropertyLoaderThrowsErrorForInvalidFormatURL() { + let bundle = Bundle.test + guard let url = bundle.url(forResource: "invalid", withExtension: "toml") else { + XCTFail("Could not find invalid.toml in test bundle") + return + } + + let loader = TomlPropertyLoader(url: url) + XCTAssertThrowsError(try loader.load()) { error in + XCTAssert(error is PropertyLoaderError) + if case PropertyLoaderError.invalidTOMLFormatURL(let errorURL) = error { + XCTAssertEqual(errorURL, url) + } else { + XCTFail("Expected invalidTOMLFormatURL error, got \(error)") + } + } + } + + // MARK: - Container Integration Tests + + func testCanUseTomlLoaderWithContainer() throws { + let bundle = Bundle.test + let container = Container() + let loader = TomlPropertyLoader(bundle: bundle, name: "first") + try container.applyPropertyLoader(loader) + + // Test dot notation property access + let baseUrl: String? = container.property("api.base_url") + XCTAssertEqual(baseUrl, "https://api.example.com") + + let timeout: Int? = container.property("api.timeout") + XCTAssertEqual(timeout, 30) + + let cost: Double? = container.property("packages.unlimited.cost") + XCTAssertEqual(cost, 99.99) + } + + func testTomlPropertiesMergeCorrectly() throws { + let bundle = Bundle.test + let container = Container() + + // Load first properties + let firstLoader = TomlPropertyLoader(bundle: bundle, name: "first") + try container.applyPropertyLoader(firstLoader) + + // Load second properties (should override) + let secondLoader = TomlPropertyLoader(bundle: bundle, name: "second") + try container.applyPropertyLoader(secondLoader) + + // Test overridden values + let testString: String? = container.property("test.string") + XCTAssertEqual(testString, "second", "Second loader should override first") + + let apiUrl: String? = container.property("api.base_url") + XCTAssertEqual(apiUrl, "https://override.example.com", "Second loader should override first") + + // Test values only in second + let apiKey: String? = container.property("api.api_key") + XCTAssertEqual(apiKey, "secret123") + + let newValue: String? = container.property("test.new_value") + XCTAssertEqual(newValue, "added") + + // Test values only in first (should still exist) + let timeout: Int? = container.property("api.timeout") + XCTAssertEqual(timeout, 30) + + // Test database values from second + let dbHost: String? = container.property("database.host") + XCTAssertEqual(dbHost, "localhost") + + let dbPort: Int? = container.property("database.port") + XCTAssertEqual(dbPort, 5432) + } +} diff --git a/Tests/URLPropertyLoaderTests.swift b/Tests/URLPropertyLoaderTests.swift new file mode 100644 index 0000000..1e31607 --- /dev/null +++ b/Tests/URLPropertyLoaderTests.swift @@ -0,0 +1,110 @@ +// +// URLPropertyLoaderTests.swift +// SwinjectPropertyLoader +// +// Tests for URL-based property loading +// + +import XCTest +import Swinject +import SwinjectPropertyLoader + +class URLPropertyLoaderTests: XCTestCase { + + // MARK: - JSON URL Tests + + func testJsonPropertyLoaderCanLoadFromURL() throws { + // Get the test resource URL + let bundle = Bundle.test + guard let url = bundle.url(forResource: "first", withExtension: "json") else { + XCTFail("Could not find first.json in test bundle") + return + } + + let loader = JsonPropertyLoader(url: url) + let properties = try loader.load() + + XCTAssertEqual(properties["test.string"] as? String, "first") + XCTAssertEqual(properties["test.int"] as? Int, 100) + XCTAssertEqual(properties["test.double"] as? Double, 30.50) + } + + func testJsonPropertyLoaderThrowsErrorForMissingURL() { + let tempDir = FileManager.default.temporaryDirectory + let missingURL = tempDir.appendingPathComponent("nonexistent.json") + + let loader = JsonPropertyLoader(url: missingURL) + XCTAssertThrowsError(try loader.load()) { error in + guard case PropertyLoaderError.missingResourceURL(let url) = error else { + XCTFail("Expected missingResourceURL error, got \(error)") + return + } + XCTAssertEqual(url, missingURL) + } + } + + func testJsonPropertyLoaderThrowsErrorForInvalidFormatURL() { + let bundle = Bundle.test + guard let url = bundle.url(forResource: "invalid", withExtension: "json") else { + XCTFail("Could not find invalid.json in test bundle") + return + } + + let loader = JsonPropertyLoader(url: url) + XCTAssertThrowsError(try loader.load()) { error in + XCTAssert(error is PropertyLoaderError) + if case PropertyLoaderError.invalidJSONFormatURL(let errorURL) = error { + XCTAssertEqual(errorURL, url) + } else { + XCTFail("Expected invalidJSONFormatURL error") + } + } + } + + // MARK: - Plist URL Tests + + func testPlistPropertyLoaderCanLoadFromURL() throws { + let bundle = Bundle.test + guard let url = bundle.url(forResource: "first", withExtension: "plist") else { + XCTFail("Could not find first.plist in test bundle") + return + } + + let loader = PlistPropertyLoader(url: url) + let properties = try loader.load() + + XCTAssertEqual(properties["test.string"] as? String, "first") + XCTAssertEqual(properties["test.int"] as? Int, 100) + } + + func testPlistPropertyLoaderThrowsErrorForMissingURL() { + let tempDir = FileManager.default.temporaryDirectory + let missingURL = tempDir.appendingPathComponent("nonexistent.plist") + + let loader = PlistPropertyLoader(url: missingURL) + XCTAssertThrowsError(try loader.load()) { error in + guard case PropertyLoaderError.missingResourceURL(let url) = error else { + XCTFail("Expected missingResourceURL error, got \(error)") + return + } + XCTAssertEqual(url, missingURL) + } + } + + // MARK: - Integration Tests + + func testCanUseURLBasedLoaderWithContainer() throws { + let bundle = Bundle.test + guard let url = bundle.url(forResource: "first", withExtension: "json") else { + XCTFail("Could not find first.json in test bundle") + return + } + + let container = Container() + let loader = JsonPropertyLoader(url: url) + try container.applyPropertyLoader(loader) + + let value: String? = container.property("test.string") + XCTAssertEqual(value, "first") + } +}