Skip to content

Commit a83c956

Browse files
committed
Add an allowMissing parameter to file-based providers
1 parent 5a82370 commit a83c956

30 files changed

+1157
-466
lines changed

Package.swift

Lines changed: 5 additions & 10 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

@@ -188,7 +179,11 @@ for target in package.targets {
188179
// https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/
189180
settings.append(.enableUpcomingFeature("NonisolatedNonsendingByDefault"))
190181

191-
settings.append(.enableExperimentalFeature("AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"))
182+
settings.append(
183+
.enableExperimentalFeature(
184+
"AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"
185+
)
186+
)
192187

193188
if enableAllCIFlags {
194189
// Ensure all public types are explicitly annotated as Sendable or not Sendable.

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
@@ -82,6 +82,45 @@ When no provider has the requested value:
8282
- **Methods without defaults**: Return nil.
8383
- **Required methods**: Throw an error.
8484

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

87126
#### 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/MultiProvider.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ extension MultiProvider {
209209
updateSequences: &updateSequences,
210210
) { providerUpdateSequences in
211211
let updateArrays = combineLatestMany(
212-
elementType: (any ConfigSnapshotProtocol).self,
212+
elementType: (any ConfigSnapshot).self,
213213
failureType: Never.self,
214214
providerUpdateSequences
215215
)
@@ -364,8 +364,8 @@ nonisolated(nonsending) private func withProvidersWatchingValue<ReturnInner>(
364364
@available(Configuration 1.0, *)
365365
nonisolated(nonsending) private func withProvidersWatchingSnapshot<ReturnInner>(
366366
providers: ArraySlice<any ConfigProvider>,
367-
updateSequences: inout [any (AsyncSequence<any ConfigSnapshotProtocol, Never> & Sendable)],
368-
body: ([any (AsyncSequence<any ConfigSnapshotProtocol, Never> & Sendable)]) async throws -> ReturnInner
367+
updateSequences: inout [any (AsyncSequence<any ConfigSnapshot, Never> & Sendable)],
368+
body: ([any (AsyncSequence<any ConfigSnapshot, Never> & Sendable)]) async throws -> ReturnInner
369369
) async throws -> ReturnInner {
370370
guard let provider = providers.first else {
371371
// Recursion termination, once we've collected all update sequences, execute the body.

0 commit comments

Comments
 (0)