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
[](http://cocoapods.org/pods/SwinjectPropertyLoader)
[](http://cocoapods.org/pods/SwinjectPropertyLoader)
[](http://cocoapods.org/pods/SwinjectPropertyLoader)
-[](https://developer.apple.com/swift)
+[](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")
+ }
+}