Skip to content

Commit 0270868

Browse files
authored
Merge branch 'main' into hd-return-noncopyable
2 parents cb603cd + c647945 commit 0270868

29 files changed

+1150
-462
lines changed

Package.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,6 @@ import Foundation
99

1010
let defaultTraits: Set<String> = [
1111
"JSONSupport"
12-
13-
// Disabled due to a bug in SwiftPM with traits that pull in an external dependency.
14-
// Once that's fixed in Swift 6.2.x, we can enable these traits by default.
15-
// Open fix: https://github.com/swiftlang/swift-package-manager/pull/9136
16-
// "LoggingSupport",
17-
// "ReloadingSupport",
1812
]
1913

2014
var traits: Set<Trait> = [
@@ -146,9 +140,6 @@ let package = Package(
146140
"ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet1.swift.gyb",
147141
"ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet2.swift.gyb",
148142
"ConfigReaderTests/ConfigSnapshotReaderMethodTestsGet3.swift.gyb",
149-
],
150-
resources: [
151-
.copy("Resources")
152143
]
153144
),
154145

Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ let logger: Logger = ...
3636
let config = ConfigReader(
3737
providers: [
3838
EnvironmentVariablesProvider(),
39-
try await FileProvider<JSONSnapshot>(filePath: "/etc/myapp/config.json"),
39+
try await FileProvider<JSONSnapshot>(
40+
filePath: "/etc/myapp/config.json",
41+
allowMissing: true // Optional: treat missing file as empty config
42+
),
4043
InMemoryProvider(values: [
4144
"http.server.port": 8080,
4245
"http.server.host": "127.0.0.1",

Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,71 @@ such as Kubernetes secrets mounted into a container's filesystem.
7777

7878
> Tip: For comprehensive guidance on handling secrets securely, see <doc:Handling-secrets-correctly>.
7979
80+
### Handling optional configuration files
81+
82+
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.
83+
84+
When `allowMissing` is `false` (the default), missing files throw an error:
85+
86+
```swift
87+
import Configuration
88+
89+
// This will throw an error if config.json doesn't exist
90+
let config = ConfigReader(
91+
provider: try await FileProvider<JSONSnapshot>(
92+
filePath: "/etc/config.json",
93+
allowMissing: false // This is the default
94+
)
95+
)
96+
```
97+
98+
When `allowMissing` is `true`, missing files are treated as empty configuration:
99+
100+
```swift
101+
import Configuration
102+
103+
// This won't throw if config.json is missing - treats it as empty
104+
let config = ConfigReader(
105+
provider: try await FileProvider<JSONSnapshot>(
106+
filePath: "/etc/config.json",
107+
allowMissing: true
108+
)
109+
)
110+
111+
// Returns the default value if the file is missing
112+
let port = config.int(forKey: "server.port", default: 8080)
113+
```
114+
115+
The same applies to other file-based providers:
116+
117+
```swift
118+
// Optional secrets directory
119+
let secretsConfig = ConfigReader(
120+
provider: try await DirectoryFilesProvider(
121+
directoryPath: "/run/secrets",
122+
allowMissing: true
123+
)
124+
)
125+
126+
// Optional environment file
127+
let envConfig = ConfigReader(
128+
provider: try await EnvironmentVariablesProvider(
129+
environmentFilePath: "/etc/app.env",
130+
allowMissing: true
131+
)
132+
)
133+
134+
// Optional reloading configuration
135+
let reloadingConfig = ConfigReader(
136+
provider: try await ReloadingFileProvider<YAMLSnapshot>(
137+
filePath: "/etc/dynamic-config.yaml",
138+
allowMissing: true
139+
)
140+
)
141+
```
142+
143+
> 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.
144+
80145
### Setting up a fallback hierarchy
81146

82147
Use multiple providers together to provide a configuration hierarchy that can override values at different levels.

Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,45 @@ When no provider has the requested value:
8080
- **Methods without defaults**: Return nil.
8181
- **Required methods**: Throw an error.
8282

83+
#### File not found errors
84+
85+
File-based providers (``FileProvider``, ``ReloadingFileProvider``, ``DirectoryFilesProvider``, ``EnvironmentVariablesProvider`` with file path) can throw "file not found" errors when expected configuration files don't exist.
86+
87+
Common scenarios and solutions:
88+
89+
**Optional configuration files:**
90+
```swift
91+
// Problem: App crashes when optional config file is missing
92+
let provider = try await FileProvider<JSONSnapshot>(filePath: "/etc/optional-config.json")
93+
94+
// Solution: Use allowMissing parameter
95+
let provider = try await FileProvider<JSONSnapshot>(
96+
filePath: "/etc/optional-config.json",
97+
allowMissing: true
98+
)
99+
```
100+
101+
**Environment-specific files:**
102+
```swift
103+
// Different environments may have different config files
104+
let configPath = "/etc/\(environment)/config.json"
105+
let provider = try await FileProvider<JSONSnapshot>(
106+
filePath: configPath,
107+
allowMissing: true // Gracefully handle missing env-specific configs
108+
)
109+
```
110+
111+
**Container startup issues:**
112+
```swift
113+
// Config files might not be ready when container starts
114+
let provider = try await ReloadingFileProvider<JSONSnapshot>(
115+
filePath: "/mnt/config/app.json",
116+
allowMissing: true // Allow startup with empty config, load when available
117+
)
118+
```
119+
120+
> 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.
121+
83122
### Reloading provider troubleshooting
84123

85124
#### Configuration not updating

Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import ServiceLifecycle
2020

2121
let provider = try await ReloadingFileProvider<JSONSnapshot>(
2222
filePath: "/etc/config.json",
23+
allowMissing: true, // Optional: treat missing file as empty config
2324
pollInterval: .seconds(15)
2425
)
2526

@@ -98,6 +99,58 @@ try await config.watchSnapshot { updates in
9899
| **Service lifecycle** | Not required | Conforms to `Service` and must run in a `ServiceGroup` |
99100
| **Configuration updates** | Require restart | Automatic reload |
100101

102+
### Handling missing files during reloading
103+
104+
Reloading providers support the `allowMissing` parameter to handle cases where configuration files might be temporarily missing or optional. This is particularly useful for:
105+
106+
- Optional configuration files that might not exist in all environments.
107+
- Configuration files that are created or removed dynamically.
108+
- Graceful handling of file system issues during service startup.
109+
110+
#### Missing file behavior
111+
112+
When `allowMissing` is `false` (the default), missing files cause errors:
113+
114+
```swift
115+
let provider = try await ReloadingFileProvider<JSONSnapshot>(
116+
filePath: "/etc/config.json",
117+
allowMissing: false // Default: throw error if file is missing
118+
)
119+
// Will throw an error if config.json doesn't exist
120+
```
121+
122+
When `allowMissing` is `true`, missing files are treated as empty configuration:
123+
124+
```swift
125+
let provider = try await ReloadingFileProvider<JSONSnapshot>(
126+
filePath: "/etc/config.json",
127+
allowMissing: true // Treat missing file as empty config
128+
)
129+
// Won't throw if config.json is missing - uses empty config instead
130+
```
131+
132+
#### Behavior during reloading
133+
134+
If a file becomes missing after the provider starts, the behavior depends on the `allowMissing` setting:
135+
136+
- **`allowMissing: false`**: The provider keeps the last known configuration and logs an error.
137+
- **`allowMissing: true`**: The provider switches to empty configuration.
138+
139+
In both cases, when a valid file comes back, the provider will load it and recover.
140+
141+
```swift
142+
// Example: File gets deleted during runtime
143+
try await config.watchString(forKey: "database.host", default: "localhost") { updates in
144+
for await host in updates {
145+
// With allowMissing: true, this will receive "localhost" when file is removed
146+
// With allowMissing: false, this keeps the last known value
147+
print("Database host: \(host)")
148+
}
149+
}
150+
```
151+
152+
> Important: The `allowMissing` parameter only affects missing files. Malformed files will still cause parsing errors regardless of this setting.
153+
101154
### Advanced features
102155

103156
#### Configuration-driven setup

Sources/Configuration/Documentation.docc/Reference/DirectoryFilesProvider.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
### Creating a directory files provider
66

7-
- ``init(directoryPath:secretsSpecifier:arraySeparator:keyEncoder:)``
7+
- ``init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:keyEncoder:)``

Sources/Configuration/Documentation.docc/Reference/EnvironmentVariablesProvider.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
- ``init(secretsSpecifier:bytesDecoder:arraySeparator:)``
88
- ``init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:)``
9-
- ``init(environmentFilePath:secretsSpecifier:bytesDecoder:arraySeparator:)``
9+
- ``init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:)``
1010

1111
### Inspecting an environment variable provider
1212

Sources/Configuration/Documentation.docc/Reference/FileProvider.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
### Creating a file provider
66

7-
- ``init(snapshotType:parsingOptions:filePath:)``
7+
- ``init(snapshotType:parsingOptions:filePath:allowMissing:)``
88
- ``init(snapshotType:parsingOptions:config:)``
99

1010
### Reading configuration files

Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
### Creating a reloading file provider
66

7-
- ``init(snapshotType:parsingOptions:filePath:pollInterval:logger:metrics:)``
7+
- ``init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:)``
88
- ``init(snapshotType:parsingOptions:config:logger:metrics:)``
99

1010
### Service lifecycle

Sources/Configuration/Providers/EnvironmentVariables/EnvironmentVariablesProvider.swift

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -147,20 +147,6 @@ public struct EnvironmentVariablesProvider: Sendable {
147147
/// The underlying snapshot of the provider.
148148
private let _snapshot: Snapshot
149149

150-
/// The error thrown by the provider.
151-
enum ProviderError: Error, CustomStringConvertible {
152-
153-
/// The environment file was not found at the provided path.
154-
case environmentFileNotFound(path: FilePath)
155-
156-
var description: String {
157-
switch self {
158-
case .environmentFileNotFound(let path):
159-
return "EnvironmentVariablesProvider: File not found at path: \(path)."
160-
}
161-
}
162-
}
163-
164150
/// Creates a new provider that reads from the current process environment.
165151
///
166152
/// This initializer creates a provider that sources configuration values from
@@ -239,44 +225,80 @@ public struct EnvironmentVariablesProvider: Sendable {
239225

240226
/// Creates a new provider that reads from an environment file.
241227
///
242-
/// This initializer loads environment variables from a `.env` file at the specified path.
228+
/// This initializer loads environment variables from an `.env` file at the specified path.
243229
/// The file should contain key-value pairs in the format `KEY=value`, one per line.
244230
/// Comments (lines starting with `#`) and empty lines are ignored.
245231
///
246232
/// ```swift
247233
/// // Load from a .env file
248234
/// let provider = try await EnvironmentVariablesProvider(
249235
/// environmentFilePath: ".env",
236+
/// allowMissing: true,
250237
/// secretsSpecifier: .specific(["API_KEY"])
251238
/// )
252239
/// ```
253240
///
254241
/// - Parameters:
255242
/// - environmentFilePath: The file system path to the environment file to load.
243+
/// - allowMissing: A flag controlling how the provider handles a missing file.
244+
/// - When `false` (the default), if the file is missing or malformed, throws an error.
245+
/// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error.
256246
/// - secretsSpecifier: Specifies which environment variables should be treated as secrets.
257247
/// - bytesDecoder: The decoder used for converting string values to byte arrays.
258248
/// - arraySeparator: The character used to separate elements in array values.
259-
/// - Throws: If the file cannot be found or read.
249+
/// - Throws: If the file is malformed, or if missing when allowMissing is `false`.
260250
public init(
261251
environmentFilePath: FilePath,
252+
allowMissing: Bool = false,
262253
secretsSpecifier: SecretsSpecifier<String, String> = .none,
263254
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
264255
arraySeparator: Character = ","
265256
) async throws {
266-
do {
267-
let contents = try String(
268-
contentsOfFile: environmentFilePath.string,
269-
encoding: .utf8
270-
)
271-
self.init(
272-
environmentVariables: EnvironmentFileParser.parsed(contents),
273-
secretsSpecifier: secretsSpecifier,
274-
bytesDecoder: bytesDecoder,
275-
arraySeparator: arraySeparator
276-
)
277-
} catch let error where error.isFileNotFoundError {
278-
throw ProviderError.environmentFileNotFound(path: environmentFilePath)
257+
try await self.init(
258+
environmentFilePath: environmentFilePath,
259+
allowMissing: allowMissing,
260+
fileSystem: LocalCommonProviderFileSystem(),
261+
secretsSpecifier: secretsSpecifier,
262+
bytesDecoder: bytesDecoder,
263+
arraySeparator: arraySeparator
264+
)
265+
}
266+
267+
/// Creates a new provider that reads from an environment file.
268+
/// - Parameters:
269+
/// - environmentFilePath: The file system path to the environment file to load.
270+
/// - allowMissing: A flag controlling how the provider handles a missing file.
271+
/// - When `false` (the default), if the file is missing or malformed, throws an error.
272+
/// - When `true`, if the file is missing, treats it as empty. Malformed files still throw an error.
273+
/// - fileSystem: The file system implementation to use.
274+
/// - secretsSpecifier: Specifies which environment variables should be treated as secrets.
275+
/// - bytesDecoder: The decoder used for converting string values to byte arrays.
276+
/// - arraySeparator: The character used to separate elements in array values.
277+
/// - Throws: If the file is malformed, or if missing when allowMissing is `false`.
278+
internal init(
279+
environmentFilePath: FilePath,
280+
allowMissing: Bool,
281+
fileSystem: some CommonProviderFileSystem,
282+
secretsSpecifier: SecretsSpecifier<String, String> = .none,
283+
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
284+
arraySeparator: Character = ","
285+
) async throws {
286+
let loadedData = try await fileSystem.fileContents(atPath: environmentFilePath)
287+
let data: Data
288+
if let loadedData {
289+
data = loadedData
290+
} else if allowMissing {
291+
data = Data()
292+
} else {
293+
throw FileSystemError.fileNotFound(path: environmentFilePath)
279294
}
295+
let contents = String(decoding: data, as: UTF8.self)
296+
self.init(
297+
environmentVariables: EnvironmentFileParser.parsed(contents),
298+
secretsSpecifier: secretsSpecifier,
299+
bytesDecoder: bytesDecoder,
300+
arraySeparator: arraySeparator
301+
)
280302
}
281303

282304
/// Returns the raw string value for a specific environment variable name.
@@ -317,23 +339,6 @@ internal struct EnvironmentValueArrayDecoder {
317339
}
318340
}
319341

320-
extension Error {
321-
/// Inspects whether the error represents a file not found.
322-
internal var isFileNotFoundError: Bool {
323-
if let posixError = self as? POSIXError {
324-
return posixError.code == POSIXError.Code.ENOENT
325-
}
326-
if let cocoaError = self as? CocoaError, cocoaError.isFileError {
327-
return [
328-
CocoaError.fileNoSuchFile,
329-
CocoaError.fileReadNoSuchFile,
330-
]
331-
.contains(cocoaError.code)
332-
}
333-
return false
334-
}
335-
}
336-
337342
@available(Configuration 1.0, *)
338343
extension EnvironmentVariablesProvider: CustomStringConvertible {
339344
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation

0 commit comments

Comments
 (0)