Merge pull request 'Basic implementation' (#3) from develop into master
Reviewed-on: http://code.merlin.local:3000/adan/Conf/pulls/3
This commit is contained in:
commit
a7b49b69e6
|
@ -6,20 +6,11 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Conf",
|
name: "Conf",
|
||||||
products: [
|
products: [
|
||||||
.library(name: "Conf", targets: ["Conf"]),
|
.library(name: "Conf", targets: ["Conf"])
|
||||||
// .executable(name: "Demo", targets: ["DemoApp"])
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
|
||||||
name: "DemoApp",
|
|
||||||
dependencies: [
|
|
||||||
"Conf",
|
|
||||||
]),
|
|
||||||
.testTarget(
|
|
||||||
name: "DemoAppTests",
|
|
||||||
dependencies: ["DemoApp"]),
|
|
||||||
.target(
|
.target(
|
||||||
name: "Conf",
|
name: "Conf",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Config {
|
||||||
|
func load(env filename: String) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.file(filename), parser: Parser.donEnv))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(env data: Data) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.direct(data), parser: Parser.donEnv))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(json filename: String) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.file(filename), parser: Parser.json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(json data: Data) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.direct(data), parser: Parser.json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(plist filename: String) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.file(filename), parser: Parser.plist))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(plist data: Data) throws {
|
||||||
|
try load(from: DefaultConfigurationProvider(loader: Fetcher.direct(data), parser: Parser.plist))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
/// Key-value storage that can be filled from multiple sources
|
||||||
|
public final class Config {
|
||||||
|
public init(useEnvironment: Bool = false) {
|
||||||
|
isBackedByEnvironment = useEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBackedByEnvironment: Bool
|
||||||
|
private let environment = Environment()
|
||||||
|
private var data = [Key: String]()
|
||||||
|
|
||||||
|
func value(for key: Key) -> String? {
|
||||||
|
return data[key] ?? envValue(for: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envValue(for key: Key) -> String? {
|
||||||
|
guard isBackedByEnvironment,
|
||||||
|
key.path.count == 1,
|
||||||
|
let variable = key.path.first
|
||||||
|
else { return nil }
|
||||||
|
return environment[variable]
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(value: String?, for key: Key) {
|
||||||
|
data[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript(_ key: Key) -> String? {
|
||||||
|
get { value(for: key) }
|
||||||
|
set { set(value: newValue, for: key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Value>(_ key: Key) -> Value? where Value: LosslessStringConvertible {
|
||||||
|
get { value(for: key).flatMap(Value.init) }
|
||||||
|
set { set(value: newValue?.description, for: key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func dump() -> [Key: String] {
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(from provider: ConfigurationProvider) throws {
|
||||||
|
try data.merge(provider.configuration(),
|
||||||
|
uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
public enum ConfigurationError: Error {
|
||||||
|
case fetch(Error)
|
||||||
|
case parse(Error)
|
||||||
|
case decode(path: Key, value: Any)
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol ConfigurationProvider {
|
||||||
|
func configuration() throws -> [Key: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DefaultConfigurationProvider: ConfigurationProvider {
|
||||||
|
typealias Fetcher = () throws -> Data
|
||||||
|
typealias Parser = (Data) throws -> [String: Any]
|
||||||
|
|
||||||
|
init(loader: @escaping Fetcher, parser: @escaping Parser) {
|
||||||
|
self.load = loader
|
||||||
|
self.parse = parser
|
||||||
|
}
|
||||||
|
|
||||||
|
let load: Fetcher
|
||||||
|
let parse: Parser
|
||||||
|
|
||||||
|
func configuration() throws -> [Key: String] {
|
||||||
|
let rawData: Data
|
||||||
|
let parsedData: [String: Any]
|
||||||
|
do {
|
||||||
|
try rawData = load()
|
||||||
|
} catch { throw ConfigurationError.fetch(error) }
|
||||||
|
do {
|
||||||
|
parsedData = try parse(rawData)
|
||||||
|
} catch { throw ConfigurationError.parse(error) }
|
||||||
|
return try decode(currentKey: Key(), object: parsedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(currentKey: Key, object: Any) throws -> [Key: String] {
|
||||||
|
switch object {
|
||||||
|
case let value as LosslessStringConvertible:
|
||||||
|
return [currentKey: value.description]
|
||||||
|
case let value as [String: LosslessStringConvertible]:
|
||||||
|
return .init(uniqueKeysWithValues:
|
||||||
|
value.map { key, value in
|
||||||
|
(currentKey.child(key: key), value.description) })
|
||||||
|
case let dictionary as [String: Any]:
|
||||||
|
return try dictionary.map { key, value in
|
||||||
|
try decode(currentKey: currentKey.child(key: key), object: value)
|
||||||
|
}.reduce(into: [Key: String]()) { result, value in
|
||||||
|
result.merge(value) { current, _ in current }
|
||||||
|
}
|
||||||
|
case let array as [LosslessStringConvertible]:
|
||||||
|
return .init(uniqueKeysWithValues:
|
||||||
|
array.enumerated()
|
||||||
|
.map { ( currentKey.child(key: String($0)), $1.description) })
|
||||||
|
default:
|
||||||
|
throw ConfigurationError.decode(path: currentKey, value: object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Manages environment variables
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct Environment {
|
||||||
|
public init(info: ProcessInfo = .processInfo) {
|
||||||
|
self.info = info
|
||||||
|
}
|
||||||
|
|
||||||
|
private let info: ProcessInfo
|
||||||
|
|
||||||
|
public func dump() -> [String: String] {
|
||||||
|
info.environment
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript(_ key: String) -> String? {
|
||||||
|
get { info.environment[key] }
|
||||||
|
nonmutating set (value) {
|
||||||
|
if let raw = value {
|
||||||
|
setenv(key, raw, 1)
|
||||||
|
} else {
|
||||||
|
unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript(dynamicMember member: String) -> String? {
|
||||||
|
get { self[member] }
|
||||||
|
nonmutating set { self[member] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Value>(_ key: String) -> Value? where Value: LosslessStringConvertible {
|
||||||
|
get {
|
||||||
|
let raw: String? = self[key]
|
||||||
|
return raw.flatMap(Value.init)
|
||||||
|
}
|
||||||
|
nonmutating set (value) {
|
||||||
|
self[key] = value?.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Value>(dynamicMember member: String) -> Value? where Value: LosslessStringConvertible {
|
||||||
|
get { self[member] }
|
||||||
|
nonmutating set (value) { self[member] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Namespace for the predefined fetchers
|
||||||
|
enum Fetcher { }
|
||||||
|
|
||||||
|
extension Fetcher {
|
||||||
|
|
||||||
|
static let direct: (Data) -> DefaultConfigurationProvider.Fetcher = { data in
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
static let file: (String) -> DefaultConfigurationProvider.Fetcher = { configName in
|
||||||
|
return {
|
||||||
|
let url = URL(fileURLWithPath: configName, isDirectory: false)
|
||||||
|
print(url)
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
public func foo() -> Int {
|
|
||||||
return 42
|
|
||||||
}
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/// Configuration key that points to the value
|
||||||
|
public struct Key {
|
||||||
|
|
||||||
|
let path: [String]
|
||||||
|
|
||||||
|
public init(_ value: LosslessStringConvertible) {
|
||||||
|
path = [value.description]
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ elements: [LosslessStringConvertible]) {
|
||||||
|
path = elements.map(\.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ elements: LosslessStringConvertible...) {
|
||||||
|
self.init(elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
func child(key: String) -> Key {
|
||||||
|
Key(path + [key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Key: Equatable {}
|
||||||
|
extension Key: Hashable {}
|
||||||
|
|
||||||
|
extension Key: ExpressibleByArrayLiteral {
|
||||||
|
public init(arrayLiteral elements: LosslessStringConvertible...) {
|
||||||
|
self.init(elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Key: ExpressibleByStringLiteral {
|
||||||
|
public init(stringLiteral value: String) {
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Key: ExpressibleByIntegerLiteral {
|
||||||
|
public init(integerLiteral value: Int) {
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Key: CustomStringConvertible {
|
||||||
|
public var description: String {
|
||||||
|
"Key<\(path.description)>"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Namespace for the predefined parsers
|
||||||
|
enum Parser {
|
||||||
|
struct InvalidFormat: Error { let data: Data }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Parser {
|
||||||
|
static let json: DefaultConfigurationProvider.Parser = { data in
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data, options: [])
|
||||||
|
guard let values = object as? [String: Any] else {
|
||||||
|
throw InvalidFormat(data: data)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Parser {
|
||||||
|
static let donEnv: DefaultConfigurationProvider.Parser = { data in
|
||||||
|
func fail() throws -> Never {
|
||||||
|
throw InvalidFormat(data: data)
|
||||||
|
}
|
||||||
|
guard let contents = String(data: data, encoding: .utf8) else { try fail() }
|
||||||
|
var result = [String: String]()
|
||||||
|
let lines = contents.split(whereSeparator: { $0 == "\n" || $0 == "\r\n" }).lazy
|
||||||
|
for line in lines {
|
||||||
|
// ignore comments
|
||||||
|
if line.starts(with: "#") { continue }
|
||||||
|
// ignore lines that appear empty
|
||||||
|
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// extract key and value which are separated by an equals sign
|
||||||
|
let parts = line.split(separator: "=", maxSplits: 1)
|
||||||
|
guard parts.count == 2 else { try fail() }
|
||||||
|
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// remove surrounding quotes from value & convert remove escape character before any embedded quotes
|
||||||
|
if value[value.startIndex] == "\"" && value[value.index(before: value.endIndex)] == "\"" {
|
||||||
|
value.remove(at: value.startIndex)
|
||||||
|
value.remove(at: value.index(before: value.endIndex))
|
||||||
|
value = value.replacingOccurrences(of: "\\\"", with: "\"")
|
||||||
|
}
|
||||||
|
// remove surrounding single quotes from value & convert remove escape character before any embedded quotes
|
||||||
|
if value[value.startIndex] == "'" && value[value.index(before: value.endIndex)] == "'" {
|
||||||
|
value.remove(at: value.startIndex)
|
||||||
|
value.remove(at: value.index(before: value.endIndex))
|
||||||
|
value = value.replacingOccurrences(of: "'", with: "'")
|
||||||
|
}
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Parser {
|
||||||
|
static let plist: DefaultConfigurationProvider.Parser = { data in
|
||||||
|
let object = try PropertyListSerialization.propertyList(from: data, format: nil)
|
||||||
|
guard let values = object as? [String: Any] else {
|
||||||
|
throw InvalidFormat(data: data)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
import Conf
|
|
||||||
|
|
||||||
print("Hello, world!")
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class ConfigTests: XCTestCase {
|
||||||
|
|
||||||
|
var config: Config!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
config = Config()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnvironmentuse() {
|
||||||
|
XCTAssertNil(Config(useEnvironment: false)["PATH"])
|
||||||
|
XCTAssertNotNil(Config(useEnvironment: true)["PATH"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadConfigThrow() {
|
||||||
|
let errorConfigProvider = MockConfigurationProvider(config: [:], error: TestError())
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try config.load(from: errorConfigProvider)) { error in
|
||||||
|
XCTAssertTrue(error is TestError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadConfigSuccess() throws {
|
||||||
|
let loader = MockConfigurationProvider(config: ["key": "value"])
|
||||||
|
try config.load(from: loader)
|
||||||
|
XCTAssertEqual(config.dump(), [Key("key"): "value"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWrite() {
|
||||||
|
let key: Key = "key"
|
||||||
|
XCTAssertEqual(config.dump(), [:])
|
||||||
|
config.set(value: "value", for: key)
|
||||||
|
XCTAssertEqual(config.dump(), [Key("key"): "value"])
|
||||||
|
config.set(value: nil, for: key)
|
||||||
|
XCTAssertEqual(config.dump(), [:])
|
||||||
|
config.set(value: "another value", for: ["long", "path"])
|
||||||
|
XCTAssertEqual(config.dump(), [Key(["long", "path"]): "another value"])
|
||||||
|
|
||||||
|
config["subscript"] = "subscriptValue"
|
||||||
|
XCTAssertEqual(config.dump()["subscript"], "subscriptValue")
|
||||||
|
|
||||||
|
config["lossless"] = 42
|
||||||
|
XCTAssertEqual(config.dump()["lossless"], "42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRead() throws {
|
||||||
|
let loader = MockConfigurationProvider(config: [
|
||||||
|
"string": "value",
|
||||||
|
"int": "42",
|
||||||
|
"double": "23.5"
|
||||||
|
])
|
||||||
|
try config.load(from: loader)
|
||||||
|
XCTAssertEqual(config["blabla"], nil)
|
||||||
|
XCTAssertEqual(config["string"], "value")
|
||||||
|
XCTAssertEqual(config["int"], "42")
|
||||||
|
XCTAssertEqual(config["int"], 42)
|
||||||
|
XCTAssertEqual(config["double"], "23.5")
|
||||||
|
XCTAssertEqual(config["double"], 23.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockConfigurationProvider: ConfigurationProvider {
|
||||||
|
var config: [Key: String]
|
||||||
|
var error: Error?
|
||||||
|
func configuration() throws -> [Key: String] {
|
||||||
|
if let error = self.error { throw error }
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class CommonConfigurationProviderTests: XCTestCase {
|
||||||
|
func testFetchError() {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { throw TestError() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in [:] }
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try provider.configuration()) { error in
|
||||||
|
if case let ConfigurationError.fetch(detailError) = error {
|
||||||
|
XCTAssertTrue(detailError is TestError)
|
||||||
|
} else {
|
||||||
|
XCTFail("Invalid error returned \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParserError() {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in throw TestError() }
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try provider.configuration()) { error in
|
||||||
|
if case let ConfigurationError.parse(detailError) = error {
|
||||||
|
XCTAssertTrue(detailError is TestError)
|
||||||
|
} else {
|
||||||
|
XCTFail("Invalid error returned \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeError() {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let uuid = UUID()
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { data in
|
||||||
|
return [
|
||||||
|
"first": "value",
|
||||||
|
"second": uuid
|
||||||
|
]
|
||||||
|
}
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try provider.configuration()) { error in
|
||||||
|
if case let ConfigurationError.decode(path: key, value: value) = error {
|
||||||
|
XCTAssertEqual(key, "second")
|
||||||
|
XCTAssertEqual(value as? UUID, uuid)
|
||||||
|
} else {
|
||||||
|
XCTFail("Invalid error returned \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDataFlow() throws {
|
||||||
|
let data = "string".data(using: .utf8)!
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { data }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { parserInput in
|
||||||
|
XCTAssertEqual(parserInput, data)
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
XCTAssertTrue(configuration.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeValues() throws {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in
|
||||||
|
return [ "key": 22]
|
||||||
|
}
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
XCTAssertEqual(configuration, ["key": "22"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeArray() throws {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in
|
||||||
|
return [ "key": ["one", "two"]]
|
||||||
|
}
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
let expect: [Key: String] = [
|
||||||
|
Key(["key", "0"]): "one",
|
||||||
|
Key(["key", "1"]): "two"
|
||||||
|
]
|
||||||
|
XCTAssertEqual(configuration, expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeNestedValues() throws {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in
|
||||||
|
return [
|
||||||
|
"key": [ "nested": "value" ],
|
||||||
|
"one": [ "more": [
|
||||||
|
"deep": "value"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
let expect: [Key: String] = [
|
||||||
|
Key(["key", "nested"]): "value",
|
||||||
|
Key(["one", "more", "deep"]): "value"
|
||||||
|
]
|
||||||
|
XCTAssertEqual(configuration, expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeEmptyValues() throws {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in [:] }
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
XCTAssertEqual(configuration, [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeEmptyArray() throws {
|
||||||
|
let fetcher: DefaultConfigurationProvider.Fetcher = { Data() }
|
||||||
|
let parser: DefaultConfigurationProvider.Parser = { _ in [ "key": []] }
|
||||||
|
let provider = DefaultConfigurationProvider(loader: fetcher, parser: parser)
|
||||||
|
let configuration = try provider.configuration()
|
||||||
|
XCTAssertEqual(configuration, [:])
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class DotEnvParserTests: XCTestCase {
|
||||||
|
func testSuccess() throws {
|
||||||
|
let content = try Resource(name: "valid", type: "env").data()
|
||||||
|
let result = try Parser.donEnv(content)
|
||||||
|
let expect = [
|
||||||
|
"TZ": "Europe/Moscow",
|
||||||
|
"PUID": "1026",
|
||||||
|
"PGID": "100",
|
||||||
|
"IP": "127.0.0.1"
|
||||||
|
]
|
||||||
|
XCTAssertEqual(result as? [String: String], expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testError() throws {
|
||||||
|
let content = try Resource(name: "invalid", type: "env").data()
|
||||||
|
XCTAssertThrowsError(try Parser.donEnv(content)) { error in
|
||||||
|
XCTAssertTrue(error is Parser.InvalidFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class EnvironmentTests: XCTestCase {
|
||||||
|
|
||||||
|
static let sampleValues: [String: String] = [
|
||||||
|
"first": "string",
|
||||||
|
"integer": "1",
|
||||||
|
"float": "2.33",
|
||||||
|
"booleanTrue": "true",
|
||||||
|
"booleanFalse": "false",
|
||||||
|
"YES": "YES"
|
||||||
|
]
|
||||||
|
|
||||||
|
func testDump() throws {
|
||||||
|
let values = Self.sampleValues
|
||||||
|
let info = ProcessInfo.make(with: values)
|
||||||
|
let env = Environment(info: info)
|
||||||
|
|
||||||
|
let dumped = env.dump()
|
||||||
|
|
||||||
|
XCTAssertEqual(values, dumped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRead() throws {
|
||||||
|
let info = ProcessInfo.make(with: Self.sampleValues)
|
||||||
|
let env = Environment(info: info)
|
||||||
|
|
||||||
|
XCTAssertNil(env.blabla)
|
||||||
|
XCTAssertNil(env["blabla"])
|
||||||
|
|
||||||
|
XCTAssertEqual(env.first, "string")
|
||||||
|
XCTAssertNil(env.first as Int?)
|
||||||
|
XCTAssertEqual(env["first"], "string")
|
||||||
|
XCTAssertNil(env["first"] as Int?)
|
||||||
|
|
||||||
|
XCTAssertEqual(env.integer as Int?, 1)
|
||||||
|
XCTAssertEqual(env.integer, "1")
|
||||||
|
XCTAssertEqual(env["integer"] as Int?, 1)
|
||||||
|
XCTAssertEqual(env["integer"], "1")
|
||||||
|
|
||||||
|
XCTAssertEqual(env.float, "2.33")
|
||||||
|
XCTAssertEqual(env.float as Double?, 2.33)
|
||||||
|
XCTAssertEqual(env.float as Float?, 2.33)
|
||||||
|
XCTAssertNil(env.float as Int?)
|
||||||
|
XCTAssertEqual(env["float"], "2.33")
|
||||||
|
XCTAssertEqual(env["float"] as Double?, 2.33)
|
||||||
|
XCTAssertEqual(env["float"] as Float?, 2.33)
|
||||||
|
XCTAssertNil(env["float"] as Int?)
|
||||||
|
|
||||||
|
XCTAssertEqual(env.booleanTrue, "true")
|
||||||
|
XCTAssertEqual(env.booleanTrue as Bool?, true)
|
||||||
|
XCTAssertNil(env.booleanTrue as Int?)
|
||||||
|
XCTAssertEqual(env["booleanTrue"], "true")
|
||||||
|
XCTAssertEqual(env["booleanTrue"] as Bool?, true)
|
||||||
|
XCTAssertNil(env["booleanTrue"] as Int?)
|
||||||
|
|
||||||
|
XCTAssertEqual(env.booleanFalse, "false")
|
||||||
|
XCTAssertEqual(env.booleanFalse as Bool?, false)
|
||||||
|
XCTAssertNil(env.booleanFalse as Int?)
|
||||||
|
XCTAssertEqual(env["booleanFalse"], "false")
|
||||||
|
XCTAssertEqual(env["booleanFalse"] as Bool?, false)
|
||||||
|
XCTAssertNil(env["booleanFalse"] as Int?)
|
||||||
|
|
||||||
|
XCTAssertEqual(env.YES, "YES")
|
||||||
|
XCTAssertNil(env.YES as Bool?)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAdd() {
|
||||||
|
let env = Environment(info: ProcessInfo.instance)
|
||||||
|
|
||||||
|
XCTAssertNil(env.key1)
|
||||||
|
env.key1 = "value1"
|
||||||
|
XCTAssertEqual(env.key1, "value1")
|
||||||
|
|
||||||
|
XCTAssertNil(env.key2)
|
||||||
|
env["key2"] = "value2"
|
||||||
|
XCTAssertEqual(env.key2, "value2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemove() {
|
||||||
|
let env = Environment(info: ProcessInfo.instance)
|
||||||
|
env.key1 = "value1"
|
||||||
|
|
||||||
|
XCTAssertEqual(env.key1, "value1")
|
||||||
|
env.key1 = nil
|
||||||
|
XCTAssertNil(env.key1)
|
||||||
|
|
||||||
|
env["key2"] = "value2"
|
||||||
|
XCTAssertEqual(env["key2"], "value2")
|
||||||
|
env["key2"] = nil
|
||||||
|
XCTAssertNil(env["key2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdate() {
|
||||||
|
let env = Environment(info: ProcessInfo.instance)
|
||||||
|
env.key1 = "value"
|
||||||
|
|
||||||
|
XCTAssertEqual(env.key1, "value")
|
||||||
|
env.key1 = "new value"
|
||||||
|
XCTAssertEqual(env.key1, "new value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWrite() {
|
||||||
|
let env = Environment(info: ProcessInfo.instance)
|
||||||
|
|
||||||
|
let int = Int.random(in: Int.min...Int.max)
|
||||||
|
env.int = int
|
||||||
|
XCTAssertEqual(env.int, int)
|
||||||
|
|
||||||
|
let float = Float.random(in: 0...100)
|
||||||
|
env.float = float
|
||||||
|
XCTAssertEqual(env.float, float)
|
||||||
|
|
||||||
|
env.success = true
|
||||||
|
XCTAssertEqual(env.success, true)
|
||||||
|
|
||||||
|
env.failure = false
|
||||||
|
XCTAssertEqual(env.failure, false)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class FileFetcherTests: XCTestCase {
|
||||||
|
func testSuccess() throws {
|
||||||
|
let load = Fetcher.file("Tests/Resources/valid.env")
|
||||||
|
_ = try load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testError() throws {
|
||||||
|
let load = Fetcher.file("file that not exist")
|
||||||
|
XCTAssertThrowsError(try load())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
import XCTest
|
|
||||||
@testable import Conf
|
|
||||||
|
|
||||||
final class FooTests: XCTestCase {
|
|
||||||
|
|
||||||
func testFoo() throws {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class JSONParserTests: XCTestCase {
|
||||||
|
func testSuccess() throws {
|
||||||
|
let content = try Resource(name: "valid", type: "json").data()
|
||||||
|
_ = try Parser.json(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testError() throws {
|
||||||
|
let content = try Resource(name: "invalid", type: "json").data()
|
||||||
|
XCTAssertThrowsError(try Parser.json(content)) { error in
|
||||||
|
XCTAssertTrue(error is Parser.InvalidFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class KeyTests: XCTestCase {
|
||||||
|
func testCreation() {
|
||||||
|
let key1: Key = Key()
|
||||||
|
XCTAssertEqual(key1.path, [])
|
||||||
|
let key2: Key = "24"
|
||||||
|
XCTAssertEqual(key2.path, ["24"])
|
||||||
|
let key3: Key = 99
|
||||||
|
XCTAssertEqual(key3.path, ["99"])
|
||||||
|
let key4: Key = Key(23.4)
|
||||||
|
XCTAssertEqual(key4.path, ["23.4"])
|
||||||
|
let key5: Key = Key("some")
|
||||||
|
XCTAssertEqual(key5.path, ["some"])
|
||||||
|
let key6: Key = ["24", 72, 23.4, true]
|
||||||
|
XCTAssertEqual(key6.path, ["24", "72", "23.4", "true"])
|
||||||
|
let key7: Key = Key([1, 2, 3])
|
||||||
|
XCTAssertEqual(key7.path, ["1", "2", "3"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChild() {
|
||||||
|
XCTAssertEqual(Key("hello").child(key: "world").path, ["hello", "world"])
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
class MockProcessInfo: ProcessInfo {
|
||||||
|
init(_ env: [String: String]) { self.env = env }
|
||||||
|
var env: [String: String]
|
||||||
|
override var environment: [String: String] { env }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension ProcessInfo {
|
||||||
|
static var instance: ProcessInfo {
|
||||||
|
#if os(macOS)
|
||||||
|
return ProcessInfo()
|
||||||
|
#else
|
||||||
|
return ProcessInfo.processInfo
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func make(with env: [String: String]) -> ProcessInfo {
|
||||||
|
#if os(macOS)
|
||||||
|
return MockProcessInfo(env)
|
||||||
|
#else
|
||||||
|
let info = ProcessInfo.instance
|
||||||
|
info.clearEnv()
|
||||||
|
info.set(env: env)
|
||||||
|
return info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearEnv() {
|
||||||
|
environment.forEach {key, _ in
|
||||||
|
unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(env: [String: String]) {
|
||||||
|
env.forEach { key, value in
|
||||||
|
setenv(key, value, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import Conf
|
||||||
|
|
||||||
|
final class PlistParserTests: XCTestCase {
|
||||||
|
func testSuccess() throws {
|
||||||
|
let content = try Resource(name: "valid", type: "plist").data()
|
||||||
|
_ = try Parser.plist(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testError() throws {
|
||||||
|
let content = try Resource(name: "invalid", type: "plist").data()
|
||||||
|
XCTAssertThrowsError(try Parser.plist(content)) { error in
|
||||||
|
XCTAssertTrue(error is Parser.InvalidFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Resource {
|
||||||
|
let name: String
|
||||||
|
let type: String
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
init(name: String, type: String) {
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
url = Resource.resourceFolderURL.appendingPathComponent(name).appendingPathExtension(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content -
|
||||||
|
extension Resource {
|
||||||
|
func data() throws -> Data { try Data(contentsOf: url) }
|
||||||
|
func string() throws -> String { try String(contentsOf: url, encoding: .utf8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Path helpers -
|
||||||
|
extension Resource {
|
||||||
|
// expected folder structure
|
||||||
|
// <Some folder>
|
||||||
|
// - <Resources>
|
||||||
|
// - <resource files>
|
||||||
|
// - <Some test source folder>
|
||||||
|
// - <test case files>
|
||||||
|
// - <this file>
|
||||||
|
static let resourceFolderURL = testsFolderURL
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent(resourceFolder, isDirectory: true)
|
||||||
|
.standardized
|
||||||
|
static let testsFolderURL = sourceFileURL.deletingLastPathComponent()
|
||||||
|
private static let resourceFolder = "Resources"
|
||||||
|
private static let sourceFileURL = URL(fileURLWithPath: #file, isDirectory: false)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
struct TestError: Error {}
|
|
@ -1,43 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import class Foundation.Bundle
|
|
||||||
|
|
||||||
final class OutputTests: XCTestCase {
|
|
||||||
func testExample() throws {
|
|
||||||
// This is an example of a functional test case.
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
|
||||||
// results.
|
|
||||||
|
|
||||||
// Some of the APIs that we use below are available in macOS 10.13 and above.
|
|
||||||
guard #available(macOS 10.13, *) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let fooBinary = productsDirectory.appendingPathComponent("DemoApp")
|
|
||||||
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = fooBinary
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
process.standardOutput = pipe
|
|
||||||
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let output = String(data: data, encoding: .utf8)
|
|
||||||
|
|
||||||
XCTAssertEqual(output, "Hello, world!\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns path to the built products directory.
|
|
||||||
var productsDirectory: URL {
|
|
||||||
#if os(macOS)
|
|
||||||
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
|
|
||||||
return bundle.bundleURL.deletingLastPathComponent()
|
|
||||||
}
|
|
||||||
fatalError("couldn't find the products directory")
|
|
||||||
#else
|
|
||||||
return Bundle.main.bundleURL
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
## Sample invalid env file
|
||||||
|
Hello
|
|
@ -0,0 +1 @@
|
||||||
|
[1, 2, 3]
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<array>
|
||||||
|
<string>one</string>
|
||||||
|
<string>two</string>
|
||||||
|
</array>
|
||||||
|
</plist>
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Sample env file
|
||||||
|
TZ="Europe/Moscow"
|
||||||
|
|
||||||
|
IP='127.0.0.1'
|
||||||
|
|
||||||
|
# UNIX PUID and PGID, find with: id $USER
|
||||||
|
PUID=1026
|
||||||
|
PGID=100
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"param": "value",
|
||||||
|
"nested": {
|
||||||
|
"key": "values"
|
||||||
|
},
|
||||||
|
"array": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
Loading…
Reference in New Issue