From 6eb8da9816b0b7953526a72ce090dc12624986ca Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 22 Oct 2025 10:49:08 +0200 Subject: [PATCH 1/4] [Experimental] Generic file providers --- Package.swift | 2 +- README.md | 12 +- Sources/Configuration/ConfigReader.swift | 2 +- .../Documentation.docc/Documentation.md | 35 ++- .../Guides/Choosing-access-patterns.md | 2 +- .../Guides/Configuring-applications.md | 4 +- .../Guides/Example-use-cases.md | 8 +- .../Guides/Handling-secrets-correctly.md | 4 +- .../Guides/Troubleshooting.md | 4 +- .../Guides/Using-reloading-providers.md | 14 +- .../Reference/FileConfigSnapshotProtocol.md | 12 + .../Reference/FileParsingOptionsProtocol.md | 11 + .../Reference/FileProvider.md | 14 + .../Reference/JSONProvider.md | 8 - .../Reference/JSONSnapshot.md | 13 + .../Reference/ReloadingFileProvider.md | 18 ++ .../Reference/ReloadingJSONprovider.md | 8 - .../Reference/ReloadingYAMLprovider.md | 8 - .../Reference/YAMLProvider.md | 8 - .../Reference/YAMLSnapshot.md | 13 + .../CommonProviderFileSystem.swift | 0 .../Providers/Files/FileProvider.swift | 201 ++++++++++++ .../Files/FileProviderSnapshot.swift | 100 ++++++ .../JSONSnapshot.swift} | 158 ++++++---- .../ReloadingFileProvider.swift} | 291 +++++++++++++----- .../ReloadingFileProviderMetrics.swift | 2 +- .../YAMLSnapshot.swift} | 159 ++++++---- .../Providers/JSON/JSONProvider.swift | 252 --------------- .../JSON/ReloadingJSONProvider.swift | 276 ----------------- .../Wrappers/KeyMappingProvider.swift | 2 +- .../YAML/ReloadingYAMLProvider.swift | 285 ----------------- .../Providers/YAML/YAMLProvider.swift | 260 ---------------- Sources/Configuration/SecretsSpecifier.swift | 2 +- ...ests.swift => JSONFileProviderTests.swift} | 25 +- ...t => JSONReloadingFileProviderTests.swift} | 18 +- ...swift => ReloadingFileProviderTests.swift} | 94 +++--- ...ests.swift => YAMLFileProviderTests.swift} | 25 +- ...t => YAMLReloadingFileProviderTests.swift} | 18 +- 38 files changed, 940 insertions(+), 1428 deletions(-) create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileProvider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/JSONProvider.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md rename Sources/Configuration/Providers/{Common => Files}/CommonProviderFileSystem.swift (100%) create mode 100644 Sources/Configuration/Providers/Files/FileProvider.swift create mode 100644 Sources/Configuration/Providers/Files/FileProviderSnapshot.swift rename Sources/Configuration/Providers/{JSON/JSONProviderSnapshot.swift => Files/JSONSnapshot.swift} (78%) rename Sources/Configuration/Providers/{Common/ReloadingFileProviderCore.swift => Files/ReloadingFileProvider.swift} (65%) rename Sources/Configuration/Providers/{Common => Files}/ReloadingFileProviderMetrics.swift (99%) rename Sources/Configuration/Providers/{YAML/YAMLProviderSnapshot.swift => Files/YAMLSnapshot.swift} (70%) delete mode 100644 Sources/Configuration/Providers/JSON/JSONProvider.swift delete mode 100644 Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift delete mode 100644 Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift delete mode 100644 Sources/Configuration/Providers/YAML/YAMLProvider.swift rename Tests/ConfigurationTests/{JSONProviderTests.swift => JSONFileProviderTests.swift} (71%) rename Tests/ConfigurationTests/{ReloadingJSONProviderTests.swift => JSONReloadingFileProviderTests.swift} (62%) rename Tests/ConfigurationTests/{ReloadingFileProviderCoreTests.swift => ReloadingFileProviderTests.swift} (79%) rename Tests/ConfigurationTests/{YAMLProviderTests.swift => YAMLFileProviderTests.swift} (72%) rename Tests/ConfigurationTests/{ReloadingYAMLProviderTests.swift => YAMLReloadingFileProviderTests.swift} (62%) diff --git a/Package.swift b/Package.swift index 71e126b..27afe6e 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ var traits: Set = [ .trait( name: "ReloadingSupport", description: - "Adds support for reloading built-in provider variants, such as ReloadingJSONProvider and ReloadingYAMLProvider (when their respective traits are enabled).", + "Adds support for reloading built-in provider variants, such as ReloadingFileProvider.", enabledTraits: [ "LoggingSupport" ] diff --git a/README.md b/README.md index 76e441c..5cce529 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The example code below creates the two relevant providers, and resolves them in ```swift let config = ConfigReader(providers: [ EnvironmentVariablesProvider(), - try await JSONProvider(filePath: "/etc/config.json") + try await FileProvider(filePath: "/etc/config.json") ]) let httpTimeout = config.int(forKey: "http.timeout", default: 60) print(httpTimeout) // prints 15 @@ -100,11 +100,11 @@ To enable an additional trait on the package, update the package dependency: ``` Available traits: -- **`JSONSupport`** (default): Adds support for `JSONProvider`, a `ConfigProvider` for reading JSON files. +- **`JSONSupport`** (default): Adds support for `FileProvider`, a `ConfigProvider` for reading JSON files. - **`LoggingSupport`** (opt-in): Adds support for `AccessLogger`, a way to emit access events into a `SwiftLog.Logger`. -- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as `ReloadingJSONProvider` (when `JSONSupport` is enabled) and `ReloadingYAMLProvider` (when `YAMLSupport` is enabled). +- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as `ReloadingFileProvider` (when `JSONSupport` is enabled) and `ReloadingFileProvider` (when `YAMLSupport` is enabled). - **`CommandLineArgumentsSupport`** (opt-in): Adds support for `CommandLineArgumentsProvider` for parsing command line arguments. -- **`YAMLSupport`** (opt-in): Adds support for `YAMLProvider`, a `ConfigProvider` for reading YAML files. +- **`YAMLSupport`** (opt-in): Adds support for `FileProvider`, a `ConfigProvider` for reading YAML files. ## Supported platforms and minimum versions @@ -120,8 +120,8 @@ The library includes comprehensive built-in provider support: - Environment variables: [`EnvironmentVariablesProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/environmentvariablesprovider) - Command-line arguments: [`CommandLineArgumentsProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/commandlineargumentsprovider) -- JSON file: [`JSONProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/jsonprovider) and [`ReloadingJSONProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingjsonprovider) -- YAML file: [`YAMLProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/yamlprovider) and [`ReloadingYAMLProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingyamlprovider) +- JSON file: [`FileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/fileprovider) and [`ReloadingFileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingfileprovider) +- YAML file: [`FileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/fileprovider) and [`ReloadingFileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingfileprovider) - Directory of files: [`DirectoryFilesProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/directoryfilesprovider) - In-memory: [`InMemoryProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/inmemoryprovider) and [`MutableInMemoryProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/mutableinmemoryprovider) - Key transforming: [`KeyMappingProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/keymappingprovider) diff --git a/Sources/Configuration/ConfigReader.swift b/Sources/Configuration/ConfigReader.swift index fc5b41b..9ea01a5 100644 --- a/Sources/Configuration/ConfigReader.swift +++ b/Sources/Configuration/ConfigReader.swift @@ -44,7 +44,7 @@ import Foundation /// // First, check environment variables /// EnvironmentVariablesProvider(), /// // Then, check a JSON config file -/// try await JSONProvider(filePath: "/etc/config.json"), +/// try await FileProvider(filePath: "/etc/config.json"), /// // Finally, fall back to in-memory defaults /// InMemoryProvider(values: [ /// "http.timeout": 60, diff --git a/Sources/Configuration/Documentation.docc/Documentation.md b/Sources/Configuration/Documentation.docc/Documentation.md index b10b114..92f47b3 100644 --- a/Sources/Configuration/Documentation.docc/Documentation.md +++ b/Sources/Configuration/Documentation.docc/Documentation.md @@ -44,7 +44,7 @@ For example, to read the timeout configuration value for an HTTP client, check o } ``` ```swift - let provider = try await JSONProvider( + let provider = try await FileProvider( filePath: "/etc/config.json" ) let config = ConfigReader(provider: provider) @@ -61,7 +61,7 @@ For example, to read the timeout configuration value for an HTTP client, check o } ``` ```swift - let provider = try await ReloadingJSONProvider( + let provider = try await ReloadingFileProvider( filePath: "/etc/config.json" ) // Omitted: Add `provider` to a ServiceGroup @@ -76,7 +76,7 @@ For example, to read the timeout configuration value for an HTTP client, check o timeout: 30 ``` ```swift - let provider = try await YAMLProvider( + let provider = try await FileProvider( filePath: "/etc/config.yaml" ) let config = ConfigReader(provider: provider) @@ -90,7 +90,7 @@ For example, to read the timeout configuration value for an HTTP client, check o timeout: 30 ``` ```swift - let provider = try await ReloadingYAMLProvider( + let provider = try await ReloadingFileProvider( filePath: "/etc/config.yaml" ) // Omitted: Add `provider` to a ServiceGroup @@ -187,11 +187,11 @@ To enable an additional trait on the package, update the package dependency: ``` Available traits: -- **`JSONSupport`** (default): Adds support for ``JSONProvider``, a ``ConfigProvider`` for reading JSON files. +- **`JSONSupport`** (default): Adds support for ``JSONSnapshot``, which enables using ``FileProvider`` and ``ReloadingFileProvider`` with JSON files. - **`LoggingSupport`** (opt-in): Adds support for ``AccessLogger``, a way to emit access events into a `SwiftLog.Logger`. -- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as ``ReloadingJSONProvider`` (when `JSONSupport` is enabled) and ``ReloadingYAMLProvider`` (when `YAMLSupport` is enabled). +- **`ReloadingSupport`** (opt-in): Adds support for ``ReloadingFileProvider``, which provides auto-reloading capability for file-based configuration. - **`CommandLineArgumentsSupport`** (opt-in): Adds support for ``CommandLineArgumentsProvider`` for parsing command line arguments. -- **`YAMLSupport`** (opt-in): Adds support for ``YAMLProvider``, a ``ConfigProvider`` for reading YAML files. +- **`YAMLSupport`** (opt-in): Adds support for ``YAMLSnapshot``, which enables using ``FileProvider`` and ``ReloadingFileProvider`` with YAML files. ### Supported platforms and minimum versions @@ -235,8 +235,8 @@ The library includes comprehensive built-in provider support: - Environment variables: ``EnvironmentVariablesProvider`` - Command-line arguments: ``CommandLineArgumentsProvider`` -- JSON file: ``JSONProvider`` and ``ReloadingJSONProvider`` -- YAML file: ``YAMLProvider`` and ``ReloadingYAMLProvider`` +- JSON file: ``FileProvider`` and ``ReloadingFileProvider`` with ``JSONSnapshot`` +- YAML file: ``FileProvider`` and ``ReloadingFileProvider`` with ``YAMLSnapshot`` - Directory of files: ``DirectoryFilesProvider`` - In-memory: ``InMemoryProvider`` and ``MutableInMemoryProvider`` - Key transforming: ``KeyMappingProvider`` @@ -259,7 +259,7 @@ let config = ConfigReader(providers: [ // Then, check command-line options. CommandLineArgumentsProvider(), // Then, check a JSON config file. - try await JSONProvider(filePath: "/etc/config.json"), + try await FileProvider(filePath: "/etc/config.json"), // Finally, fall back to in-memory defaults. InMemoryProvider(values: [ "http.timeout": 60, @@ -272,10 +272,10 @@ let timeout = config.int(forKey: "http.timeout", default: 15) #### Hot reloading -Long-running services can periodically reload configuration with ``ReloadingJSONProvider`` and ``ReloadingYAMLProvider``: +Long-running services can periodically reload configuration with ``ReloadingFileProvider``: ```swift -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") // Omitted: add provider to a ServiceGroup let config = ConfigReader(provider: provider) @@ -407,7 +407,6 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible - ``ConfigReader`` - ``ConfigProvider`` - ``ConfigSnapshotReader`` -- ``ConfigSnapshotProtocol`` - - - @@ -415,10 +414,10 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible ### Built-in providers - ``EnvironmentVariablesProvider`` - ``CommandLineArgumentsProvider`` -- ``JSONProvider`` -- ``YAMLProvider`` -- ``ReloadingJSONProvider`` -- ``ReloadingYAMLProvider`` +- ``FileProvider`` +- ``ReloadingFileProvider`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` - - ``DirectoryFilesProvider`` - @@ -427,6 +426,8 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible - ``KeyMappingProvider`` ### Creating a custom provider +- ``ConfigSnapshotProtocol`` +- ``FileParsingOptionsProtocol`` - ``ConfigProvider`` - ``ConfigContent`` - ``ConfigValue`` diff --git a/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md b/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md index 032a326..8ea2bce 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md +++ b/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md @@ -122,7 +122,7 @@ Use the "watch" pattern when: - Immediately emits the initial value, then subsequent updates. - Continues monitoring until the task is cancelled. -- Works with providers like ``ReloadingJSONProvider`` and ``ReloadingYAMLProvider``. +- Works with providers like ``ReloadingFileProvider``. For details on reloading providers, check out . diff --git a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md index d1e0feb..b2ca2de 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md +++ b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md @@ -35,8 +35,8 @@ let logger: Logger = ... let config = ConfigReader( providers: [ - EnvironmentVariablesProvider(), - try await JSONProvider(filePath: "/etc/myapp/config.json"), + EnvironmentVariablesProvider(), + try await FileProvider(filePath: "/etc/myapp/config.json"), InMemoryProvider(values: [ "http.server.port": 8080, "http.server.host": "127.0.0.1", diff --git a/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md b/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md index 342594f..1afda76 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md +++ b/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md @@ -24,7 +24,7 @@ variable name above `SERVER_PORT`. ### Reading from a JSON configuration file -You can store multiple configuration values together in a JSON file and read them from the fileystem using ``JSONProvider``. +You can store multiple configuration values together in a JSON file and read them from the fileystem using ``FileProvider`` with ``JSONSnapshot``. The following example creates a ``ConfigReader`` for a JSON file at the path `/etc/config.json`, and reads a url and port number collected as properties of the `database` JSON object: @@ -32,7 +32,7 @@ number collected as properties of the `database` JSON object: import Configuration let config = ConfigReader( - provider: try await JSONProvider(filePath: "/etc/config.json") + provider: try await FileProvider(filePath: "/etc/config.json") ) // Access nested values using dot notation. @@ -92,7 +92,7 @@ let config = ConfigReader(providers: [ // First check environment variables. EnvironmentVariablesProvider(), // Then check the config file. - try await JSONProvider(filePath: "/etc/config.json"), + try await FileProvider(filePath: "/etc/config.json"), // Finally, use hardcoded defaults. InMemoryProvider(values: [ "app.name": "MyApp", @@ -131,7 +131,7 @@ import Configuration import ServiceLifecycle // Create a reloading YAML provider -let provider = try await ReloadingYAMLProvider( +let provider = try await ReloadingFileProvider( filePath: "/etc/app-config.yaml", pollInterval: .seconds(30) ) diff --git a/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md b/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md index 8a42874..ec70ff4 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md +++ b/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md @@ -81,7 +81,7 @@ The following example marks keys as secret based on the closure you provide. In this case, keys that contain `password`, `secret`, or `token` are all marked as secret: ```swift -let provider = JSONProvider( +let provider = FileProvider( filePath: "/etc/config.json", secretsSpecifier: .dynamic { key, value in key.lowercased().contains("password") || @@ -96,7 +96,7 @@ let provider = JSONProvider( The following example asserts that none of the values returned from the provider are considered secret: ```swift -let provider = JSONProvider( +let provider = FileProvider( filePath: "/etc/config.json", secretsSpecifier: .none ) diff --git a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md index c425980..a13aea5 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md +++ b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md @@ -99,12 +99,12 @@ Common ServiceGroup problems: ```swift // Incorrect: Provider not included in ServiceGroup -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") let config = ConfigReader(provider: provider) // File monitoring won't work // Correct: Provider runs in ServiceGroup -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") let serviceGroup = ServiceGroup(services: [provider], logger: logger) try await serviceGroup.run() ``` diff --git a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md index e914f24..c1c381d 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md +++ b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md @@ -6,8 +6,8 @@ Automatically reload configuration from files when they change. A reloading provider monitors configuration files for changes and automatically updates your application's configuration without requiring restarts. Swift Configuration provides: -- ``ReloadingJSONProvider`` for JSON configuration files. -- ``ReloadingYAMLProvider`` for YAML configuration files. +- ``ReloadingFileProvider`` with ``JSONSnapshot`` for JSON configuration files. +- ``ReloadingFileProvider`` with ``YAMLSnapshot`` for YAML configuration files. ### Basic usage @@ -18,7 +18,7 @@ Reloading providers run in a [`ServiceGroup`](https://swiftpackageindex.com/swif ```swift import ServiceLifecycle -let provider = try await ReloadingJSONProvider( +let provider = try await ReloadingFileProvider( filePath: "/etc/config.json", pollInterval: .seconds(15) ) @@ -109,8 +109,8 @@ and interval to watch for a JSON file that contains the configuration for your a let envProvider = EnvironmentVariablesProvider() let envConfig = ConfigReader(provider: envProvider) -let jsonProvider = try await ReloadingJSONProvider( - config: envConfig.scoped(to: "json") +let jsonProvider = try await ReloadingFileProvider( + config: envConfig.scoped(to: "json") // Reads JSON_FILE_PATH and JSON_POLL_INTERVAL_SECONDS ) ``` @@ -120,10 +120,10 @@ let jsonProvider = try await ReloadingJSONProvider( 1. **Replace initialization**: ```swift // Before - let provider = try await JSONProvider(filePath: "/etc/config.json") + let provider = try await FileProvider(filePath: "/etc/config.json") // After - let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") + let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") ``` 2. **Add the provider to a ServiceGroup**: diff --git a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md new file mode 100644 index 0000000..f15bd6a --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md @@ -0,0 +1,12 @@ +# ``Configuration/FileConfigSnapshotProtocol`` + +## Topics + +### Required methods + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Protocol requirements + +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md new file mode 100644 index 0000000..432db59 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md @@ -0,0 +1,11 @@ +# ``Configuration/FileParsingOptionsProtocol`` + +## Topics + +### Required properties + +- ``default`` + +### Parsing options + +- ``FileConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md new file mode 100644 index 0000000..921d194 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md @@ -0,0 +1,14 @@ +# ``Configuration/FileProvider`` + +## Topics + +### Creating a file provider + +- ``init(snapshotType:parsingOptions:filePath:)`` +- ``init(snapshotType:parsingOptions:config:)`` + +### Reading configuration files + +- ``FileConfigSnapshotProtocol`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md b/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md deleted file mode 100644 index 7497c87..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/JSONProvider`` - -## Topics - -### Creating a JSON provider - -- ``init(config:bytesDecoder:secretsSpecifier:)`` -- ``init(filePath:bytesDecoder:secretsSpecifier:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md new file mode 100644 index 0000000..8e08e48 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md @@ -0,0 +1,13 @@ +# ``Configuration/JSONSnapshot`` + +## Topics + +### Creating a JSON snapshot + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Snapshot configuration + +- ``FileConfigSnapshotProtocol`` +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md new file mode 100644 index 0000000..35074c5 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md @@ -0,0 +1,18 @@ +# ``Configuration/ReloadingFileProvider`` + +## Topics + +### Creating a reloading file provider + +- ``init(snapshotType:parsingOptions:filePath:pollInterval:logger:metrics:)`` +- ``init(snapshotType:parsingOptions:config:logger:metrics:)`` + +### Service lifecycle + +- ``run()`` + +### Monitoring file changes + +- ``FileConfigSnapshotProtocol`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md deleted file mode 100644 index 8c1fcbf..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/ReloadingJSONProvider`` - -## Topics - -### Creating a reloading JSON provider - -- ``init(config:bytesDecoder:secretsSpecifier:logger:metrics:)`` -- ``init(filePath:pollInterval:bytesDecoder:secretsSpecifier:logger:metrics:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md deleted file mode 100644 index 72ff05f..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/ReloadingYAMLProvider`` - -## Topics - -### Creating a reloading YAML provider - -- ``init(config:bytesDecoder:secretsSpecifier:logger:metrics:)`` -- ``init(filePath:pollInterval:bytesDecoder:secretsSpecifier:logger:metrics:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md b/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md deleted file mode 100644 index cc0fe8e..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/YAMLProvider`` - -## Topics - -### Creating a YAML provider - -- ``init(config:bytesDecoder:secretsSpecifier:)`` -- ``init(filePath:bytesDecoder:secretsSpecifier:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md new file mode 100644 index 0000000..618fff5 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md @@ -0,0 +1,13 @@ +# ``Configuration/YAMLSnapshot`` + +## Topics + +### Creating a YAML snapshot + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Snapshot configuration + +- ``FileConfigSnapshotProtocol`` +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Providers/Common/CommonProviderFileSystem.swift b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift similarity index 100% rename from Sources/Configuration/Providers/Common/CommonProviderFileSystem.swift rename to Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift new file mode 100644 index 0000000..a4c3665 --- /dev/null +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import SystemPackage + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// A configuration provider that reads from a file on disk using a configurable snapshot type. +/// +/// `FileProvider` is a generic file-based configuration provider that works with different +/// file formats by using different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// This allows for a unified interface for reading JSON, YAML, or other structured configuration files. +/// +/// ## Usage +/// +/// Create a provider by specifying the snapshot type and file path: +/// +/// ```swift +/// // Using with JSON snapshot +/// let jsonProvider = try await FileProvider( +/// filePath: "/etc/config.json" +/// ) +/// +/// // Using with YAML snapshot +/// let yamlProvider = try await FileProvider( +/// filePath: "/etc/config.yaml" +/// ) +/// ``` +/// +/// The provider reads the file once during initialization and creates an immutable snapshot +/// of the configuration values. For auto-reloading behavior, use ``ReloadingFileProvider``. +/// +/// ## Configuration from a reader +/// +/// You can also initialize the provider using a configuration reader that specifies +/// the file path through environment variables or other configuration sources: +/// +/// ```swift +/// let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) +/// let provider = try await FileProvider(config: envConfig) +/// ``` +/// +/// This expects a `filePath` key in the configuration that specifies the path to the file. +/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +@available(Configuration 1.0, *) +public struct FileProvider: Sendable { + + /// A snapshot of the internal state. + private let _snapshot: SnapshotType + + /// Creates a file provider that reads from the specified file path. + /// + /// This initializer reads the file at the given path and creates a snapshot using the + /// specified snapshot type. The file is read once during initialization. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - filePath: The path to the configuration file to read. + /// - Throws: If the file cannot be read or if snapshot creation fails. + public init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions = .default, + filePath: FilePath + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + filePath: filePath, + fileSystem: LocalCommonProviderFileSystem() + ) + } + + /// Creates a file provider using a file path from a configuration reader. + /// + /// This initializer reads the file path from the provided configuration reader + /// and creates a snapshot from that file. + /// + /// ## Configuration keys + /// - `filePath` (string, required): The path to the configuration file to read. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - config: A configuration reader that contains the required configuration keys. + /// - Throws: If the `filePath` key is missing, if the file cannot be read, or if snapshot creation fails. + public init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions = .default, + config: ConfigReader + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + filePath: config.requiredString(forKey: "filePath", as: FilePath.self) + ) + } + + /// Creates a file provider. + /// + /// This internal initializer allows specifying a custom file system implementation, + /// which is primarily used for testing and internal operations. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - filePath: The path to the configuration file to read. + /// - fileSystem: The file system implementation to use for reading the file. + /// - Throws: If the file cannot be read or if snapshot creation fails. + internal init( + snapshotType: SnapshotType.Type, + parsingOptions: SnapshotType.ParsingOptions, + filePath: FilePath, + fileSystem: some CommonProviderFileSystem + ) async throws { + let fileContents = try await fileSystem.fileContents(atPath: filePath) + self._snapshot = try snapshotType.init( + data: fileContents, + providerName: "FileProvider<\(SnapshotType.self)>", + parsingOptions: parsingOptions + ) + } +} + +@available(Configuration 1.0, *) +extension FileProvider: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + _snapshot.description + } +} + +@available(Configuration 1.0, *) +extension FileProvider: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + _snapshot.debugDescription + } +} + +@available(Configuration 1.0, *) +extension FileProvider: ConfigProvider { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var providerName: String { + _snapshot.providerName + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func value( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) throws -> LookupResult { + try _snapshot.value(forKey: key, type: type) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func fetchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) async throws -> LookupResult { + try value(forKey: key, type: type) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchSnapshot( + updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return { + try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: ( + ConfigUpdatesAsyncSequence, Never> + ) async throws -> Return + ) async throws -> Return { + try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func snapshot() -> any ConfigSnapshotProtocol { + _snapshot + } +} diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift new file mode 100644 index 0000000..8ecc05b --- /dev/null +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SystemPackage +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else +public import Foundation +#endif + +/// A type that provides parsing options for file configuration snapshots. +/// +/// This protocol defines the requirements for parsing options types used when creating +/// file-based configuration snapshots. Types conforming to this protocol can provide +/// additional configuration or processing parameters that affect how file data is +/// interpreted and parsed. +/// +/// ## Usage +/// +/// Implement this protocol to provide parsing options: +/// +/// ```swift +/// struct MyParsingOptions: FileParsingOptionsProtocol { +/// let encoding: String.Encoding +/// let dateFormat: String? +/// +/// static let `default` = MyParsingOptions( +/// encoding: .utf8, +/// dateFormat: nil +/// ) +/// } +/// ``` +@available(Configuration 1.0, *) +public protocol FileParsingOptionsProtocol: Sendable { + /// The default instance of this options type. + /// + /// This property provides a default configuration that can be used when + /// no parsing options are specified. + static var `default`: Self { get } +} + +/// A protocol for configuration snapshots created from file data. +/// +/// This protocol extends ``ConfigSnapshotProtocol`` to provide file-specific functionality +/// for creating configuration snapshots from raw file data. Types conforming to this protocol +/// can parse various file formats (such as JSON and YAML) and convert them into configuration values. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. +/// +/// ## Implementation +/// +/// To create a custom file configuration snapshot: +/// +/// ```swift +/// struct MyFormatSnapshot: FileConfigSnapshotProtocol { +/// typealias ParsingOptions = MyParsingOptions +/// +/// let values: [String: ConfigValue] +/// let providerName: String +/// +/// init(data: Data, providerName: String, parsingOptions: MyParsingOptions) throws { +/// self.providerName = providerName +/// // Parse the data according to your format +/// self.values = try parseMyFormat(data, using: parsingOptions) +/// } +/// } +/// ``` +/// +/// The snapshot is responsible for parsing the file data and converting it into a +/// representation of configuration values that can be queried by the configuration system. +@available(Configuration 1.0, *) +public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomStringConvertible, + CustomDebugStringConvertible +{ + /// The parsing options type used for parsing this snapshot. + associatedtype ParsingOptions: FileParsingOptionsProtocol + + /// Creates a new snapshot from file data. + /// + /// This initializer parses the provided file data and creates a snapshot + /// containing the configuration values found in the file. + /// + /// - Parameters: + /// - data: The raw file data to parse. + /// - providerName: The name of the provider creating this snapshot. + /// - parsingOptions: Parsing options that affect parsing behavior. + /// - Throws: If the file data cannot be parsed or contains invalid configuration. + init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws +} diff --git a/Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift similarity index 78% rename from Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift rename to Sources/Configuration/Providers/Files/JSONSnapshot.swift index 07253f4..e4ec2b0 100644 --- a/Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -17,14 +17,49 @@ import SystemPackage // Needs full Foundation for JSONSerialization. -import Foundation +public import Foundation /// A snapshot of configuration values parsed from JSON data. /// /// This structure represents a point-in-time view of configuration values. It handles /// the conversion from JSON types to configuration value types. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. @available(Configuration 1.0, *) -internal struct JSONProviderSnapshot { +public struct JSONSnapshot { + + /// Parsing options for JSON snapshot creation. + /// + /// This struct provides configuration options for parsing JSON data into configuration snapshots, + /// including byte decoding and secrets specification. + public struct ParsingOptions: FileParsingOptionsProtocol { + /// A decoder of bytes from a string. + public var bytesDecoder: any ConfigBytesFromStringDecoder + + /// A specifier for determining which configuration values should be treated as secrets. + public var secretsSpecifier: SecretsSpecifier + + /// Creates parsing options for JSON snapshots. + /// + /// - Parameters: + /// - bytesDecoder: The decoder to use for converting string values to byte arrays. + /// - secretsSpecifier: The specifier for identifying secret values. + public init( + bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + secretsSpecifier: SecretsSpecifier = .none + ) { + self.bytesDecoder = bytesDecoder + self.secretsSpecifier = secretsSpecifier + } + + /// The default parsing options. + /// + /// Uses base64 byte decoding and treats no values as secrets. + public static var `default`: Self { + .init() + } + } + /// The key encoder for JSON. static let keyEncoder: SeparatorKeyEncoder = .dotSeparated @@ -109,7 +144,7 @@ internal struct JSONProviderSnapshot { internal enum JSONConfigError: Error, CustomStringConvertible { /// The top level JSON value was not an object. - case topLevelJSONValueIsNotObject(FilePath) + case topLevelJSONValueIsNotObject /// The primitive type returned by JSONSerialization is not supported. case unsupportedPrimitiveValue([String], String) @@ -119,8 +154,8 @@ internal struct JSONProviderSnapshot { var description: String { switch self { - case .topLevelJSONValueIsNotObject(let path): - return "The top-level value of the JSON file must be an object. File: \(path)" + case .topLevelJSONValueIsNotObject: + return "The top-level value of the JSON file must be an object." case .unsupportedPrimitiveValue(let keyPath, let typeName): return "Unsupported primitive value type: \(typeName) at \(keyPath.joined(separator: "."))" case .unexpectedValueInArray(let keyPath, let typeName): @@ -129,59 +164,14 @@ internal struct JSONProviderSnapshot { } } - /// A decoder of bytes from a string. - var bytesDecoder: any ConfigBytesFromStringDecoder - /// The underlying config values. var values: [String: ValueWrapper] - /// Creates a snapshot with pre-parsed values. - /// - /// - Parameters: - /// - values: The configuration values. - /// - bytesDecoder: The decoder for converting string values to bytes. - init( - values: [String: ValueWrapper], - bytesDecoder: some ConfigBytesFromStringDecoder, - ) { - self.values = values - self.bytesDecoder = bytesDecoder - } + /// The name of the provider that created this snapshot. + public let providerName: String - /// Creates a snapshot by parsing JSON data from a file. - /// - /// This initializer reads JSON data from the specified file, parses it using - /// `JSONSerialization`, and converts the parsed values into the internal - /// configuration format. The top-level JSON value must be an object. - /// - /// - Parameters: - /// - filePath: The path of the JSON file to read. - /// - fileSystem: The file system interface for reading the file. - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - secretsSpecifier: The specifier for identifying secret values. - /// - Throws: An error if the JSON root is not an object, or any error from - /// file reading or JSON parsing. - init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier - ) async throws { - let fileContents = try await fileSystem.fileContents(atPath: filePath) - guard let parsedDictionary = try JSONSerialization.jsonObject(with: fileContents) as? [String: any Sendable] - else { - throw JSONProviderSnapshot.JSONConfigError.topLevelJSONValueIsNotObject(filePath) - } - let values = try parseValues( - parsedDictionary, - keyEncoder: Self.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - self.init( - values: values, - bytesDecoder: bytesDecoder, - ) - } + /// A decoder of bytes from a string. + var bytesDecoder: any ConfigBytesFromStringDecoder /// Parses config content from the provided JSON value. /// - Parameters: @@ -302,13 +292,30 @@ internal struct JSONProviderSnapshot { } @available(Configuration 1.0, *) -extension JSONProviderSnapshot: ConfigSnapshotProtocol { - var providerName: String { - "JSONProvider" +extension JSONSnapshot: FileConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { + guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] + else { + throw JSONSnapshot.JSONConfigError.topLevelJSONValueIsNotObject + } + let values = try parseValues( + parsedDictionary, + keyEncoder: Self.keyEncoder, + secretsSpecifier: parsingOptions.secretsSpecifier + ) + self.init( + values: values, + providerName: providerName, + bytesDecoder: parsingOptions.bytesDecoder + ) } +} +@available(Configuration 1.0, *) +extension JSONSnapshot: ConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = Self.keyEncoder.encode(key) return try withConfigValueLookup(encodedKey: encodedKey) { guard let value = values[encodedKey] else { @@ -319,6 +326,27 @@ extension JSONProviderSnapshot: ConfigSnapshotProtocol { } } +@available(Configuration 1.0, *) +extension JSONSnapshot: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + "\(providerName)[\(values.count) values]" + } +} + +@available(Configuration 1.0, *) +extension JSONSnapshot: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + let prettyValues = + values + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ", ") + return "\(providerName)[\(values.count) values: \(prettyValues)]" + } +} + /// Parses a value emitted by JSONSerialization into a JSON config value. /// - Parameters: /// - parsedDictionary: The parsed JSON object from JSONSerialization. @@ -331,15 +359,15 @@ internal func parseValues( _ parsedDictionary: [String: any Sendable], keyEncoder: some ConfigKeyEncoder, secretsSpecifier: SecretsSpecifier -) throws -> [String: JSONProviderSnapshot.ValueWrapper] { - var values: [String: JSONProviderSnapshot.ValueWrapper] = [:] +) throws -> [String: JSONSnapshot.ValueWrapper] { + var values: [String: JSONSnapshot.ValueWrapper] = [:] var valuesToIterate: [([String], any Sendable)] = parsedDictionary.map { ([$0], $1) } while !valuesToIterate.isEmpty { let (keyComponents, value) = valuesToIterate.removeFirst() if let dictionary = value as? [String: any Sendable] { valuesToIterate.append(contentsOf: dictionary.map { (keyComponents + [$0], $1) }) } else { - let primitiveValue: JSONProviderSnapshot.JSONValue? + let primitiveValue: JSONSnapshot.JSONValue? if let array = value as? [any Sendable] { if array.isEmpty { primitiveValue = .emptyArray @@ -350,7 +378,7 @@ internal func parseValues( try array.enumerated() .map { index, value in guard let string = value as? String else { - throw JSONProviderSnapshot.JSONConfigError.unexpectedValueInArray( + throw JSONSnapshot.JSONConfigError.unexpectedValueInArray( keyComponents + ["\(index)"], "\(type(of: value))" ) @@ -369,7 +397,7 @@ internal func parseValues( } else if let bool = value as? Bool { return .bool(bool) } else { - throw JSONProviderSnapshot.JSONConfigError.unexpectedValueInArray( + throw JSONSnapshot.JSONConfigError.unexpectedValueInArray( keyComponents + ["\(index)"], "\(type(of: value))" ) @@ -377,7 +405,7 @@ internal func parseValues( } ) } else { - throw JSONProviderSnapshot.JSONConfigError.unsupportedPrimitiveValue( + throw JSONSnapshot.JSONConfigError.unsupportedPrimitiveValue( keyComponents + ["0"], "\(type(of: firstValue))" ) @@ -395,7 +423,7 @@ internal func parseValues( } else if value is NSNull { primitiveValue = nil } else { - throw JSONProviderSnapshot.JSONConfigError.unsupportedPrimitiveValue( + throw JSONSnapshot.JSONConfigError.unsupportedPrimitiveValue( keyComponents, "\(type(of: value))" ) diff --git a/Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift similarity index 65% rename from Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift rename to Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index 65b342a..e540c2f 100644 --- a/Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -19,20 +19,71 @@ import FoundationEssentials #else import Foundation #endif -import ServiceLifecycle -import Logging -import Metrics -import Synchronization + +public import SystemPackage +public import ServiceLifecycle +public import Logging +public import Metrics import AsyncAlgorithms -import SystemPackage +import Synchronization -/// A generic common implementation of file-based reloading for configuration providers. +/// A configuration provider that reads configuration from a file on disk with automatic reloading capability. +/// +/// `ReloadingFileProvider` is a generic file-based configuration provider that monitors +/// a configuration file for changes and automatically reloads the data when +/// the file is modified. This provider works with different file formats by using +/// different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// +/// ## Usage +/// +/// Create a reloading provider by specifying the snapshot type and file path: +/// +/// ```swift +/// // Using with a JSON snapshot and a custom poll interval +/// let jsonProvider = try await ReloadingFileProvider( +/// filePath: "/etc/config.json", +/// pollInterval: .seconds(30) +/// ) +/// +/// // Using with a YAML snapshot +/// let yamlProvider = try await ReloadingFileProvider( +/// filePath: "/etc/config.yaml" +/// ) +/// ``` +/// +/// ## Service integration +/// +/// This provider implements the `Service` protocol and must be run within a `ServiceGroup` +/// to enable automatic reloading: +/// +/// ```swift +/// let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") +/// let serviceGroup = ServiceGroup(services: [provider], logger: logger) +/// try await serviceGroup.run() +/// ``` +/// +/// The provider monitors the file by polling at the specified interval (default: 15 seconds) +/// and notifies any active watchers when changes are detected. +/// +/// ## Configuration from a reader +/// +/// You can also initialize the provider using a configuration reader: +/// +/// ```swift +/// let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) +/// let provider = try await ReloadingFileProvider(config: envConfig) +/// ``` /// -/// This internal type handles all the common reloading logic, state management, -/// and service lifecycle for reloading file-based providers. It allows different provider types -/// (JSON, YAML, and so on) to reuse the same logic while providing their own format-specific deserialization. +/// This expects a `filePath` key in the configuration that specifies the path to the file. +/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// +/// ## File monitoring +/// +/// The provider detects changes by monitoring both file timestamps and symlink target changes. +/// When a change is detected, it reloads the file and notifies all active watchers of the +/// updated configuration values. @available(Configuration 1.0, *) -internal final class ReloadingFileProviderCore: Sendable { +public final class ReloadingFileProvider: Sendable { /// The internal storage structure for the provider state. private struct Storage { @@ -63,6 +114,9 @@ internal final class ReloadingFileProviderCore + /// The options used for parsing the data. + private let parsingOptions: SnapshotType.ParsingOptions + /// The file system interface for reading files and timestamps. private let fileSystem: any CommonProviderFileSystem @@ -73,7 +127,7 @@ internal final class ReloadingFileProviderCore SnapshotType - - /// Creates a new reloading file provider core. - /// - /// This initializer performs the initial file load and snapshot creation, - /// resolves any symlinks, and sets up the internal storage. - /// - /// - Parameters: - /// - filePath: The path to the configuration file to monitor. - /// - pollInterval: The interval between timestamp checks. - /// - providerName: The human-readable name of the provider. - /// - fileSystem: The file system to use. - /// - logger: The logger instance. - /// - metrics: The metrics factory. - /// - createSnapshot: A closure that creates a snapshot from file data. - /// - Throws: If the initial file load or snapshot creation fails. internal init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions, filePath: FilePath, pollInterval: Duration, - providerName: String, fileSystem: any CommonProviderFileSystem, logger: Logger, - metrics: any MetricsFactory, - createSnapshot: @Sendable @escaping (Data) async throws -> SnapshotType + metrics: any MetricsFactory ) async throws { + self.parsingOptions = parsingOptions self.filePath = filePath self.pollInterval = pollInterval - self.providerName = providerName + self.providerName = "ReloadingFileProvider<\(SnapshotType.self)>" self.fileSystem = fileSystem - self.createSnapshot = createSnapshot // Set up the logger with metadata var logger = logger @@ -130,9 +167,13 @@ internal final class ReloadingFileProviderCore LookupResult { +@available(Configuration 1.0, *) +extension ReloadingFileProvider: ConfigProvider { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { try storage.withLock { storage in try storage.snapshot.value(forKey: key, type: type) } } - internal func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func fetchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) async throws -> LookupResult { try await reloadIfNeeded(logger: logger) return try value(forKey: key, type: type) } - internal func watchValue( + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchValue( forKey key: AbsoluteConfigKey, type: ConfigType, updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return @@ -395,11 +488,13 @@ extension ReloadingFileProviderCore: ConfigProvider { return try await updatesHandler(.init(stream)) } - internal func snapshot() -> any ConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func snapshot() -> any ConfigSnapshotProtocol { storage.withLock { $0.snapshot } } - internal func watchSnapshot( + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchSnapshot( updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return ) async throws -> Return { let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) @@ -425,4 +520,42 @@ extension ReloadingFileProviderCore: ConfigProvider { } } +@available(Configuration 1.0, *) +extension ReloadingFileProvider: Service { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func run() async throws { + logger.debug("File polling starting") + defer { + logger.debug("File polling stopping") + } + + var counter = 1 + for try await _ in AsyncTimerSequence(interval: pollInterval, clock: .continuous).cancelOnGracefulShutdown() { + defer { + counter += 1 + metrics.pollTickCounter.increment(by: 1) + } + + var tickLogger = logger + tickLogger[metadataKey: "\(providerName).poll.tick.number"] = .stringConvertible(counter) + tickLogger.debug("Poll tick starting") + defer { + tickLogger.debug("Poll tick stopping") + } + + do { + try await reloadIfNeeded(logger: tickLogger) + } catch { + tickLogger.debug( + "Poll tick failed, will retry on next tick", + metadata: [ + "error": "\(error)" + ] + ) + metrics.pollTickErrorCounter.increment(by: 1) + } + } + } +} + #endif diff --git a/Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift b/Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift similarity index 99% rename from Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift rename to Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift index 774de1d..a99930c 100644 --- a/Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift @@ -69,7 +69,7 @@ internal struct ReloadingFileProviderMetrics { /// /// - Parameters: /// - factory: The metrics factory to use for creating metric instances. - /// - providerName: The name of the provider. For example: "ReloadingJSONProvider". + /// - providerName: The name of the provider. For example: "ReloadingFileProvider". init(factory: any MetricsFactory, providerName: String) { let prefix = providerName.lowercased() self.pollTickCounter = Counter(label: "\(prefix)_poll_ticks_total", factory: factory) diff --git a/Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift similarity index 70% rename from Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift rename to Sources/Configuration/Providers/Files/YAMLSnapshot.swift index a50de00..31f5b99 100644 --- a/Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift @@ -15,9 +15,9 @@ #if YAMLSupport #if canImport(FoundationEssentials) -import FoundationEssentials +public import FoundationEssentials #else -import Foundation +public import Foundation #endif import Yams import Synchronization @@ -27,8 +27,43 @@ import SystemPackage /// /// This class represents a point-in-time view of configuration values. It handles /// the conversion from YAML types to configuration value types. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. @available(Configuration 1.0, *) -final class YAMLProviderSnapshot: Sendable { +public final class YAMLSnapshot: Sendable { + + /// Custom input configuration for YAML snapshot creation. + /// + /// This struct provides configuration options for parsing YAML data into configuration snapshots, + /// including byte decoding and secrets specification. + public struct ParsingOptions: FileParsingOptionsProtocol { + /// A decoder of bytes from a string. + public var bytesDecoder: any ConfigBytesFromStringDecoder + + /// A specifier for determining which configuration values should be treated as secrets. + public var secretsSpecifier: SecretsSpecifier + + /// Creates custom input configuration for YAML snapshots. + /// + /// - Parameters: + /// - bytesDecoder: The decoder to use for converting string values to byte arrays. + /// - secretsSpecifier: The specifier for identifying secret values. + public init( + bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + secretsSpecifier: SecretsSpecifier = .none + ) { + self.bytesDecoder = bytesDecoder + self.secretsSpecifier = secretsSpecifier + } + + /// The default custom input configuration. + /// + /// Uses base64 byte decoding and treats no values as secrets. + public static var `default`: Self { + .init() + } + } + /// The key encoder for YAML. private static let keyEncoder: SeparatorKeyEncoder = .dotSeparated @@ -72,7 +107,7 @@ final class YAMLProviderSnapshot: Sendable { internal enum YAMLConfigError: Error, CustomStringConvertible { /// The top level YAML value was not a mapping. - case topLevelYAMLValueIsNotMapping(FilePath) + case topLevelYAMLValueIsNotMapping /// A YAML key is not convertible to string. case keyNotConvertibleToString([String]) @@ -88,8 +123,8 @@ final class YAMLProviderSnapshot: Sendable { var description: String { switch self { - case .topLevelYAMLValueIsNotMapping(let path): - return "Top level YAML value is not a mapping. File: \(path)" + case .topLevelYAMLValueIsNotMapping: + return "Top level YAML value is not a mapping." case .keyNotConvertibleToString(let keyPath): return "YAML key is not convertible to string: \(keyPath.joined(separator: "."))" case .unsupportedPrimitiveValue(let keyPath): @@ -102,6 +137,9 @@ final class YAMLProviderSnapshot: Sendable { } } + /// The name of the provider that created this snapshot. + public let providerName: String + /// A decoder of bytes from a string. let bytesDecoder: any ConfigBytesFromStringDecoder @@ -110,48 +148,14 @@ final class YAMLProviderSnapshot: Sendable { /// Using a Mutex since the Yams types aren't Sendable. let values: Mutex<[String: ValueWrapper]> - /// Creates a snapshot with pre-parsed values. - /// - /// - Parameters: - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - values: The configuration values. - init(bytesDecoder: some ConfigBytesFromStringDecoder, values: sending [String: ValueWrapper]) { - self.bytesDecoder = bytesDecoder + init( + values: sending [String: ValueWrapper], + providerName: String, + bytesDecoder: any ConfigBytesFromStringDecoder + ) { self.values = .init(values) - } - - /// Creates a snapshot by parsing YAML data from a file. - /// - /// This initializer reads YAML data from the specified file, parses it using - /// the Yams library, and converts the parsed values into the internal - /// configuration format. The top-level YAML value must be a mapping. - /// - /// - Parameters: - /// - filePath: The path of the YAML file to read. - /// - fileSystem: The file system interface for reading the file. - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - secretsSpecifier: The specifier for identifying secret values. - /// - Throws: An error if the YAML root is not a mapping, or any error from - /// file reading or YAML parsing. - convenience init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier - ) async throws { - let fileContents = try await fileSystem.fileContents(atPath: filePath) - guard let mapping = try Yams.Parser(yaml: fileContents).singleRoot()?.mapping else { - throw YAMLProviderSnapshot.YAMLConfigError.topLevelYAMLValueIsNotMapping(filePath) - } - let values = try parseValues( - mapping, - keyEncoder: Self.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - self.init( - bytesDecoder: bytesDecoder, - values: values - ) + self.providerName = providerName + self.bytesDecoder = bytesDecoder } /// Parses config content from the provided YAML value. @@ -240,13 +244,29 @@ final class YAMLProviderSnapshot: Sendable { } @available(Configuration 1.0, *) -extension YAMLProviderSnapshot: ConfigSnapshotProtocol { - var providerName: String { - "YAMLProvider" +extension YAMLSnapshot: FileConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public convenience init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { + guard let mapping = try Yams.Parser(yaml: data).singleRoot()?.mapping else { + throw YAMLConfigError.topLevelYAMLValueIsNotMapping + } + let values = try parseValues( + mapping, + keyEncoder: Self.keyEncoder, + secretsSpecifier: parsingOptions.secretsSpecifier + ) + self.init( + values: values, + providerName: providerName, + bytesDecoder: parsingOptions.bytesDecoder + ) } +} +@available(Configuration 1.0, *) +extension YAMLSnapshot: ConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = Self.keyEncoder.encode(key) return try withConfigValueLookup(encodedKey: encodedKey) { try values.withLock { (values) -> ConfigValue? in @@ -263,6 +283,31 @@ extension YAMLProviderSnapshot: ConfigSnapshotProtocol { } } +@available(Configuration 1.0, *) +extension YAMLSnapshot: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + values.withLock { values in + "\(providerName)[\(values.count) values]" + } + } +} + +@available(Configuration 1.0, *) +extension YAMLSnapshot: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + values.withLock { values in + let prettyValues = + values + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ", ") + return "\(providerName)[\(values.count) values: \(prettyValues)]" + } + } +} + /// Parses the root emitted by Yams. /// - Parameters: /// - parsedDictionary: The parsed YAML mapping from Yams. @@ -275,24 +320,24 @@ internal func parseValues( _ parsedDictionary: Yams.Node.Mapping, keyEncoder: some ConfigKeyEncoder, secretsSpecifier: SecretsSpecifier -) throws -> [String: YAMLProviderSnapshot.ValueWrapper] { - var values: [String: YAMLProviderSnapshot.ValueWrapper] = [:] +) throws -> [String: YAMLSnapshot.ValueWrapper] { + var values: [String: YAMLSnapshot.ValueWrapper] = [:] var valuesToIterate: [([String], Yams.Node, Yams.Node)] = parsedDictionary.map { ([], $0, $1) } while !valuesToIterate.isEmpty { let (prefix, nodeKey, value) = valuesToIterate.removeFirst() guard let stringKey = nodeKey.string else { - throw YAMLProviderSnapshot.YAMLConfigError.keyNotConvertibleToString(prefix) + throw YAMLSnapshot.YAMLConfigError.keyNotConvertibleToString(prefix) } let keyComponents = prefix + [stringKey] if let mapping = value.mapping { valuesToIterate.append(contentsOf: mapping.map { (keyComponents, $0, $1) }) } else { - let yamlValue: YAMLProviderSnapshot.YAMLValue + let yamlValue: YAMLSnapshot.YAMLValue if let sequence = value.sequence { let scalarArray = try sequence.enumerated() .map { index, value in guard let scalar = value.scalar else { - throw YAMLProviderSnapshot.YAMLConfigError.nonScalarValueInArray(keyComponents, index) + throw YAMLSnapshot.YAMLConfigError.nonScalarValueInArray(keyComponents, index) } return scalar } @@ -300,7 +345,7 @@ internal func parseValues( } else if let scalar = value.scalar { yamlValue = .scalar(scalar) } else { - throw YAMLProviderSnapshot.YAMLConfigError.unsupportedPrimitiveValue(keyComponents) + throw YAMLSnapshot.YAMLConfigError.unsupportedPrimitiveValue(keyComponents) } let encodedKey = keyEncoder.encode(AbsoluteConfigKey(keyComponents)) let isSecret = secretsSpecifier.isSecret(key: encodedKey, value: ()) diff --git a/Sources/Configuration/Providers/JSON/JSONProvider.swift b/Sources/Configuration/Providers/JSON/JSONProvider.swift deleted file mode 100644 index 52de70e..0000000 --- a/Sources/Configuration/Providers/JSON/JSONProvider.swift +++ /dev/null @@ -1,252 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if JSONSupport - -public import SystemPackage - -// Needs full Foundation for JSONSerialization. -import Foundation - -/// A configuration provider that loads values from JSON files. -/// -/// This provider reads JSON files from the file system and makes their values -/// available as configuration. The JSON structure is flattened using dot notation, -/// allowing nested objects to be accessed with hierarchical keys. -/// -/// The provider loads the JSON file once during initialization and never reloads -/// it, making it a constant provider suitable for configuration that doesn't -/// change during application runtime. -/// -/// > Tip: Do you need to watch the JSON files on disk for changes, and reload them automatically? Check out ``ReloadingJSONProvider``. -/// -/// ## Package traits -/// -/// This provider is guarded by the `JSONSupport` package trait. -/// -/// ## Supported JSON types -/// -/// The provider supports these JSON value types: -/// - **Strings**: Mapped directly to string configuration values -/// - **Numbers**: Integers, doubles, and booleans -/// - **Arrays**: Homogeneous arrays of strings or numbers -/// - **Objects**: Nested objects are flattened using dot notation -/// - **null**: Ignored (no configuration value is created) -/// -/// ## Key flattening -/// -/// Nested JSON objects are flattened into dot-separated keys: -/// -/// ```json -/// { -/// "database": { -/// "host": "localhost", -/// "port": 5432 -/// } -/// } -/// ``` -/// -/// Becomes accessible as: -/// - `database.host` → `"localhost"` -/// - `database.port` → `5432` -/// -/// ## Secret handling -/// -/// The provider supports marking values as secret using a ``SecretsSpecifier``. -/// Secret values are automatically redacted in logs and debug output. -/// -/// ## Usage -/// -/// ```swift -/// // Load from a JSON file -/// let provider = try await JSONProvider(filePath: "/etc/config.json") -/// let config = ConfigReader(provider: provider) -/// -/// // Access nested values using dot notation -/// let host = config.string(forKey: "database.host") -/// let port = config.int(forKey: "database.port") -/// let isEnabled = config.bool(forKey: "features.enabled", default: false) -/// ``` -/// -/// ## Configuration context -/// -/// This provider ignores the context passed in ``AbsoluteConfigKey/context``. -/// All keys are resolved using only their component path. -@available(Configuration 1.0, *) -public struct JSONProvider: Sendable { - - /// A snapshot of the internal state. - private let _snapshot: JSONProviderSnapshot - - /// Creates a new JSON provider by loading the specified file. - /// - /// This initializer loads and parses the JSON file synchronously during - /// initialization. The file must contain a valid JSON object at the root level. - /// - /// ```swift - /// // Load configuration from a JSON file - /// let provider = try await JSONProvider( - /// filePath: "/etc/app-config.json", - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the JSON configuration file. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - public init( - filePath: FilePath, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: filePath, - fileSystem: LocalCommonProviderFileSystem(), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new JSON provider using a file path from configuration. - /// - /// This convenience initializer reads the JSON file path from another - /// configuration source, allowing the JSON provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure JSON provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // JSON_FILE_PATH environment variable specifies the file - /// let jsonProvider = try await JSONProvider( - /// config: config.scoped(to: "json") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the JSON configuration file. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the JSON file. - /// - fileSystem: The underlying file system. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - internal init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - self._snapshot = try await .init( - filePath: filePath, - fileSystem: fileSystem, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - "JSONProvider[\(_snapshot.values.count) values]" - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let prettyValues = _snapshot.values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "JSONProvider[\(_snapshot.values.count) values: \(prettyValues)]" - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - _snapshot.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) throws -> LookupResult { - try _snapshot.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) async throws -> LookupResult { - try value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence, Never> - ) async throws -> Return - ) async throws -> Return { - try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - _snapshot - } -} - -#endif diff --git a/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift b/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift deleted file mode 100644 index 44cc9d6..0000000 --- a/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift +++ /dev/null @@ -1,276 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if JSONSupport && ReloadingSupport - -public import SystemPackage -public import ServiceLifecycle -public import Logging -public import Metrics -// Needs full Foundation for JSONSerialization. -import Foundation - -/// A configuration provider that loads values from a JSON file and automatically reloads them when the file changes. -/// -/// This provider reads a JSON file from the file system and makes its values -/// available as configuration. Unlike ``JSONProvider``, this provider continuously -/// monitors the file for changes and automatically reloads the configuration when -/// the file is modified. -/// -/// The provider must be run as part of a [`ServiceGroup`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle/servicegroup) -/// for the periodic reloading to work. -/// -/// ## Package traits -/// -/// This provider is guarded by the `JSONSupport` and `ReloadingSupport` package traits. -/// -/// ## File monitoring -/// -/// The provider monitors the JSON file by checking its real path and modification timestamp at regular intervals -/// (default: 15 seconds). When a change is detected, the entire file is reloaded and parsed, and changed keys emit -/// a change event to active watchers. -/// -/// ## Watching for changes -/// -/// ```swift -/// let config = ConfigReader(provider: provider) -/// -/// // Watch for changes to specific values -/// try await config.watchString(forKey: "database.host") { updates in -/// for await host in updates { -/// print("Database host updated: \(host)") -/// } -/// } -/// ``` -/// -/// ## Similarities to JSONProvider -/// -/// Check out ``JSONProvider`` to learn more about using JSON for configuration. ``ReloadingJSONProvider`` is -/// a reloading variant of ``JSONProvider`` that otherwise follows the same behavior for handling secrets, -/// key and context mapping, and so on. -@available(Configuration 1.0, *) -public final class ReloadingJSONProvider: Sendable { - - /// The core implementation that handles all reloading logic. - private let core: ReloadingFileProviderCore - - /// Creates a new reloading JSON provider by loading the specified file. - /// - /// This initializer loads and parses the JSON file during initialization and - /// sets up the monitoring infrastructure. The file must contain a valid JSON - /// object at the root level. - /// - /// ```swift - /// // Load configuration from a JSON file with custom settings - /// let provider = try await ReloadingJSONProvider( - /// filePath: "/etc/app-config.json", - /// pollInterval: .seconds(5), - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the JSON configuration file. - /// - pollInterval: The interval between file modification checks. Defaults to 15 seconds. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger. - /// - metrics: The metrics factory. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - public convenience init( - filePath: FilePath, - pollInterval: Duration = .seconds(15), - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingJSONProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: filePath, - pollInterval: pollInterval, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the JSON file. - /// - pollInterval: The interval between file modification checks. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - fileSystem: The underlying file system. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - internal init( - filePath: FilePath, - pollInterval: Duration, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier, - fileSystem: some CommonProviderFileSystem, - logger: Logger, - metrics: any MetricsFactory - ) async throws { - self.core = try await ReloadingFileProviderCore( - filePath: filePath, - pollInterval: pollInterval, - providerName: "ReloadingJSONProvider", - fileSystem: fileSystem, - logger: logger, - metrics: metrics, - createSnapshot: { data in - // Parse JSON and create snapshot using existing logic - guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] - else { - throw JSONProviderSnapshot.JSONConfigError.topLevelJSONValueIsNotObject(filePath) - } - let values = try parseValues( - parsedDictionary, - keyEncoder: JSONProviderSnapshot.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - return JSONProviderSnapshot( - values: values, - bytesDecoder: bytesDecoder - ) - } - ) - } - - /// Creates a new reloading JSON provider using a file path from configuration. - /// - /// This convenience initializer reads the JSON file path from another - /// configuration source, allowing the JSON provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure JSON provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // JSON_FILE_PATH environment variable specifies the file - /// let jsonProvider = try await ReloadingJSONProvider( - /// config: config.scoped(to: "json"), - /// pollInterval: .seconds(10) - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the JSON configuration file. - /// - `pollIntervalSeconds` (int, optional, default: `15`): The interval at which the provider checks the - /// file's last modified timestamp. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger. - /// - metrics: The metrics factory. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public convenience init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingJSONProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - pollInterval: .seconds(config.int(forKey: "pollIntervalSeconds", default: 15)), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - let snapshot = core.snapshot() as! JSONProviderSnapshot - return "ReloadingJSONProvider[\(snapshot.values.count) values]" - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let snapshot = core.snapshot() as! JSONProviderSnapshot - let prettyValues = snapshot.values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "ReloadingJSONProvider[\(snapshot.values.count) values: \(prettyValues)]" - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - core.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { - try core.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { - try await core.fetchValue(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return - ) async throws -> Return { - try await core.watchValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - core.snapshot() - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await core.watchSnapshot(updatesHandler: updatesHandler) - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: Service { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func run() async throws { - try await core.run() - } -} - -#endif diff --git a/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift b/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift index 5357568..a149d3b 100644 --- a/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift +++ b/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift @@ -34,7 +34,7 @@ /// ```swift /// // Create providers /// let envProvider = EnvironmentVariablesProvider() -/// let jsonProvider = try await JSONProvider(filePath: "/etc/config.json") +/// let jsonProvider = try await FileProvider(filePath: "/etc/config.json") /// /// // Only remap the environment variables, not the JSON config /// let keyMappedEnvProvider = KeyMappingProvider(upstream: envProvider) { key in diff --git a/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift b/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift deleted file mode 100644 index e765eb1..0000000 --- a/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift +++ /dev/null @@ -1,285 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if YAMLSupport && ReloadingSupport - -public import SystemPackage -public import ServiceLifecycle -public import Logging -public import Metrics -import Yams -import Synchronization -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -/// A configuration provider that loads values from a YAML file and automatically reloads them when the file changes. -/// -/// This provider reads a YAML file from the file system and makes its values -/// available as configuration. Unlike ``YAMLProvider``, this provider continuously -/// monitors the file for changes and automatically reloads the configuration when -/// the file is modified. -/// -/// The provider must be run as part of a [`ServiceGroup`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle/servicegroup) -/// for the periodic reloading to work. -/// -/// ## Package traits -/// -/// This provider is guarded by the `YAMLSupport` and `ReloadingSupport` package traits. -/// -/// ## File monitoring -/// -/// The provider monitors the YAML file by checking its real path and modification timestamp at regular intervals -/// (default: 15 seconds). When a change is detected, the entire file is reloaded and parsed, and changed keys emit -/// a change event to active watchers. -/// -/// ## Watching for changes -/// -/// ```swift -/// let config = ConfigReader(provider: provider) -/// -/// // Watch for changes to specific values -/// try await config.watchString(forKey: "database.host") { updates in -/// for await host in updates { -/// print("Database host updated: \(host)") -/// } -/// } -/// ``` -/// -/// ## Similarities to YAMLProvider -/// -/// Check out ``YAMLProvider`` to learn more about using YAML for configuration. ``ReloadingYAMLProvider`` is -/// a reloading variant of ``YAMLProvider`` that otherwise follows the same behavior for handling secrets, -/// key and context mapping, and so on. -@available(Configuration 1.0, *) -public final class ReloadingYAMLProvider: Sendable { - - /// The core implementation that handles all reloading logic. - private let core: ReloadingFileProviderCore - - /// Creates a new reloading YAML provider by loading the specified file. - /// - /// This initializer loads and parses the YAML file during initialization and - /// sets up the monitoring infrastructure. The file must contain a valid YAML - /// mapping at the root level. - /// - /// ```swift - /// // Load configuration from a YAML file with custom settings - /// let provider = try await ReloadingYAMLProvider( - /// filePath: "/etc/app-config.yaml", - /// pollInterval: .seconds(5), - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the YAML configuration file. - /// - pollInterval: The interval between file modification checks. Defaults to 15 seconds. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - public convenience init( - filePath: FilePath, - pollInterval: Duration = .seconds(15), - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingYAMLProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: filePath, - pollInterval: pollInterval, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the YAML file. - /// - pollInterval: The interval between file modification checks. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - fileSystem: The underlying file system. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - internal init( - filePath: FilePath, - pollInterval: Duration, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier, - fileSystem: some CommonProviderFileSystem, - logger: Logger, - metrics: any MetricsFactory - ) async throws { - self.core = try await ReloadingFileProviderCore( - filePath: filePath, - pollInterval: pollInterval, - providerName: "ReloadingYAMLProvider", - fileSystem: fileSystem, - logger: logger, - metrics: metrics, - createSnapshot: { data in - // Parse YAML and create snapshot using existing logic - let fileContents = String(decoding: data, as: UTF8.self) - guard let mapping = try Yams.Parser(yaml: fileContents).singleRoot()?.mapping else { - throw YAMLProviderSnapshot.YAMLConfigError.topLevelYAMLValueIsNotMapping(filePath) - } - let values = try parseValues( - mapping, - keyEncoder: SeparatorKeyEncoder.dotSeparated, - secretsSpecifier: secretsSpecifier - ) - return YAMLProviderSnapshot( - bytesDecoder: bytesDecoder, - values: values - ) - } - ) - } - - /// Creates a new reloading YAML provider using a file path from configuration. - /// - /// This convenience initializer reads the YAML file path from another - /// configuration source, allowing the YAML provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure YAML provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // YAML_FILE_PATH environment variable specifies the file - /// let yamlProvider = try await ReloadingYAMLProvider( - /// config: config.scoped(to: "yaml") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the YAML configuration file. - /// - `pollIntervalSeconds` (int, optional, default: `15`): The interval at which the provider checks the - /// file's last modified timestamp. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public convenience init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingYAMLProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - pollInterval: .seconds(config.int(forKey: "pollIntervalSeconds", default: 15)), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - let snapshot = core.snapshot() as! YAMLProviderSnapshot - return snapshot.values.withLock { values in - "ReloadingYAMLProvider[\(values.count) values]" - } - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let snapshot = core.snapshot() as! YAMLProviderSnapshot - return snapshot.values.withLock { values in - let prettyValues = - values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "ReloadingYAMLProvider[\(values.count) values: \(prettyValues)]" - } - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - core.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { - try core.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { - try await core.fetchValue(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return - ) async throws -> Return { - try await core.watchValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - core.snapshot() - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await core.watchSnapshot(updatesHandler: updatesHandler) - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: Service { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func run() async throws { - try await core.run() - } -} - -#endif diff --git a/Sources/Configuration/Providers/YAML/YAMLProvider.swift b/Sources/Configuration/Providers/YAML/YAMLProvider.swift deleted file mode 100644 index 99e56eb..0000000 --- a/Sources/Configuration/Providers/YAML/YAMLProvider.swift +++ /dev/null @@ -1,260 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if YAMLSupport - -public import SystemPackage -import Yams -import Synchronization -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -/// A configuration provider that loads values from YAML files. -/// -/// This provider reads YAML files from the file system and makes their values -/// available as configuration. The YAML structure is flattened using dot notation, -/// allowing nested mappings to be accessed with hierarchical keys. -/// -/// The provider loads the YAML file once during initialization and never reloads -/// it, making it a constant provider suitable for configuration that doesn't -/// change during application runtime. -/// -/// > Tip: Do you need to watch the YAML files on disk for changes, and reload them automatically? Check out ``ReloadingYAMLProvider``. -/// -/// ## Package traits -/// -/// This provider is guarded by the `YAMLSupport` package trait. -/// -/// ## Supported YAML types -/// -/// The provider supports these YAML value types: -/// - **Scalars**: Strings, integers, doubles, and booleans -/// - **Sequences**: Homogeneous arrays of scalars -/// - **Mappings**: Nested objects that are flattened using dot notation -/// - **null**: Ignored (no configuration value is created) -/// -/// ## Key flattening -/// -/// Nested YAML mappings are flattened into dot-separated keys: -/// -/// ```yaml -/// database: -/// host: localhost -/// port: 5432 -/// features: -/// enabled: true -/// ``` -/// -/// Becomes accessible as: -/// - `database.host` → `"localhost"` -/// - `database.port` → `5432` -/// - `features.enabled` → `true` -/// -/// ## Secret handling -/// -/// The provider supports marking values as secret using a ``SecretsSpecifier``. -/// Secret values are automatically redacted in logs and debug output. -/// -/// ## Usage -/// -/// ```swift -/// // Load from a YAML file -/// let provider = try await YAMLProvider(filePath: "/etc/config.yaml") -/// let config = ConfigReader(provider: provider) -/// -/// // Access nested values using dot notation -/// let host = config.string(forKey: "database.host") -/// let port = config.int(forKey: "database.port") -/// let isEnabled = config.bool(forKey: "features.enabled", default: false) -/// ``` -/// -/// ## Configuration context -/// -/// This provider ignores the context passed in ``AbsoluteConfigKey/context``. -/// All keys are resolved using only their component path. -@available(Configuration 1.0, *) -public struct YAMLProvider: Sendable { - - /// A snapshot of the internal state. - private let _snapshot: YAMLProviderSnapshot - - /// Creates a new YAML provider by loading the specified file. - /// - /// This initializer loads and parses the YAML file during initialization. - /// The file must contain a valid YAML mapping at the root level. - /// - /// ```swift - /// // Load configuration from a YAML file - /// let provider = try await YAMLProvider( - /// filePath: "/etc/app-config.yaml", - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the YAML configuration file. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - public init( - filePath: FilePath, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: filePath, - fileSystem: LocalCommonProviderFileSystem(), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new YAML provider using a file path from configuration. - /// - /// This convenience initializer reads the YAML file path from another - /// configuration source, allowing the YAML provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure YAML provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // YAML_FILE_PATH environment variable specifies the file - /// let yamlProvider = try await YAMLProvider( - /// config: config.scoped(to: "yaml") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the YAML configuration file. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the YAML file. - /// - fileSystem: The underlying file system. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - internal init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - self._snapshot = try await .init( - filePath: filePath, - fileSystem: fileSystem, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - _snapshot.values.withLock { values in - "YAMLProvider[\(values.count) values]" - } - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - _snapshot.values.withLock { values in - let prettyValues = - values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "YAMLProvider[\(values.count) values: \(prettyValues)]" - } - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - _snapshot.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) throws -> LookupResult { - try _snapshot.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) async throws -> LookupResult { - try value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence, Never> - ) async throws -> Return - ) async throws -> Return { - try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - _snapshot - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) - } -} - -#endif diff --git a/Sources/Configuration/SecretsSpecifier.swift b/Sources/Configuration/SecretsSpecifier.swift index 78c296d..738b020 100644 --- a/Sources/Configuration/SecretsSpecifier.swift +++ b/Sources/Configuration/SecretsSpecifier.swift @@ -49,7 +49,7 @@ /// Use this for complex logic that determines secrecy based on key patterns or values: /// /// ```swift -/// let provider = JSONProvider( +/// let provider = FileProvider( /// filePath: "/etc/config.json", /// secretsSpecifier: .dynamic { key, value in /// // Mark keys containing "password", diff --git a/Tests/ConfigurationTests/JSONProviderTests.swift b/Tests/ConfigurationTests/JSONFileProviderTests.swift similarity index 71% rename from Tests/ConfigurationTests/JSONProviderTests.swift rename to Tests/ConfigurationTests/JSONFileProviderTests.swift index 4f00e23..9a59458 100644 --- a/Tests/ConfigurationTests/JSONProviderTests.swift +++ b/Tests/ConfigurationTests/JSONFileProviderTests.swift @@ -22,33 +22,40 @@ import SystemPackage private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) let jsonConfigFile = resourcesPath.appending("/config.json") -struct JSONProviderTests { +struct JSONFileProviderTests { @available(Configuration 1.0, *) - var provider: JSONProvider { - get async throws { - try await JSONProvider(filePath: jsonConfigFile) + var provider: JSONSnapshot { + get throws { + try JSONSnapshot( + data: Data(contentsOf: URL(filePath: jsonConfigFile.string)), + providerName: "TestProvider", + parsingOptions: .default + ) } } @available(Configuration 1.0, *) @Test func printingDescription() async throws { let expectedDescription = #""" - JSONProvider[20 values] + TestProvider[20 values] """# - try await #expect(provider.description == expectedDescription) + try #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { let expectedDebugDescription = #""" - JSONProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + TestProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# - try await #expect(provider.debugDescription == expectedDebugDescription) + try #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - try await ProviderCompatTest(provider: provider).runTest() + try await ProviderCompatTest( + provider: FileProvider(filePath: jsonConfigFile) + ) + .runTest() } } diff --git a/Tests/ConfigurationTests/ReloadingJSONProviderTests.swift b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift similarity index 62% rename from Tests/ConfigurationTests/ReloadingJSONProviderTests.swift rename to Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift index ef8b14c..04c3b31 100644 --- a/Tests/ConfigurationTests/ReloadingJSONProviderTests.swift +++ b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift @@ -22,28 +22,28 @@ import ConfigurationTesting import Logging import SystemPackage -struct ReloadingJSONProviderTests { +struct JSONReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDescription = #""" - ReloadingJSONProvider[20 values] + ReloadingFileProvider[20 values] """# #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDebugDescription = #""" - ReloadingJSONProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + ReloadingFileProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) try await ProviderCompatTest(provider: provider).runTest() } @@ -56,12 +56,12 @@ struct ReloadingJSONProviderTests { ]) let config = ConfigReader(provider: envProvider) - let reloadingProvider = try await ReloadingJSONProvider( + let reloadingProvider = try await ReloadingFileProvider( config: config.scoped(to: "json") ) - #expect(reloadingProvider.providerName == "ReloadingJSONProvider") - #expect(reloadingProvider.description.contains("ReloadingJSONProvider[20 values]")) + #expect(reloadingProvider.providerName == "ReloadingFileProvider") + #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) } } diff --git a/Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift similarity index 79% rename from Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift rename to Tests/ConfigurationTests/ReloadingFileProviderTests.swift index c693528..80b7658 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -26,21 +26,29 @@ import Synchronization import SystemPackage @available(Configuration 1.0, *) -private struct TestSnapshot: ConfigSnapshotProtocol { +private struct TestSnapshot: FileConfigSnapshotProtocol { + + struct Input: FileParsingOptionsProtocol { + static var `default`: TestSnapshot.Input { + .init() + } + } + var values: [String: ConfigValue] - var providerName: String { "TestProvider" } + var providerName: String func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = SeparatorKeyEncoder.dotSeparated.encode(key) return LookupResult(encodedKey: encodedKey, value: values[encodedKey]) } - init(values: [String: ConfigValue]) { + init(values: [String: ConfigValue], providerName: String) { self.values = values + self.providerName = providerName } - init(contents: String) throws { + init(contents: String, providerName: String) throws { var values: [String: ConfigValue] = [:] // Simple key=value parser for testing @@ -52,7 +60,19 @@ private struct TestSnapshot: ConfigSnapshotProtocol { values[key] = .init(.string(value), isSecret: false) } } - self.init(values: values) + self.init(values: values, providerName: providerName) + } + + init(data: Data, providerName: String, parsingOptions: Input) throws { + try self.init(contents: String(decoding: data, as: UTF8.self), providerName: providerName) + } + + var description: String { + "TestSnapshot" + } + + var debugDescription: String { + description } } @@ -73,7 +93,7 @@ extension InMemoryFileSystem.FileInfo { @available(Configuration 1.0, *) private func withTestProvider( body: ( - ReloadingFileProviderCore, + ReloadingFileProvider, InMemoryFileSystem, FilePath, Date @@ -92,26 +112,23 @@ private func withTestProvider( ) ] ) - let core = try await ReloadingFileProviderCore( + let provider = try await ReloadingFileProvider( + parsingOptions: .default, filePath: filePath, pollInterval: .seconds(1), - providerName: "TestProvider", fileSystem: fileSystem, logger: .noop, - metrics: NOOPMetricsHandler.instance, - createSnapshot: { data in - try TestSnapshot(contents: String(decoding: data, as: UTF8.self)) - } + metrics: NOOPMetricsHandler.instance ) - return try await body(core, fileSystem, filePath, originalTimestamp) + return try await body(provider, fileSystem, filePath, originalTimestamp) } -struct CoreTests { +struct ReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func testBasicManualReload() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in // Check initial values - let result1 = try core.value(forKey: ["key1"], type: .string) + let result1 = try provider.value(forKey: ["key1"], type: .string) #expect(try result1.value?.content.asString == "value1") // Update file content @@ -127,10 +144,10 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value - let result2 = try core.value(forKey: ["key1"], type: .string) + let result2 = try provider.value(forKey: ["key1"], type: .string) #expect(try result2.value?.content.asString == "newValue1") } } @@ -150,20 +167,17 @@ struct CoreTests { ) ] ) - let core = try await ReloadingFileProviderCore( + let provider = try await ReloadingFileProvider( + parsingOptions: .default, filePath: filePath, pollInterval: .milliseconds(1), - providerName: "TestProvider", fileSystem: fileSystem, logger: .noop, - metrics: NOOPMetricsHandler.instance, - createSnapshot: { data in - try TestSnapshot(contents: String(decoding: data, as: UTF8.self)) - } + metrics: NOOPMetricsHandler.instance ) // Check initial values - let result1 = try core.value(forKey: ["key1"], type: .string) + let result1 = try provider.value(forKey: ["key1"], type: .string) #expect(try result1.value?.content.asString == "value1") // Update file content @@ -181,10 +195,10 @@ struct CoreTests { // Run the service and actively poll until we see the change try await withThrowingTaskGroup { group in group.addTask { - try await core.run() + try await provider.run() } for _ in 1..<1000 { - let result2 = try core.value(forKey: ["key1"], type: .string) + let result2 = try provider.value(forKey: ["key1"], type: .string) guard try result2.value?.content.asString == "newValue1" else { try await Task.sleep(for: .milliseconds(1)) continue @@ -199,7 +213,7 @@ struct CoreTests { @available(Configuration 1.0, *) @Test func testSymlink_targetPathChanged() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let targetPath1 = FilePath("/test/config1.txt") let targetPath2 = FilePath("/test/config2.txt") @@ -230,10 +244,10 @@ struct CoreTests { timestamp: originalTimestamp, contents: .symlink(targetPath1) ) - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check initial value (from target1) - let result1 = try core.value(forKey: ["key"], type: .string) + let result1 = try provider.value(forKey: ["key"], type: .string) #expect(try result1.value?.content.asString == "target1") // Change symlink to point to second target (with same timestamp) @@ -244,17 +258,17 @@ struct CoreTests { ) // Trigger reload - should detect the change even though timestamp is the same - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value (from target2) - let result2 = try core.value(forKey: ["key"], type: .string) + let result2 = try provider.value(forKey: ["key"], type: .string) #expect(try result2.value?.content.asString == "target2") } } @available(Configuration 1.0, *) @Test func testSymlink_timestampChanged() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let targetPath = FilePath("/test/config1.txt") // Create two target files with the same timestamp @@ -275,10 +289,10 @@ struct CoreTests { timestamp: originalTimestamp, contents: .symlink(targetPath) ) - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check initial value (from target1) - let result1 = try core.value(forKey: ["key"], type: .string) + let result1 = try provider.value(forKey: ["key"], type: .string) #expect(try result1.value?.content.asString == "target1") // Change symlink to point to second target (with same timestamp) @@ -293,23 +307,23 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value (from target2) - let result2 = try core.value(forKey: ["key"], type: .string) + let result2 = try provider.value(forKey: ["key"], type: .string) #expect(try result2.value?.content.asString == "target2") } } @available(Configuration 1.0, *) @Test func testWatchValue() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let firstValueConsumed = TestFuture(name: "First value consumed") let updateReceived = TestFuture(name: "Update") try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await core.watchValue(forKey: ["key1"], type: .string) { updates in + try await provider.watchValue(forKey: ["key1"], type: .string) { updates in var iterator = updates.makeAsyncIterator() // First value (initial) @@ -338,7 +352,7 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Wait for update let receivedValue = await updateReceived.value diff --git a/Tests/ConfigurationTests/YAMLProviderTests.swift b/Tests/ConfigurationTests/YAMLFileProviderTests.swift similarity index 72% rename from Tests/ConfigurationTests/YAMLProviderTests.swift rename to Tests/ConfigurationTests/YAMLFileProviderTests.swift index 6a0a818..f072daa 100644 --- a/Tests/ConfigurationTests/YAMLProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLFileProviderTests.swift @@ -24,34 +24,41 @@ import SystemPackage private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) let yamlConfigFile = resourcesPath.appending("/config.yaml") -struct YAMLProviderTests { +struct YAMLFileProviderTests { @available(Configuration 1.0, *) - var provider: YAMLProvider { - get async throws { - try await YAMLProvider(filePath: yamlConfigFile) + var provider: YAMLSnapshot { + get throws { + try YAMLSnapshot( + data: Data(contentsOf: URL(filePath: yamlConfigFile.string)), + providerName: "TestProvider", + parsingOptions: .default + ) } } @available(Configuration 1.0, *) @Test func printingDescription() async throws { let expectedDescription = #""" - YAMLProvider[20 values] + TestProvider[20 values] """# - try await #expect(provider.description == expectedDescription) + try #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { let expectedDebugDescription = #""" - YAMLProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + TestProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# - try await #expect(provider.debugDescription == expectedDebugDescription) + try #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - try await ProviderCompatTest(provider: provider).runTest() + try await ProviderCompatTest( + provider: FileProvider(filePath: yamlConfigFile) + ) + .runTest() } } diff --git a/Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift similarity index 62% rename from Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift rename to Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift index deb7edc..06c07c0 100644 --- a/Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift @@ -22,28 +22,28 @@ import ConfigurationTesting import Logging import SystemPackage -struct ReloadingYAMLProviderTests { +struct YAMLReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDescription = #""" - ReloadingYAMLProvider[20 values] + ReloadingFileProvider[20 values] """# #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDebugDescription = #""" - ReloadingYAMLProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + ReloadingFileProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) try await ProviderCompatTest(provider: provider).runTest() } @@ -56,12 +56,12 @@ struct ReloadingYAMLProviderTests { ]) let config = ConfigReader(provider: envProvider) - let reloadingProvider = try await ReloadingYAMLProvider( + let reloadingProvider = try await ReloadingFileProvider( config: config.scoped(to: "yaml") ) - #expect(reloadingProvider.providerName == "ReloadingYAMLProvider") - #expect(reloadingProvider.description.contains("ReloadingYAMLProvider[20 values]")) + #expect(reloadingProvider.providerName == "ReloadingFileProvider") + #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) } } From 0b7b81c56729105973fbf16e504e39f81c1864cd Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 23 Oct 2025 14:24:41 +0200 Subject: [PATCH 2/4] Remove the word 'read' from the docs' --- Sources/Configuration/Providers/Files/FileProvider.swift | 2 +- .../Configuration/Providers/Files/ReloadingFileProvider.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index a4c3665..960f66a 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -56,7 +56,7 @@ import Foundation /// ``` /// /// This expects a `filePath` key in the configuration that specifies the path to the file. -/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. @available(Configuration 1.0, *) public struct FileProvider: Sendable { diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index e540c2f..0b3a99b 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -75,7 +75,7 @@ import Synchronization /// ``` /// /// This expects a `filePath` key in the configuration that specifies the path to the file. -/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. /// /// ## File monitoring /// From 1370c15ec98b55d55a7dc36a6923f275c5512eac Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 27 Oct 2025 18:29:13 +0100 Subject: [PATCH 3/4] Review feedback from Franz: move from Data to RawSpan in the file snapshot API --- .../Providers/Files/FileProvider.swift | 2 +- .../Files/FileProviderSnapshot.swift | 9 ++--- .../Providers/Files/JSONSnapshot.swift | 6 ++-- .../Files/ReloadingFileProvider.swift | 4 +-- .../Providers/Files/YAMLSnapshot.swift | 8 ++--- Sources/Configuration/Utilities/Span.swift | 33 +++++++++++++++++++ .../JSONFileProviderTests.swift | 2 +- .../ReloadingFileProviderTests.swift | 4 +-- .../YAMLFileProviderTests.swift | 2 +- 9 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 Sources/Configuration/Utilities/Span.swift diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index 960f66a..639940a 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -130,7 +130,7 @@ public struct FileProvider: Sendable { ) async throws { let fileContents = try await fileSystem.fileContents(atPath: filePath) self._snapshot = try snapshotType.init( - data: fileContents, + data: fileContents.bytes, providerName: "FileProvider<\(SnapshotType.self)>", parsingOptions: parsingOptions ) diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift index 8ecc05b..edc3e13 100644 --- a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -13,11 +13,6 @@ //===----------------------------------------------------------------------===// import SystemPackage -#if canImport(FoundationEssentials) -public import FoundationEssentials -#else -public import Foundation -#endif /// A type that provides parsing options for file configuration snapshots. /// @@ -69,7 +64,7 @@ public protocol FileParsingOptionsProtocol: Sendable { /// let values: [String: ConfigValue] /// let providerName: String /// -/// init(data: Data, providerName: String, parsingOptions: MyParsingOptions) throws { +/// init(data: RawSpan, providerName: String, parsingOptions: MyParsingOptions) throws { /// self.providerName = providerName /// // Parse the data according to your format /// self.values = try parseMyFormat(data, using: parsingOptions) @@ -96,5 +91,5 @@ public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomString /// - providerName: The name of the provider creating this snapshot. /// - parsingOptions: Parsing options that affect parsing behavior. /// - Throws: If the file data cannot be parsed or contains invalid configuration. - init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws + init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws } diff --git a/Sources/Configuration/Providers/Files/JSONSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift index e4ec2b0..30e0caa 100644 --- a/Sources/Configuration/Providers/Files/JSONSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -17,7 +17,7 @@ import SystemPackage // Needs full Foundation for JSONSerialization. -public import Foundation +import Foundation /// A snapshot of configuration values parsed from JSON data. /// @@ -294,8 +294,8 @@ public struct JSONSnapshot { @available(Configuration 1.0, *) extension JSONSnapshot: FileConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { - guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] + public init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { + guard let parsedDictionary = try JSONSerialization.jsonObject(with: Data(data)) as? [String: any Sendable] else { throw JSONSnapshot.JSONConfigError.topLevelJSONValueIsNotObject } diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index 0b3a99b..e6b77d1 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -170,7 +170,7 @@ public final class ReloadingFileProvider Date: Mon, 27 Oct 2025 18:44:00 +0100 Subject: [PATCH 4/4] Drop *Protocol suffix --- .../Documentation.docc/Documentation.md | 2 +- ...pshotProtocol.md => FileConfigSnapshot.md} | 2 +- .../Reference/FileParsingOptions.md | 11 +++++++ .../Reference/FileParsingOptionsProtocol.md | 11 ------- .../Reference/FileProvider.md | 2 +- .../Reference/JSONSnapshot.md | 2 +- .../Reference/ReloadingFileProvider.md | 2 +- .../Reference/YAMLSnapshot.md | 2 +- .../Providers/Files/FileProvider.swift | 20 ++++++------- .../Files/FileProviderSnapshot.swift | 10 +++---- .../Providers/Files/JSONSnapshot.swift | 4 +-- .../Files/ReloadingFileProvider.swift | 30 +++++++++---------- .../Providers/Files/YAMLSnapshot.swift | 4 +-- .../ReloadingFileProviderTests.swift | 4 +-- 14 files changed, 53 insertions(+), 53 deletions(-) rename Sources/Configuration/Documentation.docc/Reference/{FileConfigSnapshotProtocol.md => FileConfigSnapshot.md} (77%) create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md diff --git a/Sources/Configuration/Documentation.docc/Documentation.md b/Sources/Configuration/Documentation.docc/Documentation.md index 92f47b3..7649110 100644 --- a/Sources/Configuration/Documentation.docc/Documentation.md +++ b/Sources/Configuration/Documentation.docc/Documentation.md @@ -427,7 +427,7 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible ### Creating a custom provider - ``ConfigSnapshotProtocol`` -- ``FileParsingOptionsProtocol`` +- ``FileParsingOptions`` - ``ConfigProvider`` - ``ConfigContent`` - ``ConfigValue`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md similarity index 77% rename from Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md rename to Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md index f15bd6a..5f5ebe8 100644 --- a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md +++ b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md @@ -1,4 +1,4 @@ -# ``Configuration/FileConfigSnapshotProtocol`` +# ``Configuration/FileConfigSnapshot`` ## Topics diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md new file mode 100644 index 0000000..8ac2a54 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md @@ -0,0 +1,11 @@ +# ``Configuration/FileParsingOptions`` + +## Topics + +### Required properties + +- ``default`` + +### Parsing options + +- ``FileConfigSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md deleted file mode 100644 index 432db59..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md +++ /dev/null @@ -1,11 +0,0 @@ -# ``Configuration/FileParsingOptionsProtocol`` - -## Topics - -### Required properties - -- ``default`` - -### Parsing options - -- ``FileConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md index 921d194..34f9518 100644 --- a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md @@ -9,6 +9,6 @@ ### Reading configuration files -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``JSONSnapshot`` - ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md index 8e08e48..fd6ff92 100644 --- a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md +++ b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md @@ -9,5 +9,5 @@ ### Snapshot configuration -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md index 35074c5..9f72e9b 100644 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md @@ -13,6 +13,6 @@ ### Monitoring file changes -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``JSONSnapshot`` - ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md index 618fff5..8e9d03b 100644 --- a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md +++ b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md @@ -9,5 +9,5 @@ ### Snapshot configuration -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index 639940a..af93177 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -23,7 +23,7 @@ import Foundation /// A configuration provider that reads from a file on disk using a configurable snapshot type. /// /// `FileProvider` is a generic file-based configuration provider that works with different -/// file formats by using different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// file formats by using different snapshot types that conform to ``FileConfigSnapshot``. /// This allows for a unified interface for reading JSON, YAML, or other structured configuration files. /// /// ## Usage @@ -58,10 +58,10 @@ import Foundation /// This expects a `filePath` key in the configuration that specifies the path to the file. /// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. @available(Configuration 1.0, *) -public struct FileProvider: Sendable { +public struct FileProvider: Sendable { /// A snapshot of the internal state. - private let _snapshot: SnapshotType + private let _snapshot: Snapshot /// Creates a file provider that reads from the specified file path. /// @@ -74,8 +74,8 @@ public struct FileProvider: Sendable { /// - filePath: The path to the configuration file to read. /// - Throws: If the file cannot be read or if snapshot creation fails. public init( - snapshotType: SnapshotType.Type = SnapshotType.self, - parsingOptions: SnapshotType.ParsingOptions = .default, + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions = .default, filePath: FilePath ) async throws { try await self.init( @@ -100,8 +100,8 @@ public struct FileProvider: Sendable { /// - config: A configuration reader that contains the required configuration keys. /// - Throws: If the `filePath` key is missing, if the file cannot be read, or if snapshot creation fails. public init( - snapshotType: SnapshotType.Type = SnapshotType.self, - parsingOptions: SnapshotType.ParsingOptions = .default, + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions = .default, config: ConfigReader ) async throws { try await self.init( @@ -123,15 +123,15 @@ public struct FileProvider: Sendable { /// - fileSystem: The file system implementation to use for reading the file. /// - Throws: If the file cannot be read or if snapshot creation fails. internal init( - snapshotType: SnapshotType.Type, - parsingOptions: SnapshotType.ParsingOptions, + snapshotType: Snapshot.Type, + parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, fileSystem: some CommonProviderFileSystem ) async throws { let fileContents = try await fileSystem.fileContents(atPath: filePath) self._snapshot = try snapshotType.init( data: fileContents.bytes, - providerName: "FileProvider<\(SnapshotType.self)>", + providerName: "FileProvider<\(Snapshot.self)>", parsingOptions: parsingOptions ) } diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift index edc3e13..b4bcc25 100644 --- a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -26,7 +26,7 @@ import SystemPackage /// Implement this protocol to provide parsing options: /// /// ```swift -/// struct MyParsingOptions: FileParsingOptionsProtocol { +/// struct MyParsingOptions: FileParsingOptions { /// let encoding: String.Encoding /// let dateFormat: String? /// @@ -37,7 +37,7 @@ import SystemPackage /// } /// ``` @available(Configuration 1.0, *) -public protocol FileParsingOptionsProtocol: Sendable { +public protocol FileParsingOptions: Sendable { /// The default instance of this options type. /// /// This property provides a default configuration that can be used when @@ -58,7 +58,7 @@ public protocol FileParsingOptionsProtocol: Sendable { /// To create a custom file configuration snapshot: /// /// ```swift -/// struct MyFormatSnapshot: FileConfigSnapshotProtocol { +/// struct MyFormatSnapshot: FileConfigSnapshot { /// typealias ParsingOptions = MyParsingOptions /// /// let values: [String: ConfigValue] @@ -75,11 +75,11 @@ public protocol FileParsingOptionsProtocol: Sendable { /// The snapshot is responsible for parsing the file data and converting it into a /// representation of configuration values that can be queried by the configuration system. @available(Configuration 1.0, *) -public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomStringConvertible, +public protocol FileConfigSnapshot: ConfigSnapshotProtocol, CustomStringConvertible, CustomDebugStringConvertible { /// The parsing options type used for parsing this snapshot. - associatedtype ParsingOptions: FileParsingOptionsProtocol + associatedtype ParsingOptions: FileParsingOptions /// Creates a new snapshot from file data. /// diff --git a/Sources/Configuration/Providers/Files/JSONSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift index 30e0caa..7b8a2fb 100644 --- a/Sources/Configuration/Providers/Files/JSONSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -32,7 +32,7 @@ public struct JSONSnapshot { /// /// This struct provides configuration options for parsing JSON data into configuration snapshots, /// including byte decoding and secrets specification. - public struct ParsingOptions: FileParsingOptionsProtocol { + public struct ParsingOptions: FileParsingOptions { /// A decoder of bytes from a string. public var bytesDecoder: any ConfigBytesFromStringDecoder @@ -292,7 +292,7 @@ public struct JSONSnapshot { } @available(Configuration 1.0, *) -extension JSONSnapshot: FileConfigSnapshotProtocol { +extension JSONSnapshot: FileConfigSnapshot { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { guard let parsedDictionary = try JSONSerialization.jsonObject(with: Data(data)) as? [String: any Sendable] diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index e6b77d1..9b779e6 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -32,7 +32,7 @@ import Synchronization /// `ReloadingFileProvider` is a generic file-based configuration provider that monitors /// a configuration file for changes and automatically reloads the data when /// the file is modified. This provider works with different file formats by using -/// different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// different snapshot types that conform to ``FileConfigSnapshot``. /// /// ## Usage /// @@ -83,13 +83,13 @@ import Synchronization /// When a change is detected, it reloads the file and notifies all active watchers of the /// updated configuration values. @available(Configuration 1.0, *) -public final class ReloadingFileProvider: Sendable { +public final class ReloadingFileProvider: Sendable { /// The internal storage structure for the provider state. private struct Storage { /// The current configuration snapshot. - var snapshot: SnapshotType + var snapshot: Snapshot /// Last modified timestamp of the resolved file. var lastModifiedTimestamp: Date @@ -101,7 +101,7 @@ public final class ReloadingFileProvider>.Continuation]] /// Active watchers for configuration snapshots. - var snapshotWatchers: [UUID: AsyncStream.Continuation] + var snapshotWatchers: [UUID: AsyncStream.Continuation] /// Returns the total number of active watchers. var totalWatcherCount: Int { @@ -115,7 +115,7 @@ public final class ReloadingFileProvider /// The options used for parsing the data. - private let parsingOptions: SnapshotType.ParsingOptions + private let parsingOptions: Snapshot.ParsingOptions /// The file system interface for reading files and timestamps. private let fileSystem: any CommonProviderFileSystem @@ -136,8 +136,8 @@ public final class ReloadingFileProvider, [AsyncStream>.Continuation] )] - typealias SnapshotWatchers = (SnapshotType, [AsyncStream.Continuation]) + typealias SnapshotWatchers = (Snapshot, [AsyncStream.Continuation]) guard let (valueWatchersToNotify, snapshotWatchersToNotify) = storage @@ -497,7 +497,7 @@ extension ReloadingFileProvider: ConfigProvider { public func watchSnapshot( updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return ) async throws -> Return { - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) let id = UUID() // Add watcher and get initial snapshot diff --git a/Sources/Configuration/Providers/Files/YAMLSnapshot.swift b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift index 8836bd4..84ffc75 100644 --- a/Sources/Configuration/Providers/Files/YAMLSnapshot.swift +++ b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift @@ -36,7 +36,7 @@ public final class YAMLSnapshot: Sendable { /// /// This struct provides configuration options for parsing YAML data into configuration snapshots, /// including byte decoding and secrets specification. - public struct ParsingOptions: FileParsingOptionsProtocol { + public struct ParsingOptions: FileParsingOptions { /// A decoder of bytes from a string. public var bytesDecoder: any ConfigBytesFromStringDecoder @@ -244,7 +244,7 @@ public final class YAMLSnapshot: Sendable { } @available(Configuration 1.0, *) -extension YAMLSnapshot: FileConfigSnapshotProtocol { +extension YAMLSnapshot: FileConfigSnapshot { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public convenience init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { guard let mapping = try Yams.Parser(yaml: Data(data)).singleRoot()?.mapping else { diff --git a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift index f1771e4..724826f 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -26,9 +26,9 @@ import Synchronization import SystemPackage @available(Configuration 1.0, *) -private struct TestSnapshot: FileConfigSnapshotProtocol { +private struct TestSnapshot: FileConfigSnapshot { - struct Input: FileParsingOptionsProtocol { + struct Input: FileParsingOptions { static var `default`: TestSnapshot.Input { .init() }