From a83c95656fb76cf1ad6223ed401619d101af94b5 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 12 Nov 2025 14:29:38 +0100 Subject: [PATCH 1/3] Add an allowMissing parameter to file-based providers --- Package.swift | 15 +- .../Guides/Configuring-applications.md | 5 +- .../Guides/Example-use-cases.md | 65 ++++ .../Guides/Troubleshooting.md | 39 +++ .../Guides/Using-reloading-providers.md | 53 ++++ .../Reference/DirectoryFilesProvider.md | 2 +- .../Reference/EnvironmentVariablesProvider.md | 2 +- .../Reference/FileProvider.md | 2 +- .../Reference/ReloadingFileProvider.md | 2 +- Sources/Configuration/MultiProvider.swift | 6 +- .../EnvironmentVariablesProvider.swift | 97 +++--- .../Files/CommonProviderFileSystem.swift | 125 +++++--- .../Files/DirectoryFilesProvider.swift | 30 +- .../Providers/Files/FileProvider.swift | 77 ++++- .../Files/FileProviderSnapshot.swift | 26 ++ .../Files/ReloadingFileProvider.swift | 282 ++++++++++++++---- .../Utilities/FoundationExtensions.swift | 22 ++ .../InMemoryFileSystem.swift | 42 ++- .../DirectoryFilesProviderTests.swift | 30 ++ .../EnvironmentVariablesProviderTests.swift | 35 ++- Tests/ConfigurationTests/FileProvider.swift | 54 ---- .../FileProviderTests.swift | 101 +++++++ .../JSONFileProviderTests.swift | 96 +++++- .../JSONReloadingFileProviderTests.swift | 44 +-- .../ReloadingFileProviderTests.swift | 110 +++++++ Tests/ConfigurationTests/Resources/.env | 9 - .../ConfigurationTests/Resources/config.json | 79 ----- .../ConfigurationTests/Resources/config.yaml | 56 ---- .../YAMLFileProviderTests.swift | 73 ++++- .../YAMLReloadingFileProviderTests.swift | 44 +-- 30 files changed, 1157 insertions(+), 466 deletions(-) delete mode 100644 Tests/ConfigurationTests/FileProvider.swift create mode 100644 Tests/ConfigurationTests/FileProviderTests.swift delete mode 100644 Tests/ConfigurationTests/Resources/.env delete mode 100644 Tests/ConfigurationTests/Resources/config.json delete mode 100644 Tests/ConfigurationTests/Resources/config.yaml diff --git a/Package.swift b/Package.swift index f22459b..78df901 100644 --- a/Package.swift +++ b/Package.swift @@ -9,12 +9,6 @@ import Foundation let defaultTraits: Set = [ "JSONSupport" - - // Disabled due to a bug in SwiftPM with traits that pull in an external dependency. - // Once that's fixed in Swift 6.2.x, we can enable these traits by default. - // Open fix: https://github.com/swiftlang/swift-package-manager/pull/9136 - // "LoggingSupport", - // "ReloadingSupport", ] var traits: Set = [ @@ -146,9 +140,6 @@ let package = Package( "ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet1.swift.gyb", "ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet2.swift.gyb", "ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet3.swift.gyb", - ], - resources: [ - .copy("Resources") ] ), @@ -188,7 +179,11 @@ for target in package.targets { // https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/ settings.append(.enableUpcomingFeature("NonisolatedNonsendingByDefault")) - settings.append(.enableExperimentalFeature("AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0")) + settings.append( + .enableExperimentalFeature( + "AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0" + ) + ) if enableAllCIFlags { // Ensure all public types are explicitly annotated as Sendable or not Sendable. diff --git a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md index b2ca2de..d79abbc 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md +++ b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md @@ -36,7 +36,10 @@ let logger: Logger = ... let config = ConfigReader( providers: [ EnvironmentVariablesProvider(), - try await FileProvider(filePath: "/etc/myapp/config.json"), + try await FileProvider( + filePath: "/etc/myapp/config.json", + allowMissing: true // Optional: treat missing file as empty config + ), 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 1afda76..4016ec0 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md +++ b/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md @@ -77,6 +77,71 @@ such as Kubernetes secrets mounted into a container's filesystem. > Tip: For comprehensive guidance on handling secrets securely, see . +### Handling optional configuration files + +File-based providers support an `allowMissing` parameter to control whether missing files should throw an error or be treated as empty configuration. This is useful when configuration files are optional. + +When `allowMissing` is `false` (the default), missing files throw an error: + +```swift +import Configuration + +// This will throw an error if config.json doesn't exist +let config = ConfigReader( + provider: try await FileProvider( + filePath: "/etc/config.json", + allowMissing: false // This is the default + ) +) +``` + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +```swift +import Configuration + +// This won't throw if config.json is missing - treats it as empty +let config = ConfigReader( + provider: try await FileProvider( + filePath: "/etc/config.json", + allowMissing: true + ) +) + +// Returns the default value if the file is missing +let port = config.int(forKey: "server.port", default: 8080) +``` + +The same applies to other file-based providers: + +```swift +// Optional secrets directory +let secretsConfig = ConfigReader( + provider: try await DirectoryFilesProvider( + directoryPath: "/run/secrets", + allowMissing: true + ) +) + +// Optional environment file +let envConfig = ConfigReader( + provider: try await EnvironmentVariablesProvider( + environmentFilePath: "/etc/app.env", + allowMissing: true + ) +) + +// Optional reloading configuration +let reloadingConfig = ConfigReader( + provider: try await ReloadingFileProvider( + filePath: "/etc/dynamic-config.yaml", + allowMissing: true + ) +) +``` + +> Important: The `allowMissing` parameter only affects missing files. Malformed files, such as invalid JSON and YAML syntax errors will still throw parsing errors regardless of this setting. + ### Setting up a fallback hierarchy Use multiple providers together to provide a configuration hierarchy that can override values at different levels. diff --git a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md index a13aea5..26ded5c 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md +++ b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md @@ -82,6 +82,45 @@ When no provider has the requested value: - **Methods without defaults**: Return nil. - **Required methods**: Throw an error. +#### File not found errors + +File-based providers (``FileProvider``, ``ReloadingFileProvider``, ``DirectoryFilesProvider``, ``EnvironmentVariablesProvider`` with file path) can throw "file not found" errors when expected configuration files don't exist. + +Common scenarios and solutions: + +**Optional configuration files:** +```swift +// Problem: App crashes when optional config file is missing +let provider = try await FileProvider(filePath: "/etc/optional-config.json") + +// Solution: Use allowMissing parameter +let provider = try await FileProvider( + filePath: "/etc/optional-config.json", + allowMissing: true +) +``` + +**Environment-specific files:** +```swift +// Different environments may have different config files +let configPath = "/etc/\(environment)/config.json" +let provider = try await FileProvider( + filePath: configPath, + allowMissing: true // Gracefully handle missing env-specific configs +) +``` + +**Container startup issues:** +```swift +// Config files might not be ready when container starts +let provider = try await ReloadingFileProvider( + filePath: "/mnt/config/app.json", + allowMissing: true // Allow startup with empty config, load when available +) +``` + +> Important: The `allowMissing` parameter only affects missing files or directories. Files with syntax errors (invalid JSON, YAML, and so on) will still throw parsing errors. + ### Reloading provider troubleshooting #### Configuration not updating diff --git a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md index c1c381d..1f4d4c6 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md +++ b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md @@ -20,6 +20,7 @@ import ServiceLifecycle let provider = try await ReloadingFileProvider( filePath: "/etc/config.json", + allowMissing: true, // Optional: treat missing file as empty config pollInterval: .seconds(15) ) @@ -98,6 +99,58 @@ try await config.watchSnapshot { updates in | **Service lifecycle** | Not required | Conforms to `Service` and must run in a `ServiceGroup` | | **Configuration updates** | Require restart | Automatic reload | +### Handling missing files during reloading + +Reloading providers support the `allowMissing` parameter to handle cases where configuration files might be temporarily missing or optional. This is particularly useful for: + +- Optional configuration files that might not exist in all environments. +- Configuration files that are created or removed dynamically. +- Graceful handling of file system issues during service startup. + +#### Missing file behavior + +When `allowMissing` is `false` (the default), missing files cause errors: + +```swift +let provider = try await ReloadingFileProvider( + filePath: "/etc/config.json", + allowMissing: false // Default: throw error if file is missing +) +// Will throw an error if config.json doesn't exist +``` + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +```swift +let provider = try await ReloadingFileProvider( + filePath: "/etc/config.json", + allowMissing: true // Treat missing file as empty config +) +// Won't throw if config.json is missing - uses empty config instead +``` + +#### Behavior during reloading + +If a file becomes missing after the provider starts, the behavior depends on the `allowMissing` setting: + +- **`allowMissing: false`**: The provider keeps the last known configuration and logs an error. +- **`allowMissing: true`**: The provider switches to empty configuration. + +In both cases, when a valid file comes back, the provider will load it and recover. + +```swift +// Example: File gets deleted during runtime +try await config.watchString(forKey: "database.host", default: "localhost") { updates in + for await host in updates { + // With allowMissing: true, this will receive "localhost" when file is removed + // With allowMissing: false, this keeps the last known value + print("Database host: \(host)") + } +} +``` + +> Important: The `allowMissing` parameter only affects missing files. Malformed files will still cause parsing errors regardless of this setting. + ### Advanced features #### Configuration-driven setup diff --git a/Sources/Configuration/Documentation.docc/Reference/DirectoryFilesProvider.md b/Sources/Configuration/Documentation.docc/Reference/DirectoryFilesProvider.md index c9b087e..19c3477 100644 --- a/Sources/Configuration/Documentation.docc/Reference/DirectoryFilesProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/DirectoryFilesProvider.md @@ -4,4 +4,4 @@ ### Creating a directory files provider -- ``init(directoryPath:secretsSpecifier:arraySeparator:keyEncoder:)`` +- ``init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:keyEncoder:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/EnvironmentVariablesProvider.md b/Sources/Configuration/Documentation.docc/Reference/EnvironmentVariablesProvider.md index 33e7b55..8b20dbf 100644 --- a/Sources/Configuration/Documentation.docc/Reference/EnvironmentVariablesProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/EnvironmentVariablesProvider.md @@ -6,7 +6,7 @@ - ``init(secretsSpecifier:bytesDecoder:arraySeparator:)`` - ``init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:)`` -- ``init(environmentFilePath:secretsSpecifier:bytesDecoder:arraySeparator:)`` +- ``init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:)`` ### Inspecting an environment variable provider diff --git a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md index 34f9518..92c90a4 100644 --- a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md @@ -4,7 +4,7 @@ ### Creating a file provider -- ``init(snapshotType:parsingOptions:filePath:)`` +- ``init(snapshotType:parsingOptions:filePath:allowMissing:)`` - ``init(snapshotType:parsingOptions:config:)`` ### Reading configuration files diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md index 9f72e9b..2fa6c76 100644 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md @@ -4,7 +4,7 @@ ### Creating a reloading file provider -- ``init(snapshotType:parsingOptions:filePath:pollInterval:logger:metrics:)`` +- ``init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:)`` - ``init(snapshotType:parsingOptions:config:logger:metrics:)`` ### Service lifecycle diff --git a/Sources/Configuration/MultiProvider.swift b/Sources/Configuration/MultiProvider.swift index d73dfaf..9aaf936 100644 --- a/Sources/Configuration/MultiProvider.swift +++ b/Sources/Configuration/MultiProvider.swift @@ -209,7 +209,7 @@ extension MultiProvider { updateSequences: &updateSequences, ) { providerUpdateSequences in let updateArrays = combineLatestMany( - elementType: (any ConfigSnapshotProtocol).self, + elementType: (any ConfigSnapshot).self, failureType: Never.self, providerUpdateSequences ) @@ -364,8 +364,8 @@ nonisolated(nonsending) private func withProvidersWatchingValue( @available(Configuration 1.0, *) nonisolated(nonsending) private func withProvidersWatchingSnapshot( providers: ArraySlice, - updateSequences: inout [any (AsyncSequence & Sendable)], - body: ([any (AsyncSequence & Sendable)]) async throws -> ReturnInner + updateSequences: inout [any (AsyncSequence & Sendable)], + body: ([any (AsyncSequence & Sendable)]) async throws -> ReturnInner ) async throws -> ReturnInner { guard let provider = providers.first else { // Recursion termination, once we've collected all update sequences, execute the body. diff --git a/Sources/Configuration/Providers/EnvironmentVariables/EnvironmentVariablesProvider.swift b/Sources/Configuration/Providers/EnvironmentVariables/EnvironmentVariablesProvider.swift index de71a22..9c1de71 100644 --- a/Sources/Configuration/Providers/EnvironmentVariables/EnvironmentVariablesProvider.swift +++ b/Sources/Configuration/Providers/EnvironmentVariables/EnvironmentVariablesProvider.swift @@ -147,20 +147,6 @@ public struct EnvironmentVariablesProvider: Sendable { /// The underlying snapshot of the provider. private let _snapshot: Snapshot - /// The error thrown by the provider. - enum ProviderError: Error, CustomStringConvertible { - - /// The environment file was not found at the provided path. - case environmentFileNotFound(path: FilePath) - - var description: String { - switch self { - case .environmentFileNotFound(let path): - return "EnvironmentVariablesProvider: File not found at path: \(path)." - } - } - } - /// Creates a new provider that reads from the current process environment. /// /// This initializer creates a provider that sources configuration values from @@ -239,7 +225,7 @@ public struct EnvironmentVariablesProvider: Sendable { /// Creates a new provider that reads from an environment file. /// - /// This initializer loads environment variables from a `.env` file at the specified path. + /// This initializer loads environment variables from an `.env` file at the specified path. /// The file should contain key-value pairs in the format `KEY=value`, one per line. /// Comments (lines starting with `#`) and empty lines are ignored. /// @@ -247,36 +233,72 @@ public struct EnvironmentVariablesProvider: Sendable { /// // Load from a .env file /// let provider = try await EnvironmentVariablesProvider( /// environmentFilePath: ".env", + /// allowMissing: true, /// secretsSpecifier: .specific(["API_KEY"]) /// ) /// ``` /// /// - Parameters: /// - environmentFilePath: The file system path to the environment file to load. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. /// - secretsSpecifier: Specifies which environment variables should be treated as secrets. /// - bytesDecoder: The decoder used for converting string values to byte arrays. /// - arraySeparator: The character used to separate elements in array values. - /// - Throws: If the file cannot be found or read. + /// - Throws: If the file is malformed, or if missing when allowMissing is `false`. public init( environmentFilePath: FilePath, + allowMissing: Bool = false, secretsSpecifier: SecretsSpecifier = .none, bytesDecoder: some ConfigBytesFromStringDecoder = .base64, arraySeparator: Character = "," ) async throws { - do { - let contents = try String( - contentsOfFile: environmentFilePath.string, - encoding: .utf8 - ) - self.init( - environmentVariables: EnvironmentFileParser.parsed(contents), - secretsSpecifier: secretsSpecifier, - bytesDecoder: bytesDecoder, - arraySeparator: arraySeparator - ) - } catch let error where error.isFileNotFoundError { - throw ProviderError.environmentFileNotFound(path: environmentFilePath) + try await self.init( + environmentFilePath: environmentFilePath, + allowMissing: allowMissing, + fileSystem: LocalCommonProviderFileSystem(), + secretsSpecifier: secretsSpecifier, + bytesDecoder: bytesDecoder, + arraySeparator: arraySeparator + ) + } + + /// Creates a new provider that reads from an environment file. + /// - Parameters: + /// - environmentFilePath: The file system path to the environment file to load. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + /// - fileSystem: The file system implementation to use. + /// - secretsSpecifier: Specifies which environment variables should be treated as secrets. + /// - bytesDecoder: The decoder used for converting string values to byte arrays. + /// - arraySeparator: The character used to separate elements in array values. + /// - Throws: If the file is malformed, or if missing when allowMissing is `false`. + internal init( + environmentFilePath: FilePath, + allowMissing: Bool, + fileSystem: some CommonProviderFileSystem, + secretsSpecifier: SecretsSpecifier = .none, + bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + arraySeparator: Character = "," + ) async throws { + let loadedData = try await fileSystem.fileContents(atPath: environmentFilePath) + let data: Data + if let loadedData { + data = loadedData + } else if allowMissing { + data = Data() + } else { + throw FileSystemError.fileNotFound(path: environmentFilePath) } + let contents = String(decoding: data, as: UTF8.self) + self.init( + environmentVariables: EnvironmentFileParser.parsed(contents), + secretsSpecifier: secretsSpecifier, + bytesDecoder: bytesDecoder, + arraySeparator: arraySeparator + ) } /// Returns the raw string value for a specific environment variable name. @@ -317,23 +339,6 @@ internal struct EnvironmentValueArrayDecoder { } } -extension Error { - /// Inspects whether the error represents a file not found. - internal var isFileNotFoundError: Bool { - if let posixError = self as? POSIXError { - return posixError.code == POSIXError.Code.ENOENT - } - if let cocoaError = self as? CocoaError, cocoaError.isFileError { - return [ - CocoaError.fileNoSuchFile, - CocoaError.fileReadNoSuchFile, - ] - .contains(cocoaError.code) - } - return false - } -} - @available(Configuration 1.0, *) extension EnvironmentVariablesProvider: CustomStringConvertible { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation diff --git a/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift index 6d95709..0970552 100644 --- a/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift +++ b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift @@ -24,68 +24,95 @@ package import SystemPackage package protocol CommonProviderFileSystem: Sendable { /// Loads the file contents at the specified file path. /// - Parameter filePath: The path to the file. - /// - Returns: The byte contents of the file. + /// - Returns: The byte contents of the file. Nil if file is missing. /// - Throws: When the file cannot be read. - func fileContents(atPath filePath: FilePath) async throws -> Data + func fileContents(atPath filePath: FilePath) async throws -> Data? /// Reads the last modified timestamp of the file, if it exists. /// - Parameter filePath: The file path to check. - /// - Returns: The last modified timestamp, if found. Nil if the file is not found. + /// - Returns: The last modified timestamp. Nil if file is missing. /// - Throws: When any other attribute reading error occurs. - func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date + func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date? /// Lists all regular file names in the specified directory. /// - Parameter directoryPath: The path to the directory. - /// - Returns: An array of file names in the directory. + /// - Returns: An array of file names in the directory. Nil if file is missing. /// - Throws: When the directory cannot be read or doesn't exist. - func listFileNames(atPath directoryPath: FilePath) async throws -> [String] + func listFileNames(atPath directoryPath: FilePath) async throws -> [String]? /// Resolves symlinks and returns the real file path. /// /// If the provided path is not a symlink, returns the same unmodified path. /// - Parameter filePath: The file path that may contain symlinks. - /// - Returns: The resolved file path with symlinks resolved. + /// - Returns: The resolved file path with symlinks resolved. Nil if file is missing. /// - Throws: When the path cannot be resolved. - func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath + func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath? +} + +/// The error thrown by the file system. +package enum FileSystemError: Error, CustomStringConvertible { + /// The directory was not found at the provided path. + case directoryNotFound(path: FilePath) + + /// The file was not found at the provided path. + case fileNotFound(path: FilePath) + + /// Failed to read a file in the directory. + case fileReadError(filePath: FilePath, underlyingError: any Error) + + /// Failed to read a file in the directory. + case missingLastModifiedTimestampAttribute(filePath: FilePath) + + /// The path exists but is not a directory. + case notADirectory(path: FilePath) + + package var description: String { + switch self { + case .directoryNotFound(let path): + return "Directory not found at path: \(path)." + case .fileNotFound(let path): + return "File not found at path: \(path)." + case .fileReadError(let filePath, let error): + return "Failed to read file '\(filePath)': \(error)." + case .missingLastModifiedTimestampAttribute(let filePath): + return "Missing last modified timestamp attribute for file '\(filePath)." + case .notADirectory(let path): + return "Path exists but is not a directory: \(path)." + } + } } /// A file system implementation that uses the local file system. @available(Configuration 1.0, *) -package struct LocalCommonProviderFileSystem: Sendable { - /// The error thrown by the file system. - package enum FileSystemError: Error, CustomStringConvertible { - /// The directory was not found at the provided path. - case directoryNotFound(path: FilePath) - - /// Failed to read a file in the directory. - case fileReadError(filePath: FilePath, underlyingError: any Error) - - /// Failed to read a file in the directory. - case missingLastModifiedTimestampAttribute(filePath: FilePath) - - /// The path exists but is not a directory. - case notADirectory(path: FilePath) - - package var description: String { - switch self { - case .directoryNotFound(let path): - return "Directory not found at path: \(path)." - case .fileReadError(let filePath, let error): - return "Failed to read file '\(filePath)': \(error)." - case .missingLastModifiedTimestampAttribute(let filePath): - return "Missing last modified timestamp attribute for file '\(filePath)." - case .notADirectory(let path): - return "Path exists but is not a directory: \(path)." - } +package struct LocalCommonProviderFileSystem: Sendable {} + +/// A utility function that wraps an async throwing operation and returns `nil` if the operation +/// fails with a file not found error, otherwise returns the result or rethrows other errors. +/// +/// - Parameter body: A body closure that performs the file system operation. +/// - Returns: The result of the operation, or `nil` if the file was not found. +/// - Throws: Any error from the operation except file not found errors. +private func returnNilIfMissing( + _ body: () async throws -> Return +) async throws -> Return? { + do { + return try await body() + } catch { + if error.isFileNotFoundError { + return nil } + throw error } } @available(Configuration 1.0, *) extension LocalCommonProviderFileSystem: CommonProviderFileSystem { - package func fileContents(atPath filePath: FilePath) async throws -> Data { + + package func fileContents(atPath filePath: FilePath) async throws -> Data? { do { - return try Data(contentsOf: URL(filePath: filePath.string)) + return try await returnNilIfMissing { + try Data(contentsOf: URL(filePath: filePath.string)) + } } catch { throw FileSystemError.fileReadError( filePath: filePath, @@ -94,17 +121,19 @@ extension LocalCommonProviderFileSystem: CommonProviderFileSystem { } } - package func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date { - guard - let timestamp = try FileManager().attributesOfItem(atPath: filePath.string)[.modificationDate] - as? Date - else { - throw FileSystemError.missingLastModifiedTimestampAttribute(filePath: filePath) + package func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date? { + try await returnNilIfMissing { + guard + let timestamp = try FileManager().attributesOfItem(atPath: filePath.string)[.modificationDate] + as? Date + else { + throw FileSystemError.missingLastModifiedTimestampAttribute(filePath: filePath) + } + return timestamp } - return timestamp } - package func listFileNames(atPath directoryPath: FilePath) async throws -> [String] { + package func listFileNames(atPath directoryPath: FilePath) async throws -> [String]? { let fileManager = FileManager.default #if canImport(Darwin) var isDirectoryWrapper: ObjCBool = false @@ -112,7 +141,7 @@ extension LocalCommonProviderFileSystem: CommonProviderFileSystem { var isDirectoryWrapper: Bool = false #endif guard fileManager.fileExists(atPath: directoryPath.string, isDirectory: &isDirectoryWrapper) else { - throw FileSystemError.directoryNotFound(path: directoryPath) + return nil } #if canImport(Darwin) let isDirectory = isDirectoryWrapper.boolValue @@ -138,7 +167,9 @@ extension LocalCommonProviderFileSystem: CommonProviderFileSystem { } } - package func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath { - FilePath(URL(filePath: filePath.string).resolvingSymlinksInPath().path()) + package func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath? { + try await returnNilIfMissing { + FilePath(URL(filePath: filePath.string).resolvingSymlinksInPath().path()) + } } } diff --git a/Sources/Configuration/Providers/Files/DirectoryFilesProvider.swift b/Sources/Configuration/Providers/Files/DirectoryFilesProvider.swift index 367fe94..63208b1 100644 --- a/Sources/Configuration/Providers/Files/DirectoryFilesProvider.swift +++ b/Sources/Configuration/Providers/Files/DirectoryFilesProvider.swift @@ -173,18 +173,23 @@ public struct DirectoryFilesProvider: Sendable { /// /// - Parameters: /// - directoryPath: The file system path to the directory containing configuration files. + /// - allowMissing: A flag controlling how the provider handles a missing directory. + /// - When `false`, if the directory is missing, throws an error. + /// - When `true`, if the directory is missing, treats it as empty. /// - secretsSpecifier: Specifies which values should be treated as secrets. /// - arraySeparator: The character used to separate elements in array values. /// - keyEncoder: The encoder to use for converting configuration keys to file names. /// - Throws: If the directory cannot be found or read. public init( directoryPath: FilePath, + allowMissing: Bool = false, secretsSpecifier: SecretsSpecifier = .all, arraySeparator: Character = ",", keyEncoder: some ConfigKeyEncoder = .directoryFiles ) async throws { try await self.init( directoryPath: directoryPath, + allowMissing: allowMissing, fileSystem: LocalCommonProviderFileSystem(), secretsSpecifier: secretsSpecifier, arraySeparator: arraySeparator, @@ -199,6 +204,9 @@ public struct DirectoryFilesProvider: Sendable { /// /// - Parameters: /// - directoryPath: The file system path to the directory containing configuration files. + /// - allowMissing: A flag controlling how the provider handles a missing directory. + /// - When `false`, if the directory is missing, throws an error. + /// - When `true`, if the directory is missing, treats it as empty. /// - fileSystem: The file system implementation to use. /// - secretsSpecifier: Specifies which values should be treated as secrets. Defaults to `.all`. /// - arraySeparator: The character used to separate elements in array values. Defaults to comma. @@ -206,6 +214,7 @@ public struct DirectoryFilesProvider: Sendable { /// - Throws: If the directory cannot be found or read. internal init( directoryPath: FilePath, + allowMissing: Bool, fileSystem: some CommonProviderFileSystem, secretsSpecifier: SecretsSpecifier = .all, arraySeparator: Character = ",", @@ -213,6 +222,7 @@ public struct DirectoryFilesProvider: Sendable { ) async throws { let fileValues = try await Self.loadDirectory( at: directoryPath, + allowMissing: allowMissing, fileSystem: fileSystem, secretsSpecifier: secretsSpecifier ) @@ -231,20 +241,36 @@ public struct DirectoryFilesProvider: Sendable { /// /// - Parameters: /// - directoryPath: The path to the directory to load files from. + /// - allowMissing: A flag controlling how the provider handles a missing directory. + /// - When `false`, if the directory is missing, throws an error. + /// - When `true`, if the directory is missing, treats it as empty. /// - fileSystem: The file system implementation to use. /// - secretsSpecifier: Specifies which values should be treated as secrets. /// - Returns: A dictionary of file values keyed by file name. /// - Throws: If the directory cannot be found or read, or any file cannot be read. private static func loadDirectory( at directoryPath: FilePath, + allowMissing: Bool, fileSystem: some CommonProviderFileSystem, secretsSpecifier: SecretsSpecifier ) async throws -> [String: FileValue] { - let fileNames = try await fileSystem.listFileNames(atPath: directoryPath) + let loadedFileNames = try await fileSystem.listFileNames(atPath: directoryPath) + let fileNames: [String] + if let loadedFileNames { + fileNames = loadedFileNames + } else if allowMissing { + fileNames = [] + } else { + throw FileSystemError.directoryNotFound(path: directoryPath) + } var fileValues: [String: FileValue] = [:] for fileName in fileNames { let filePath = directoryPath.appending(fileName) - let data = try await fileSystem.fileContents(atPath: filePath) + guard let data = try await fileSystem.fileContents(atPath: filePath) else { + // File disappeared since the last call, that's okay as no individual + // file in a DirectoryFilesProvider is required. Just skip it. + continue + } let isSecret = secretsSpecifier.isSecret(key: fileName, value: data) fileValues[fileName] = .init(data: data, isSecret: isSecret) } diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index eafaafb..19da732 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -61,7 +61,7 @@ import Foundation public struct FileProvider: Sendable { /// A snapshot of the internal state. - private let _snapshot: Snapshot + private let _snapshot: any ConfigSnapshot & CustomStringConvertible & CustomDebugStringConvertible /// Creates a file provider that reads from the specified file path. /// @@ -72,16 +72,22 @@ public struct FileProvider: Sendable { /// - 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. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + /// - Throws: If snapshot creation fails or if the file is malformed. Whether an error is throws + /// when the file is missing is controlled by the `allowMissing` parameter. public init( snapshotType: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions = .default, - filePath: FilePath + filePath: FilePath, + allowMissing: Bool = false ) async throws { try await self.init( snapshotType: snapshotType, parsingOptions: parsingOptions, filePath: filePath, + allowMissing: allowMissing, fileSystem: LocalCommonProviderFileSystem() ) } @@ -93,12 +99,17 @@ public struct FileProvider: Sendable { /// /// ## Configuration keys /// - `filePath` (string, required): The path to the configuration file to read. + /// - `allowMissing` (bool, optional, default: false): A flag controlling how + /// the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. /// /// - 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. + /// - Throws: If snapshot creation fails or if the file is malformed. Whether an error is throws + /// when the file is missing is controlled by the `allowMissing` configuration value. public init( snapshotType: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions = .default, @@ -107,7 +118,42 @@ public struct FileProvider: Sendable { try await self.init( snapshotType: snapshotType, parsingOptions: parsingOptions, - filePath: config.requiredString(forKey: "filePath", as: FilePath.self) + filePath: config.requiredString(forKey: "filePath", as: FilePath.self), + allowMissing: config.bool(forKey: "allowMissing", default: false) + ) + } + + /// 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. + /// - `allowMissing` (bool, optional, default: false): A flag controlling how + /// the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + /// + /// - 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. + /// - fileSystem: The file system implementation to use for reading the file. + /// - Throws: If snapshot creation fails or if the file is malformed. Whether an error is throws + /// when the file is missing is controlled by the `allowMissing` configuration value. + internal init( + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions = .default, + config: ConfigReader, + fileSystem: some CommonProviderFileSystem + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + filePath: config.requiredString(forKey: "filePath", as: FilePath.self), + allowMissing: config.bool(forKey: "allowMissing", default: false), + fileSystem: fileSystem ) } @@ -120,20 +166,31 @@ public struct FileProvider: Sendable { /// - 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. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. /// - 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: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, + allowMissing: Bool, fileSystem: some CommonProviderFileSystem ) async throws { let fileContents = try await fileSystem.fileContents(atPath: filePath) - self._snapshot = try snapshotType.init( - data: fileContents.bytes, - providerName: "FileProvider<\(Snapshot.self)>", - parsingOptions: parsingOptions - ) + let providerName = "FileProvider<\(Snapshot.self)>" + if let fileContents { + self._snapshot = try snapshotType.init( + data: fileContents.bytes, + providerName: providerName, + parsingOptions: parsingOptions + ) + } else if allowMissing { + self._snapshot = EmptyFileConfigSnapshot(providerName: providerName) + } else { + throw FileSystemError.fileNotFound(path: filePath) + } } } diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift index 4ac421c..e77a554 100644 --- a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -93,3 +93,29 @@ public protocol FileConfigSnapshot: ConfigSnapshot, CustomStringConvertible, /// - Throws: If the file data cannot be parsed or contains invalid configuration. init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws } + +@available(Configuration 1.0, *) +internal struct EmptyFileConfigSnapshot: Sendable { + var providerName: String +} + +@available(Configuration 1.0, *) +extension EmptyFileConfigSnapshot: ConfigSnapshot { + func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + .init(encodedKey: key.description, value: nil) + } +} + +@available(Configuration 1.0, *) +extension EmptyFileConfigSnapshot: CustomStringConvertible { + var description: String { + "\(providerName)[empty]" + } +} + +@available(Configuration 1.0, *) +extension EmptyFileConfigSnapshot: CustomDebugStringConvertible { + var debugDescription: String { + description + } +} diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index e0da725..8ce43ef 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -85,23 +85,32 @@ import Synchronization @available(Configuration 1.0, *) public final class ReloadingFileProvider: Sendable { + fileprivate typealias AnySnapshot = any ConfigSnapshot & CustomStringConvertible & CustomDebugStringConvertible + /// The internal storage structure for the provider state. - private struct Storage { + fileprivate struct Storage { /// The current configuration snapshot. - var snapshot: Snapshot + var snapshot: AnySnapshot + + /// The source of file contents. + enum Source: Equatable { - /// Last modified timestamp of the resolved file. - var lastModifiedTimestamp: Date + /// A file loaded at the timestamp located at the real path. + case file(lastModifiedTimestamp: Date, realFilePath: FilePath) - /// The resolved real file path. - var realFilePath: FilePath + /// File not found. + case missing + } + + /// The source of file contents. + var source: Source /// Active watchers for individual configuration values, keyed by encoded key. var valueWatchers: [AbsoluteConfigKey: [UUID: AsyncStream>.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 { @@ -123,6 +132,11 @@ public final class ReloadingFileProvider: Sendable /// The original unresolved file path provided by the user, may contain symlinks. private let filePath: FilePath + /// A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + private let allowMissing: Bool + /// The interval between polling checks. private let pollInterval: Duration @@ -135,10 +149,25 @@ public final class ReloadingFileProvider: Sendable /// The metrics collector for this provider instance. private let metrics: ReloadingFileProviderMetrics + /// Creates a reloading file provider that monitors the specified file path. + /// + /// - 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 monitor. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + /// - pollInterval: How often to check for file changes. + /// - fileSystem: The file system implementation to use for reading the file. + /// - logger: The logger instance to use for this provider. + /// - metrics: The metrics factory to use for monitoring provider performance. + /// - Throws: If the file cannot be read or if snapshot creation fails. internal init( snapshotType: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, + allowMissing: Bool, pollInterval: Duration, fileSystem: any CommonProviderFileSystem, logger: Logger, @@ -146,6 +175,7 @@ public final class ReloadingFileProvider: Sendable ) async throws { self.parsingOptions = parsingOptions self.filePath = filePath + self.allowMissing = allowMissing self.pollInterval = pollInterval self.providerName = "ReloadingFileProvider<\(Snapshot.self)>" self.fileSystem = fileSystem @@ -153,6 +183,7 @@ public final class ReloadingFileProvider: Sendable // Set up the logger with metadata var logger = logger logger[metadataKey: "\(providerName).filePath"] = .string(filePath.lastComponent?.string ?? "") + logger[metadataKey: "\(providerName).allowMissing"] = "\(allowMissing)" logger[metadataKey: "\(providerName).pollInterval.seconds"] = .string( pollInterval.components.seconds.description ) @@ -166,37 +197,56 @@ public final class ReloadingFileProvider: Sendable // Perform initial load logger.debug("Performing initial file load") - let realPath = try await fileSystem.resolveSymlinks(atPath: filePath) - let timestamp = try await fileSystem.lastModifiedTimestamp(atPath: realPath) - let data = try await fileSystem.fileContents(atPath: realPath) - let initialSnapshot = try snapshotType.init( - data: data.bytes, - providerName: providerName, - parsingOptions: parsingOptions - ) + let initialSnapshot: AnySnapshot + let source: Storage.Source + let dataSize: Int + if let realPath = try await fileSystem.resolveSymlinks(atPath: filePath), + let timestamp = try await fileSystem.lastModifiedTimestamp(atPath: realPath), + let data = try await fileSystem.fileContents(atPath: realPath) + { + initialSnapshot = try snapshotType.init( + data: data.bytes, + providerName: providerName, + parsingOptions: parsingOptions + ) + source = .file( + lastModifiedTimestamp: timestamp, + realFilePath: realPath + ) + dataSize = data.count + + logger.debug( + "Successfully initialized reloading file provider", + metadata: [ + "\(providerName).realFilePath": .string(realPath.string), + "\(providerName).initialTimestamp": .stringConvertible(timestamp.formatted(.iso8601)), + "\(providerName).fileSize": .stringConvertible(data.count), + ] + ) + } else if allowMissing { + initialSnapshot = EmptyFileConfigSnapshot(providerName: providerName) + source = .missing + dataSize = 0 + + logger.debug( + "Successfully initialized reloading file provider from a missing file" + ) + } else { + throw FileSystemError.fileNotFound(path: filePath) + } // Initialize storage self.storage = .init( .init( snapshot: initialSnapshot, - lastModifiedTimestamp: timestamp, - realFilePath: realPath, + source: source, valueWatchers: [:], snapshotWatchers: [:] ) ) // Update initial metrics - self.metrics.fileSize.record(data.count) - - logger.debug( - "Successfully initialized reloading file provider", - metadata: [ - "\(providerName).realFilePath": .string(realPath.string), - "\(providerName).initialTimestamp": .stringConvertible(timestamp.formatted(.iso8601)), - "\(providerName).fileSize": .stringConvertible(data.count), - ] - ) + self.metrics.fileSize.record(dataSize) } /// Creates a reloading file provider that monitors the specified file path. @@ -205,6 +255,9 @@ public final class ReloadingFileProvider: Sendable /// - 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 monitor. + /// - allowMissing: A flag controlling how the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. /// - pollInterval: How often to check for file changes. /// - logger: The logger instance to use for this provider. /// - metrics: The metrics factory to use for monitoring provider performance. @@ -213,6 +266,7 @@ public final class ReloadingFileProvider: Sendable snapshotType: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions = .default, filePath: FilePath, + allowMissing: Bool = false, pollInterval: Duration = .seconds(15), logger: Logger = Logger(label: "ReloadingFileProvider"), metrics: any MetricsFactory = MetricsSystem.factory @@ -221,6 +275,7 @@ public final class ReloadingFileProvider: Sendable snapshotType: snapshotType, parsingOptions: parsingOptions, filePath: filePath, + allowMissing: allowMissing, pollInterval: pollInterval, fileSystem: LocalCommonProviderFileSystem(), logger: logger, @@ -232,6 +287,10 @@ public final class ReloadingFileProvider: Sendable /// /// ## Configuration keys /// - `filePath` (string, required): The path to the configuration file to monitor. + /// - `allowMissing` (bool, optional, default: false): A flag controlling how + /// the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. /// - `pollIntervalSeconds` (int, optional, default: 15): How often to check for file changes in seconds. /// /// - Parameters: @@ -247,13 +306,50 @@ public final class ReloadingFileProvider: Sendable config: ConfigReader, logger: Logger = Logger(label: "ReloadingFileProvider"), metrics: any MetricsFactory = MetricsSystem.factory + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + config: config, + fileSystem: LocalCommonProviderFileSystem(), + logger: logger, + metrics: metrics + ) + } + + /// Creates a reloading file provider using configuration from a reader. + /// + /// ## Configuration keys + /// - `filePath` (string, required): The path to the configuration file to monitor. + /// - `allowMissing` (bool, optional, default: false): A flag controlling how + /// the provider handles a missing file. + /// - When `false` (the default), if the file is missing or malformed, throws an error. + /// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + /// - `pollIntervalSeconds` (int, optional, default: 15): How often to check for file changes in seconds. + /// + /// - 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. + /// - fileSystem: The file system implementation to use for reading the file. + /// - logger: The logger instance to use for this provider. + /// - metrics: The metrics factory to use for monitoring provider performance. + /// - Throws: If required configuration keys are missing, if the file cannot be read, or if snapshot creation fails. + internal convenience init( + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions, + config: ConfigReader, + fileSystem: some CommonProviderFileSystem, + logger: Logger, + metrics: any MetricsFactory ) async throws { try await self.init( snapshotType: snapshotType, parsingOptions: parsingOptions, filePath: config.requiredString(forKey: "filePath", as: FilePath.self), + allowMissing: config.bool(forKey: "allowMissing", default: false), pollInterval: .seconds(config.int(forKey: "pollIntervalSeconds", default: 15)), - fileSystem: LocalCommonProviderFileSystem(), + fileSystem: fileSystem, logger: logger, metrics: metrics ) @@ -274,86 +370,115 @@ public final class ReloadingFileProvider: Sendable logger.debug("reloadIfNeeded finished") } - let candidateRealPath = try await fileSystem.resolveSymlinks(atPath: filePath) - let candidateTimestamp = try await fileSystem.lastModifiedTimestamp(atPath: candidateRealPath) + let candidateSource: Storage.Source + if let candidateRealPath = try await fileSystem.resolveSymlinks(atPath: filePath), + let candidateTimestamp = try await fileSystem.lastModifiedTimestamp(atPath: candidateRealPath) + { + candidateSource = .file(lastModifiedTimestamp: candidateTimestamp, realFilePath: candidateRealPath) + } else { + candidateSource = .missing + } guard - let (originalTimestamp, originalRealPath) = + let originalSource = storage - .withLock({ storage -> (Date, FilePath)? in - let originalTimestamp = storage.lastModifiedTimestamp - let originalRealPath = storage.realFilePath + .withLock({ storage -> Storage.Source? in + let originalSource = storage.source - // Check if either the real path or timestamp has changed - guard originalRealPath != candidateRealPath || originalTimestamp != candidateTimestamp else { + // Check if the source has changed + guard originalSource != candidateSource else { logger.debug( - "File path and timestamp unchanged, no reload needed", - metadata: [ - "\(providerName).timestamp": .stringConvertible(originalTimestamp.formatted(.iso8601)), - "\(providerName).realPath": .string(originalRealPath.string), - ] + "File source unchanged, no reload needed", + metadata: originalSource.loggingMetadata(prefix: providerName) ) return nil } - return (originalTimestamp, originalRealPath) + return originalSource }) else { // No changes detected. return } + var summaryMetadata: Logger.Metadata = [:] + summaryMetadata.merge( + originalSource.loggingMetadata(prefix: "\(providerName).original"), + uniquingKeysWith: { a, b in a } + ) + summaryMetadata.merge( + candidateSource.loggingMetadata(prefix: "\(providerName).candidate"), + uniquingKeysWith: { a, b in a } + ) logger.debug( "File path or timestamp changed, reloading...", - metadata: [ - "\(providerName).originalTimestamp": .stringConvertible(originalTimestamp.formatted(.iso8601)), - "\(providerName).candidateTimestamp": .stringConvertible(candidateTimestamp.formatted(.iso8601)), - "\(providerName).originalRealPath": .string(originalRealPath.string), - "\(providerName).candidateRealPath": .string(candidateRealPath.string), - ] + metadata: summaryMetadata ) // Load new data outside the lock - let data = try await fileSystem.fileContents(atPath: candidateRealPath) - let newSnapshot = try Snapshot.init( - data: data.bytes, - providerName: providerName, - parsingOptions: parsingOptions - ) + let newSnapshot: AnySnapshot + let newFileSize: Int + switch candidateSource { + case .file(_, realFilePath: let realPath): + guard let data = try await fileSystem.fileContents(atPath: realPath) else { + // File was removed after checking metadata but before we loaded the data. + // This is fine, we just exit this reload early and let the next reload + // update internal state. + logger.debug("File removed half-way through a reload, not updating state") + return + } + newSnapshot = try Snapshot.init( + data: data.bytes, + providerName: providerName, + parsingOptions: parsingOptions + ) + newFileSize = data.count + case .missing: + guard allowMissing else { + // File was removed after initialization, but this provider doesn't allow + // a missing file. Keep the old snapshot and throw an error. + throw FileSystemError.fileNotFound(path: filePath) + } + newSnapshot = EmptyFileConfigSnapshot(providerName: providerName) + newFileSize = 0 + } typealias ValueWatchers = [( AbsoluteConfigKey, Result, [AsyncStream>.Continuation] )] - typealias SnapshotWatchers = (Snapshot, [AsyncStream.Continuation]) + typealias SnapshotWatchers = (AnySnapshot, [AsyncStream.Continuation]) guard let (valueWatchersToNotify, snapshotWatchersToNotify) = storage .withLock({ storage -> (ValueWatchers, SnapshotWatchers)? in // Check if we lost the race with another caller - if storage.lastModifiedTimestamp != originalTimestamp || storage.realFilePath != originalRealPath { + if storage.source != originalSource { + logger.debug("Lost race to reload file, not updating state") return nil } // Update storage with new data let oldSnapshot = storage.snapshot storage.snapshot = newSnapshot - storage.lastModifiedTimestamp = candidateTimestamp - storage.realFilePath = candidateRealPath + storage.source = candidateSource + var finalMetadata: Logger.Metadata = [ + "\(providerName).fileSize": .stringConvertible(newFileSize) + ] + finalMetadata.merge( + candidateSource.loggingMetadata(prefix: providerName), + uniquingKeysWith: { a, b in a } + ) logger.debug( "Successfully reloaded file", - metadata: [ - "\(providerName).timestamp": .stringConvertible(candidateTimestamp.formatted(.iso8601)), - "\(providerName).fileSize": .stringConvertible(data.count), - "\(providerName).realPath": .string(candidateRealPath.string), - ] + metadata: finalMetadata ) // Update metrics metrics.reloadCounter.increment(by: 1) - metrics.fileSize.record(data.count) + metrics.fileSize.record(newFileSize) metrics.watcherCount.record(storage.totalWatcherCount) // Collect watchers to potentially notify outside the lock @@ -424,6 +549,33 @@ public final class ReloadingFileProvider: Sendable } } +@available(Configuration 1.0, *) +extension ReloadingFileProvider.Storage.Source { + /// Creates logging metadata for the source. + /// - Parameter prefixString: A prefix to use in logging metadata keys. + /// - Returns: A dictionary of metadata. + fileprivate func loggingMetadata(prefix prefixString: String? = nil) -> Logger.Metadata { + let renderedPrefix: String + if let prefixString { + renderedPrefix = "\(prefixString)." + } else { + renderedPrefix = "" + } + switch self { + case .file(lastModifiedTimestamp: let timestamp, realFilePath: let realPath): + return [ + "\(renderedPrefix)fileExists": "true", + "\(renderedPrefix)timestamp": .stringConvertible(timestamp.formatted(.iso8601)), + "\(renderedPrefix)realPath": .string(realPath.string), + ] + case .missing: + return [ + "\(renderedPrefix)fileExists": "false" + ] + } + } +} + @available(Configuration 1.0, *) extension ReloadingFileProvider: CustomStringConvertible { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation @@ -497,7 +649,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/Utilities/FoundationExtensions.swift b/Sources/Configuration/Utilities/FoundationExtensions.swift index 25d92e8..51c905b 100644 --- a/Sources/Configuration/Utilities/FoundationExtensions.swift +++ b/Sources/Configuration/Utilities/FoundationExtensions.swift @@ -13,6 +13,11 @@ //===----------------------------------------------------------------------===// import SystemPackage +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif /// Prints a string to the standard error stream. /// @@ -30,3 +35,20 @@ extension StringProtocol { String(trimmingPrefix(while: \.isWhitespace).reversed().trimmingPrefix(while: \.isWhitespace).reversed()) } } + +extension Error { + /// Inspects whether the error represents a file not found. + internal var isFileNotFoundError: Bool { + if let posixError = self as? POSIXError { + return posixError.code == POSIXError.Code.ENOENT + } + if let cocoaError = self as? CocoaError, cocoaError.isFileError { + return [ + CocoaError.fileNoSuchFile, + CocoaError.fileReadNoSuchFile, + ] + .contains(cocoaError.code) + } + return false + } +} diff --git a/Sources/ConfigurationTestingInternal/InMemoryFileSystem.swift b/Sources/ConfigurationTestingInternal/InMemoryFileSystem.swift index d02a840..214b3fd 100644 --- a/Sources/ConfigurationTestingInternal/InMemoryFileSystem.swift +++ b/Sources/ConfigurationTestingInternal/InMemoryFileSystem.swift @@ -100,9 +100,9 @@ package final class InMemoryFileSystem: Sendable { @available(Configuration 1.0, *) extension InMemoryFileSystem: CommonProviderFileSystem { - func listFileNames(atPath directoryPath: FilePath) async throws -> [String] { + func listFileNames(atPath directoryPath: FilePath) async throws -> [String]? { let prefixComponents = directoryPath.components - return files.withLock { files in + let matchingFiles = files.withLock { files in files .filter { (filePath, _) in let components = filePath.components @@ -113,29 +113,28 @@ extension InMemoryFileSystem: CommonProviderFileSystem { } .compactMap { $0.key.lastComponent?.string } } + if matchingFiles.isEmpty { + return nil + } + return matchingFiles } - func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date { - try files.withLock { files in + func lastModifiedTimestamp(atPath filePath: FilePath) async throws -> Date? { + files.withLock { files in guard let data = files[filePath] else { - throw LocalCommonProviderFileSystem.FileSystemError.fileReadError( - filePath: filePath, - underlyingError: TestError.fileNotFound(filePath: filePath) - ) + return nil } return data.lastModifiedTimestamp } } - func fileContents(atPath filePath: FilePath) async throws -> Data { - let data = try files.withLock { files in - guard let data = files[filePath] else { - throw LocalCommonProviderFileSystem.FileSystemError.fileReadError( - filePath: filePath, - underlyingError: TestError.fileNotFound(filePath: filePath) - ) - } - return data + func fileContents(atPath filePath: FilePath) async throws -> Data? { + guard + let data = files.withLock({ files -> FileInfo? in + files[filePath] + }) + else { + return nil } switch data.data { case .file(let data): @@ -145,13 +144,10 @@ extension InMemoryFileSystem: CommonProviderFileSystem { } } - func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath { - func locked_resolveSymlinks(at filePath: FilePath, files: inout [FilePath: FileInfo]) throws -> FilePath { + func resolveSymlinks(atPath filePath: FilePath) async throws -> FilePath? { + func locked_resolveSymlinks(at filePath: FilePath, files: inout [FilePath: FileInfo]) throws -> FilePath? { guard let data = files[filePath] else { - throw LocalCommonProviderFileSystem.FileSystemError.fileReadError( - filePath: filePath, - underlyingError: TestError.fileNotFound(filePath: filePath) - ) + return nil } switch data.data { case .file: diff --git a/Tests/ConfigurationTests/DirectoryFilesProviderTests.swift b/Tests/ConfigurationTests/DirectoryFilesProviderTests.swift index 7e05543..799314f 100644 --- a/Tests/ConfigurationTests/DirectoryFilesProviderTests.swift +++ b/Tests/ConfigurationTests/DirectoryFilesProviderTests.swift @@ -65,6 +65,7 @@ struct DirectoryFilesProviderTests { let fileSystem = InMemoryFileSystem(files: Self.testFiles) let provider = try await DirectoryFilesProvider( directoryPath: "/test", + allowMissing: false, fileSystem: fileSystem ) @@ -76,6 +77,7 @@ struct DirectoryFilesProviderTests { let fileSystem = InMemoryFileSystem(files: Self.testFiles) let provider = try await DirectoryFilesProvider( directoryPath: "/test", + allowMissing: false, fileSystem: fileSystem, secretsSpecifier: .specific(["database-password"]) ) @@ -91,6 +93,7 @@ struct DirectoryFilesProviderTests { let fileSystem = InMemoryFileSystem(files: Self.testFiles) let provider = try await DirectoryFilesProvider( directoryPath: "/test", + allowMissing: false, fileSystem: fileSystem ) try await ProviderCompatTest( @@ -102,4 +105,31 @@ struct DirectoryFilesProviderTests { ) .runTest() } + + @available(Configuration 1.0, *) + @Test func missingDirectoryMissingError() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) + let error = await #expect(throws: FileSystemError.self) { + _ = try await DirectoryFilesProvider( + directoryPath: "/test", + allowMissing: false, + fileSystem: fileSystem + ) + } + guard case .directoryNotFound(let filePath) = error else { + Issue.record("Incorrect error thrown: \(error)") + return + } + #expect(filePath == "/test") + } + + @available(Configuration 1.0, *) + @Test func missingDirectoryAllowedMissing() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) + _ = try await DirectoryFilesProvider( + directoryPath: "/test", + allowMissing: true, + fileSystem: fileSystem + ) + } } diff --git a/Tests/ConfigurationTests/EnvironmentVariablesProviderTests.swift b/Tests/ConfigurationTests/EnvironmentVariablesProviderTests.swift index 6c057d3..ab72485 100644 --- a/Tests/ConfigurationTests/EnvironmentVariablesProviderTests.swift +++ b/Tests/ConfigurationTests/EnvironmentVariablesProviderTests.swift @@ -110,9 +110,20 @@ struct EnvironmentVariablesProviderTests { @available(Configuration 1.0, *) @Test func loadEnvironmentFile() async throws { - let envFilePath = try #require(Bundle.module.path(forResource: "Resources", ofType: nil)?.appending("/.env")) + let fileSystem = InMemoryFileSystem(files: [ + "/etc/.env": .file( + timestamp: .now, + contents: """ + HTTP_SECRET=s3cret + ENABLED=true + + """ + ) + ]) let provider = try await EnvironmentVariablesProvider( - environmentFilePath: FilePath(envFilePath), + environmentFilePath: "/etc/.env", + allowMissing: false, + fileSystem: fileSystem, secretsSpecifier: .specific([ "HTTP_SECRET" ]) @@ -123,24 +134,38 @@ struct EnvironmentVariablesProviderTests { } @available(Configuration 1.0, *) - @Test func loadEnvironmentFileError() async throws { + @Test func loadEnvironmentMissingError() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) let envFilePath: FilePath = "/tmp/definitelyNotAnEnvFile" do { _ = try await EnvironmentVariablesProvider( environmentFilePath: envFilePath, + allowMissing: false, + fileSystem: fileSystem, secretsSpecifier: .specific([ "HTTP_SECRET" ]) ) #expect(Bool(false), "Initializer should have thrown an error") - } catch let error as EnvironmentVariablesProvider.ProviderError { - guard case .environmentFileNotFound(path: let path) = error else { + } catch let error as FileSystemError { + guard case .fileNotFound(path: let path) = error else { #expect(Bool(false), "Initializer should have thrown an error") return } #expect(path == "/tmp/definitelyNotAnEnvFile") } } + + @available(Configuration 1.0, *) + @Test func loadEnvironmentAllowedMissing() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) + let envFilePath: FilePath = "/tmp/definitelyNotAnEnvFile" + _ = try await EnvironmentVariablesProvider( + environmentFilePath: envFilePath, + allowMissing: true, + fileSystem: fileSystem + ) + } } struct EnvironmentKeyEncoderTests { diff --git a/Tests/ConfigurationTests/FileProvider.swift b/Tests/ConfigurationTests/FileProvider.swift deleted file mode 100644 index 58045fc..0000000 --- a/Tests/ConfigurationTests/FileProvider.swift +++ /dev/null @@ -1,54 +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 -// -//===----------------------------------------------------------------------===// - -import Testing -import ConfigurationTestingInternal -@testable import Configuration -import Foundation -import ConfigurationTesting -import Logging -import Metrics -import ServiceLifecycle -import Synchronization -import SystemPackage - -@available(Configuration 1.0, *) -private func withTestProvider( - body: ( - FileProvider, - InMemoryFileSystem, - FilePath, - Date - ) async throws -> R -) async throws -> R { - try await withTestFileSystem { fileSystem, filePath, originalTimestamp in - let provider = try await FileProvider( - parsingOptions: .default, - filePath: filePath, - fileSystem: fileSystem - ) - return try await body(provider, fileSystem, filePath, originalTimestamp) - } -} - -struct FileProviderTests { - @available(Configuration 1.0, *) - @Test func testLoad() async throws { - try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in - // Check initial values - let result1 = try provider.value(forKey: ["key1"], type: .string) - #expect(try result1.value?.content.asString == "value1") - } - } -} diff --git a/Tests/ConfigurationTests/FileProviderTests.swift b/Tests/ConfigurationTests/FileProviderTests.swift new file mode 100644 index 0000000..d5b599a --- /dev/null +++ b/Tests/ConfigurationTests/FileProviderTests.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing +import ConfigurationTestingInternal +@testable import Configuration +import Foundation +import ConfigurationTesting +import Logging +import ServiceLifecycle +import Synchronization +import SystemPackage + +@available(Configuration 1.0, *) +private func withTestProvider( + body: ( + FileProvider, + InMemoryFileSystem, + FilePath, + Date + ) async throws -> R +) async throws -> R { + try await withTestFileSystem { fileSystem, filePath, originalTimestamp in + let provider = try await FileProvider( + parsingOptions: .default, + filePath: filePath, + allowMissing: false, + fileSystem: fileSystem + ) + return try await body(provider, fileSystem, filePath, originalTimestamp) + } +} + +struct FileProviderTests { + @available(Configuration 1.0, *) + @Test func testLoad() async throws { + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in + // Check initial values + let result1 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result1.value?.content.asString == "value1") + } + } + + @available(Configuration 1.0, *) + @Test func missingFileMissingError() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) + let error = await #expect(throws: FileSystemError.self) { + _ = try await FileProvider( + parsingOptions: .default, + filePath: "/etc/config.txt", + allowMissing: false, + fileSystem: fileSystem + ) + } + guard case .fileNotFound(let filePath) = error else { + Issue.record("Incorrect error thrown: \(error)") + return + } + #expect(filePath == "/etc/config.txt") + } + + @available(Configuration 1.0, *) + @Test func missingFileAllowMissing() async throws { + let fileSystem = InMemoryFileSystem(files: [:]) + _ = try await FileProvider( + parsingOptions: .default, + filePath: "/etc/config.txt", + allowMissing: true, + fileSystem: fileSystem + ) + } + + @available(Configuration 1.0, *) + @Test func configSuccess() async throws { + // Test initialization using config reader + let envProvider = InMemoryProvider(values: [ + "filePath": "/test/config.txt" + ]) + let config = ConfigReader(provider: envProvider) + + try await withTestFileSystem { fileSystem, filePath, _ in + let fileProvider = try await FileProvider( + config: config, + fileSystem: fileSystem + ) + #expect(fileProvider.providerName == "FileProvider") + #expect(fileProvider.description == "TestSnapshot") + } + } +} diff --git a/Tests/ConfigurationTests/JSONFileProviderTests.swift b/Tests/ConfigurationTests/JSONFileProviderTests.swift index 4e0f410..3e657fb 100644 --- a/Tests/ConfigurationTests/JSONFileProviderTests.swift +++ b/Tests/ConfigurationTests/JSONFileProviderTests.swift @@ -21,8 +21,88 @@ import Foundation import ConfigurationTesting import SystemPackage -private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) -let jsonConfigFile = resourcesPath.appending("/config.json") +let jsonTestFileContents = """ + { + "string": "Hello", + "int": 42, + "double": 3.14, + "bool": true, + "bytes": "bWFnaWM=", + + "other": { + "string": "Other Hello", + "int": 24, + "double": 2.72, + "bool": false, + "bytes": "bWFnaWMy", + + "stringy": { + "array": [ + "Hello", + "Swift" + ] + }, + "inty": { + "array": [ + 16, + 32 + ] + }, + "doubly": { + "array": [ + 0.9, + 1.8 + ] + }, + "booly": { + "array": [ + false, + true, + true + ] + }, + "byteChunky": { + "array": [ + "bWFnaWM=", + "bWFnaWMy", + "bWFnaWM=" + ] + } + }, + + "stringy": { + "array": [ + "Hello", + "World" + ] + }, + "inty": { + "array": [ + 42, + 24 + ] + }, + "doubly": { + "array": [ + 3.14, + 2.72 + ] + }, + "booly": { + "array": [ + true, + false + ] + }, + "byteChunky": { + "array": [ + "bWFnaWM=", + "bWFnaWMy" + ] + } + } + + """ struct JSONFileProviderTests { @@ -30,7 +110,7 @@ struct JSONFileProviderTests { var provider: JSONSnapshot { get throws { try JSONSnapshot( - data: Data(contentsOf: URL(filePath: jsonConfigFile.string)).bytes, + data: Data(jsonTestFileContents.utf8).bytes, providerName: "TestProvider", parsingOptions: .default ) @@ -55,8 +135,16 @@ struct JSONFileProviderTests { @available(Configuration 1.0, *) @Test func compat() async throws { + let fileSystem = InMemoryFileSystem(files: [ + "/etc/config.json": .file(timestamp: .now, contents: jsonTestFileContents) + ]) try await ProviderCompatTest( - provider: FileProvider(filePath: jsonConfigFile) + provider: FileProvider( + parsingOptions: .default, + filePath: "/etc/config.json", + allowMissing: false, + fileSystem: fileSystem + ) ) .runTest() } diff --git a/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift index 04c3b31..022dae5 100644 --- a/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift @@ -20,49 +20,49 @@ import ConfigurationTestingInternal import Foundation import ConfigurationTesting import Logging +import Metrics import SystemPackage struct JSONReloadingFileProviderTests { + + @available(Configuration 1.0, *) + var provider: ReloadingFileProvider { + get async throws { + let fileSystem = InMemoryFileSystem(files: [ + "/etc/config.json": .file(timestamp: .now, contents: jsonTestFileContents) + ]) + return try await ReloadingFileProvider( + parsingOptions: .default, + filePath: "/etc/config.json", + allowMissing: false, + pollInterval: .seconds(1), + fileSystem: fileSystem, + logger: .noop, + metrics: NOOPMetricsHandler.instance + ) + } + } + @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDescription = #""" ReloadingFileProvider[20 values] """# - #expect(provider.description == expectedDescription) + #expect(try await provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDebugDescription = #""" 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) + #expect(try await provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) try await ProviderCompatTest(provider: provider).runTest() } - - @available(Configuration 1.0, *) - @Test func initializationWithConfig() async throws { - // Test initialization using config reader - let envProvider = InMemoryProvider(values: [ - "json.filePath": ConfigValue(jsonConfigFile.string, isSecret: false), - "json.pollIntervalSeconds": 30, - ]) - let config = ConfigReader(provider: envProvider) - - let reloadingProvider = try await ReloadingFileProvider( - config: config.scoped(to: "json") - ) - - #expect(reloadingProvider.providerName == "ReloadingFileProvider") - #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) - } } #endif diff --git a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift index 2ea9453..e07be3e 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -27,6 +27,7 @@ import SystemPackage @available(Configuration 1.0, *) private func withTestProvider( + allowMissing: Bool = false, body: ( ReloadingFileProvider, InMemoryFileSystem, @@ -38,6 +39,7 @@ private func withTestProvider( let provider = try await ReloadingFileProvider( parsingOptions: .default, filePath: filePath, + allowMissing: allowMissing, pollInterval: .seconds(1), fileSystem: fileSystem, logger: .noop, @@ -48,6 +50,29 @@ private func withTestProvider( } struct ReloadingFileProviderTests { + + @available(Configuration 1.0, *) + @Test func config() async throws { + // Test initialization using config reader + let envProvider = InMemoryProvider(values: [ + "filePath": ConfigValue("/test/config.txt", isSecret: false), + "pollIntervalSeconds": 30, + ]) + let config = ConfigReader(provider: envProvider) + + try await withTestFileSystem { fileSystem, filePath, _ in + let reloadingProvider = try await ReloadingFileProvider( + parsingOptions: .default, + config: config, + fileSystem: fileSystem, + logger: .noop, + metrics: NOOPMetricsHandler.instance + ) + #expect(reloadingProvider.providerName == "ReloadingFileProvider") + #expect(reloadingProvider.description.contains("TestSnapshot")) + } + } + @available(Configuration 1.0, *) @Test func testBasicManualReload() async throws { try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in @@ -76,6 +101,90 @@ struct ReloadingFileProviderTests { } } + @available(Configuration 1.0, *) + @Test func testBasicManualReloadMissingFile() async throws { + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in + // Check initial values + let result1 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result1.value?.content.asString == "value1") + + // Remove file + fileSystem.remove(filePath: filePath) + + // Trigger reload - should throw an error but keep the old contents + let error = await #expect(throws: FileSystemError.self) { + try await provider.reloadIfNeeded(logger: .noop) + } + guard case .fileNotFound(path: let errorFilePath) = error else { + Issue.record("Unexpected error thrown: \(error)") + return + } + #expect(errorFilePath == "/test/config.txt") + + // Check original value + let result2 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result2.value?.content.asString == "value1") + + // Update to a new valid file + fileSystem.update( + filePath: filePath, + timestamp: originalTimestamp.addingTimeInterval(1.0), + contents: .file( + contents: """ + key1=newValue1 + key2=value2 + """ + ) + ) + + // Reload, no error thrown + try await provider.reloadIfNeeded(logger: .noop) + + // Check new value + let result3 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result3.value?.content.asString == "newValue1") + } + } + + @available(Configuration 1.0, *) + @Test func testBasicManualReloadAllowMissing() async throws { + try await withTestProvider(allowMissing: true) { provider, fileSystem, filePath, originalTimestamp in + // Check initial values + let result1 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result1.value?.content.asString == "value1") + + // Remove the file + fileSystem.remove(filePath: filePath) + + // Trigger reload, no file found but allowMissing is enabled, so + // leads to an empty provider + try await provider.reloadIfNeeded(logger: .noop) + + // Check empty value + let result2 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result2.value == nil) + + // Update to a new valid file + fileSystem.update( + filePath: filePath, + timestamp: originalTimestamp.addingTimeInterval(1.0), + contents: .file( + contents: """ + key1=newValue1 + key2=value2 + """ + ) + ) + + // Reload + try await provider.reloadIfNeeded(logger: .noop) + + // Check new value + let result3 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result3.value?.content.asString == "newValue1") + } + } + @available(Configuration 1.0, *) @Test func testBasicTimedReload() async throws { let filePath = FilePath("/test/config.txt") @@ -94,6 +203,7 @@ struct ReloadingFileProviderTests { let provider = try await ReloadingFileProvider( parsingOptions: .default, filePath: filePath, + allowMissing: false, pollInterval: .milliseconds(1), fileSystem: fileSystem, logger: .noop, diff --git a/Tests/ConfigurationTests/Resources/.env b/Tests/ConfigurationTests/Resources/.env deleted file mode 100644 index 21f7aba..0000000 --- a/Tests/ConfigurationTests/Resources/.env +++ /dev/null @@ -1,9 +0,0 @@ -# Top comment -HTTP_CLIENT_USER_AGENT=Config/1.0 (Test) -HTTP_CLIENT_TIMEOUT=15.0 - -# Middle comment, with an empty line around it - -HTTP_SECRET=s3cret -HTTP_VERSION=2 -ENABLED=true diff --git a/Tests/ConfigurationTests/Resources/config.json b/Tests/ConfigurationTests/Resources/config.json deleted file mode 100644 index 79a4e61..0000000 --- a/Tests/ConfigurationTests/Resources/config.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "string": "Hello", - "int": 42, - "double": 3.14, - "bool": true, - "bytes": "bWFnaWM=", - - "other": { - "string": "Other Hello", - "int": 24, - "double": 2.72, - "bool": false, - "bytes": "bWFnaWMy", - - "stringy": { - "array": [ - "Hello", - "Swift" - ] - }, - "inty": { - "array": [ - 16, - 32 - ] - }, - "doubly": { - "array": [ - 0.9, - 1.8 - ] - }, - "booly": { - "array": [ - false, - true, - true - ] - }, - "byteChunky": { - "array": [ - "bWFnaWM=", - "bWFnaWMy", - "bWFnaWM=" - ] - } - }, - - "stringy": { - "array": [ - "Hello", - "World" - ] - }, - "inty": { - "array": [ - 42, - 24 - ] - }, - "doubly": { - "array": [ - 3.14, - 2.72 - ] - }, - "booly": { - "array": [ - true, - false - ] - }, - "byteChunky": { - "array": [ - "bWFnaWM=", - "bWFnaWMy" - ] - } -} diff --git a/Tests/ConfigurationTests/Resources/config.yaml b/Tests/ConfigurationTests/Resources/config.yaml deleted file mode 100644 index b5b6123..0000000 --- a/Tests/ConfigurationTests/Resources/config.yaml +++ /dev/null @@ -1,56 +0,0 @@ -string: "Hello" -int: 42 -double: 3.14 -bool: true -bytes: "bWFnaWM=" - -other: - string: "Other Hello" - int: 24 - double: 2.72 - bool: false - bytes: "bWFnaWMy" - - stringy: - array: - - "Hello" - - "Swift" - inty: - array: - - 16 - - 32 - doubly: - array: - - 0.9 - - 1.8 - booly: - array: - - false - - true - - true - byteChunky: - array: - - "bWFnaWM=" - - "bWFnaWMy" - - "bWFnaWM=" - -stringy: - array: - - "Hello" - - "World" -inty: - array: - - 42 - - 24 -doubly: - array: - - 3.14 - - 2.72 -booly: - array: - - true - - false -byteChunky: - array: - - "bWFnaWM=" - - "bWFnaWMy" diff --git a/Tests/ConfigurationTests/YAMLFileProviderTests.swift b/Tests/ConfigurationTests/YAMLFileProviderTests.swift index a91e212..af08c61 100644 --- a/Tests/ConfigurationTests/YAMLFileProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLFileProviderTests.swift @@ -21,8 +21,65 @@ import Foundation import ConfigurationTesting import SystemPackage -private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) -let yamlConfigFile = resourcesPath.appending("/config.yaml") +let yamlTestFileContents = """ + string: "Hello" + int: 42 + double: 3.14 + bool: true + bytes: "bWFnaWM=" + + other: + string: "Other Hello" + int: 24 + double: 2.72 + bool: false + bytes: "bWFnaWMy" + + stringy: + array: + - "Hello" + - "Swift" + inty: + array: + - 16 + - 32 + doubly: + array: + - 0.9 + - 1.8 + booly: + array: + - false + - true + - true + byteChunky: + array: + - "bWFnaWM=" + - "bWFnaWMy" + - "bWFnaWM=" + + stringy: + array: + - "Hello" + - "World" + inty: + array: + - 42 + - 24 + doubly: + array: + - 3.14 + - 2.72 + booly: + array: + - true + - false + byteChunky: + array: + - "bWFnaWM=" + - "bWFnaWMy" + + """ struct YAMLFileProviderTests { @@ -30,7 +87,7 @@ struct YAMLFileProviderTests { var provider: YAMLSnapshot { get throws { try YAMLSnapshot( - data: Data(contentsOf: URL(filePath: yamlConfigFile.string)).bytes, + data: Data(yamlTestFileContents.utf8).bytes, providerName: "TestProvider", parsingOptions: .default ) @@ -55,8 +112,16 @@ struct YAMLFileProviderTests { @available(Configuration 1.0, *) @Test func compat() async throws { + let fileSystem = InMemoryFileSystem(files: [ + "/etc/config.yaml": .file(timestamp: .now, contents: yamlTestFileContents) + ]) try await ProviderCompatTest( - provider: FileProvider(filePath: yamlConfigFile) + provider: FileProvider( + parsingOptions: .default, + filePath: "/etc/config.yaml", + allowMissing: false, + fileSystem: fileSystem + ) ) .runTest() } diff --git a/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift index 06c07c0..60d6688 100644 --- a/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift @@ -20,49 +20,49 @@ import ConfigurationTestingInternal import Foundation import ConfigurationTesting import Logging +import Metrics import SystemPackage struct YAMLReloadingFileProviderTests { + + @available(Configuration 1.0, *) + var provider: ReloadingFileProvider { + get async throws { + let fileSystem = InMemoryFileSystem(files: [ + "/etc/config.yaml": .file(timestamp: .now, contents: yamlTestFileContents) + ]) + return try await ReloadingFileProvider( + parsingOptions: .default, + filePath: "/etc/config.yaml", + allowMissing: false, + pollInterval: .seconds(1), + fileSystem: fileSystem, + logger: .noop, + metrics: NOOPMetricsHandler.instance + ) + } + } + @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDescription = #""" ReloadingFileProvider[20 values] """# - #expect(provider.description == expectedDescription) + #expect(try await provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDebugDescription = #""" 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) + #expect(try await provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) try await ProviderCompatTest(provider: provider).runTest() } - - @available(Configuration 1.0, *) - @Test func initializationWithConfig() async throws { - // Test initialization using config reader - let envProvider = InMemoryProvider(values: [ - "yaml.filePath": ConfigValue(yamlConfigFile.string, isSecret: false), - "yaml.pollIntervalSeconds": 30, - ]) - let config = ConfigReader(provider: envProvider) - - let reloadingProvider = try await ReloadingFileProvider( - config: config.scoped(to: "yaml") - ) - - #expect(reloadingProvider.providerName == "ReloadingFileProvider") - #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) - } } #endif From 6444fb926d67a8def4d7856ae2c353ae0fa53a34 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 12 Nov 2025 14:34:54 +0100 Subject: [PATCH 2/3] Fix linux --- Sources/Configuration/Utilities/FoundationExtensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Configuration/Utilities/FoundationExtensions.swift b/Sources/Configuration/Utilities/FoundationExtensions.swift index 51c905b..f2e23f5 100644 --- a/Sources/Configuration/Utilities/FoundationExtensions.swift +++ b/Sources/Configuration/Utilities/FoundationExtensions.swift @@ -40,7 +40,7 @@ extension Error { /// Inspects whether the error represents a file not found. internal var isFileNotFoundError: Bool { if let posixError = self as? POSIXError { - return posixError.code == POSIXError.Code.ENOENT + return posixError.code == POSIXError.ENOENT } if let cocoaError = self as? CocoaError, cocoaError.isFileError { return [ From 25ebb8d5299b7561e92c1a0682b827056eacdf50 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 12 Nov 2025 14:41:34 +0100 Subject: [PATCH 3/3] Fix tvOS --- .../Configuration/Providers/Files/CommonProviderFileSystem.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift index 0970552..ac4b588 100644 --- a/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift +++ b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift @@ -92,6 +92,7 @@ package struct LocalCommonProviderFileSystem: Sendable {} /// - Parameter body: A body closure that performs the file system operation. /// - Returns: The result of the operation, or `nil` if the file was not found. /// - Throws: Any error from the operation except file not found errors. +@available(Configuration 1.0, *) private func returnNilIfMissing( _ body: () async throws -> Return ) async throws -> Return? {