Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

### Creating an environment variable provider

- ``init(secretsSpecifier:bytesDecoder:arraySeparator:)``
- ``init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:)``
- ``init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:)``
- ``init(secretsSpecifier:bytesDecoder:boolDecoder:arraySeparator:)``
- ``init(environmentVariables:secretsSpecifier:bytesDecoder:boolDecoder:arraySeparator:)``
- ``init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:boolDecoder:arraySeparator:)``

### Inspecting an environment variable provider

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ public struct EnvironmentVariablesProvider: Sendable {

/// A decoder of arrays from a string.
var arrayDecoder: EnvironmentValueArrayDecoder

/// A decoder of bool values from a string
let boolDecoder: BoolDecoder
}

/// The underlying snapshot of the provider.
Expand All @@ -165,16 +168,19 @@ public struct EnvironmentVariablesProvider: Sendable {
/// - Parameters:
/// - secretsSpecifier: Specifies which environment variables should be treated as secrets.
/// - bytesDecoder: The decoder used for converting string values to byte arrays.
/// - boolDecoder: The decoder used for converting string values to bool values.
/// - arraySeparator: The character used to separate elements in array values.
public init(
secretsSpecifier: SecretsSpecifier<String, String> = .none,
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
boolDecoder: BoolDecoder = .init(),
arraySeparator: Character = ","
) {
self.init(
environmentVariables: ProcessInfo.processInfo.environment,
secretsSpecifier: secretsSpecifier,
bytesDecoder: bytesDecoder,
boolDecoder: boolDecoder,
arraySeparator: arraySeparator
)
}
Expand All @@ -200,11 +206,13 @@ public struct EnvironmentVariablesProvider: Sendable {
/// - environmentVariables: A dictionary of environment variable names and values.
/// - secretsSpecifier: Specifies which environment variables should be treated as secrets.
/// - bytesDecoder: The decoder used for converting string values to byte arrays.
/// - boolDecoder: The decoder used for converting string to bool values.
/// - arraySeparator: The character used to separate elements in array values.
public init(
environmentVariables: [String: String],
secretsSpecifier: SecretsSpecifier<String, String> = .none,
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
boolDecoder: BoolDecoder = .init(),
arraySeparator: Character = ","
) {
let tuples: [(String, EnvironmentValue)] = environmentVariables.map { key, value in
Expand All @@ -219,7 +227,8 @@ public struct EnvironmentVariablesProvider: Sendable {
self._snapshot = .init(
environmentVariables: Dictionary(uniqueKeysWithValues: tuples),
bytesDecoder: bytesDecoder,
arrayDecoder: EnvironmentValueArrayDecoder(separator: arraySeparator)
arrayDecoder: EnvironmentValueArrayDecoder(separator: arraySeparator),
boolDecoder: boolDecoder
)
}

Expand All @@ -245,13 +254,15 @@ public struct EnvironmentVariablesProvider: Sendable {
/// - 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.
/// - boolDecoder: The decoder used for converting string values to bool values.
/// - arraySeparator: The character used to separate elements in array values.
/// - Throws: If the file is malformed, or if missing when allowMissing is `false`.
public init(
environmentFilePath: FilePath,
allowMissing: Bool = false,
secretsSpecifier: SecretsSpecifier<String, String> = .none,
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
boolDecoder: BoolDecoder = .init(),
arraySeparator: Character = ","
) async throws {
try await self.init(
Expand All @@ -260,6 +271,7 @@ public struct EnvironmentVariablesProvider: Sendable {
fileSystem: LocalCommonProviderFileSystem(),
secretsSpecifier: secretsSpecifier,
bytesDecoder: bytesDecoder,
boolDecoder: boolDecoder,
arraySeparator: arraySeparator
)
}
Expand All @@ -273,6 +285,7 @@ public struct EnvironmentVariablesProvider: Sendable {
/// - 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.
/// - boolDecoder: The decoder used for converting string values to bool values.
/// - 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(
Expand All @@ -281,6 +294,7 @@ public struct EnvironmentVariablesProvider: Sendable {
fileSystem: some CommonProviderFileSystem,
secretsSpecifier: SecretsSpecifier<String, String> = .none,
bytesDecoder: some ConfigBytesFromStringDecoder = .base64,
boolDecoder: BoolDecoder = .init(),
arraySeparator: Character = ","
) async throws {
let loadedData = try await fileSystem.fileContents(atPath: environmentFilePath)
Expand All @@ -297,6 +311,7 @@ public struct EnvironmentVariablesProvider: Sendable {
environmentVariables: EnvironmentFileParser.parsed(contents),
secretsSpecifier: secretsSpecifier,
bytesDecoder: bytesDecoder,
boolDecoder: boolDecoder,
arraySeparator: arraySeparator
)
}
Expand Down Expand Up @@ -393,7 +408,7 @@ extension EnvironmentVariablesProvider.Snapshot {
}
content = .double(doubleValue)
case .bool:
guard let boolValue = Bool(stringValue) else {
guard let boolValue = boolDecoder.decodeBool(from: stringValue) else {
try throwMismatch()
}
content = .bool(boolValue)
Expand Down Expand Up @@ -426,7 +441,7 @@ extension EnvironmentVariablesProvider.Snapshot {
case .boolArray:
let arrayValue = arrayDecoder.decode(stringValue)
let boolArray = try arrayValue.map { stringValue in
guard let boolValue = Bool(stringValue) else {
guard let boolValue = boolDecoder.decodeBool(from: stringValue) else {
try throwMismatch()
}
return boolValue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftConfiguration open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

/// A decoder that converts a boolean string into a Bool, taking into account different boolean string pairs.
///
/// This decoder is able to convert a string to Bool values when the string’s Boolean value format is 0 or 1, true or false, or yes or no.
///
/// ## Boolean values
///
/// Following boolean string pairs are decoded to a Bool value: trueFalse (true, false), oneZero (1, 0), yesNo (yes, no).
/// Decoding is case-insensitive.
@available(Configuration 1.0, *)
public struct BoolDecoder: Sendable {

/// Creates a new bool decoder.
public init() {}

public func decodeBool(from string: String) -> Bool? {
let stringLowercased = string.lowercased()
return if ["true", "false"].contains(stringLowercased) {
stringLowercased == "true"
} else if ["yes", "no"].contains(stringLowercased) {
stringLowercased == "yes"
} else if ["1", "0"].contains(stringLowercased) {
stringLowercased == "1"
} else {
nil
}
}
}
41 changes: 41 additions & 0 deletions Tests/ConfigurationTests/ConfigBoolsFromStringDecoderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//===----------------------------------------------------------------------===//
//
// 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 Foundation
@testable import Configuration

struct ConfigBoolsFromStringDecoderTests {

@Test()
@available(Configuration 1.0, *)
func stringToBool() throws {
let bd = BoolDecoder()
let cases: [(expected: Bool?, input: [String])] = [
(true, ["1"]),
(false, ["0"]),
(true, ["Yes", "yes", "YES", "yES"]),
(false, ["No", "no", "NO", "nO"]),
(true, ["true", "TRUE", "trUe"]),
(false, ["false", "FALSE", "faLse"]),
(nil, ["", "_true_", "_false_", "_yes_", "_no_", "_1_", "_0_", "11", "00"])
]

for (expected, inputs) in cases {
for input in inputs {
#expect(bd.decodeBool(from: input) == expected, "input: \(input)")
}
}
}
}
31 changes: 31 additions & 0 deletions Tests/ConfigurationTests/EnvironmentVariablesProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,37 @@ struct EnvironmentVariablesProviderTests {
#expect(provider.debugDescription == expectedDebugDescription)
}

@available(Configuration 1.0, *)
@Test func valueForKeyOfBoolAndBoolArrayTypes() throws {
let ep = EnvironmentVariablesProvider(
environmentVariables: [
"BOOL_TRUE": "true",
"BOOL_FALSE": "false",
"BOOL_1": "1",
"BOOL_0": "0",
"BOOL_YES": "YES",
"BOOL_NO": "NO",
"BOOL_THROWS_ERROR_EMPTY": "",
"BOOL_THROWS_ERROR_NOT_BOOL_STRING": "2",
"BOOLY_ARRAY_TRUE": "true,1,,YES",
"BOOLY_ARRAY_FALSE": "false,0,NO",
"BOOLY_ARRAY_THROWS_1": "true,1,YESS",
"BOOLY_ARRAY_THROWS_2": "false,00,no",
])
#expect(try ep.value(forKey: "BOOL_TRUE", type: .bool).value == true)
#expect(try ep.value(forKey: "BOOL_FALSE", type: .bool).value == false)
#expect(try ep.value(forKey: "BOOL_1", type: .bool).value == true)
#expect(try ep.value(forKey: "BOOL_0", type: .bool).value == false)
#expect(try ep.value(forKey: "BOOL_YES", type: .bool).value == true)
#expect(try ep.value(forKey: "BOOL_NO", type: .bool).value == false)
#expect(throws: ConfigError.self) { try ep.value(forKey: "BOOL_THROWS_ERROR_EMPTY", type: .bool) }
#expect(throws: ConfigError.self) { try ep.value(forKey: "BOOL_THROWS_ERROR_NOT_BOOL_STRING", type: .bool) }
#expect(try ep.value(forKey: "BOOLY_ARRAY_TRUE", type: .boolArray).value == .init([true, true, true], isSecret: false))
#expect(try ep.value(forKey: "BOOLY_ARRAY_FALSE", type: .boolArray).value == .init([false, false, false], isSecret: false))
#expect(throws: ConfigError.self) { try ep.value(forKey: "BOOLY_ARRAY_THROWS_1", type: .boolArray) }
#expect(throws: ConfigError.self) { try ep.value(forKey: "BOOLY_ARRAY_THROWS_2", type: .boolArray) }
}

@available(Configuration 1.0, *)
@Test func compat() async throws {
try await ProviderCompatTest(provider: provider).runTest()
Expand Down