// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:core' as core show bool, double, int; import 'dart:core' hide bool, double, int; import 'dart:io'; import 'cli_parser.dart'; import 'cli_source.dart'; import 'environment_parser.dart'; import 'environment_source.dart'; import 'file_parser.dart'; import 'file_source.dart'; import 'source.dart'; /// A hierarchical configuration. /// /// Configuration can be provided from three sources: commandline arguments, /// environment variables and configuration files. This configuration makes /// these accessible via a uniform API. /// /// Configuration can be provided via the three sources as follows: /// 1. commandline argument defines as `-Dsome_key=some_value`, /// 2. environment variables as `SOME_KEY=some_value`, and /// 3. config files as JSON or YAML as `{'some_key': 'some_value'}`. /// /// The default lookup behavior is that commandline argument defines take /// precedence over environment variables, which take precedence over the /// configuration file. /// /// If a single value is requested from this configuration, the first source /// that can provide the value will provide it. For example /// `config.string('some_key')` with `{'some_key': 'file_value'}` in the /// config file and `-Dsome_key=cli_value` as commandline argument returns /// `'cli_value'`. The implication is that you can not remove keys from the /// configuration file, only overwrite or append them. /// /// If a list value is requested from this configuration, the values provided /// by the various sources can be combined or not. For example /// `config.optionalStringList('some_key', combineAllConfigs: true)` returns /// `['cli_value', 'file_value']`. /// /// The config is hierarchical in nature, using `.` as the hierarchy separator /// for lookup and commandline defines. The hierarchy should be materialized in /// the JSON or YAML configuration file. For environment variables `__` is used /// as hierarchy separator. /// /// Hierarchical configuration can be provided via the three sources as follows: /// 1. commandline argument defines as `-Dsome_key.some_nested_key=some_value`, /// 2. environment variables as `SOME_KEY__SOME_NESTED_KEY=some_value`, and /// 3. config files as JSON or YAML as /// ```yaml /// some_key: /// some_nested_key: /// some_value /// ``` /// /// The config is opinionated on the format of the keys in the sources. /// * Command-line argument keys should be lower-cased alphanumeric /// characters or underscores, with `.` for hierarchy. /// * Environment variables keys should be upper-cased alphanumeric /// characters or underscores, with `__` for hierarchy. /// * Config files keys should be lower-cased alphanumeric /// characters or underscores. /// /// In the API they are made available lower-cased and with underscores, and /// `.` as hierarchy separator. class Config { final CliSource _cliSource; final EnvironmentSource _environmentSource; final FileSource _fileSource; /// Config sources, ordered by precedence. late final _sources = [_cliSource, _environmentSource, _fileSource]; Config._( this._cliSource, this._environmentSource, this._fileSource, ); /// Constructs a config by parsing the three sources. /// /// If provided, [commandLineDefines] must be a list of '='. /// /// If provided, [workingDirectory] is used to resolves paths inside /// [commandLineDefines]. /// /// If provided, [environment] must be a map containing environment variables. /// /// If provided, [fileParsed] must be valid parsed YSON or YAML (maps, lists, /// strings, integers, and booleans). /// /// If provided [fileSourceUri] is used to resolve paths inside /// [fileParsed] and to provide better error messages on parsing the /// configuration file. factory Config({ List commandLineDefines = const [], Uri? workingDirectory, Map environment = const {}, Map fileParsed = const {}, Uri? fileSourceUri, }) { // Parse config file. final fileConfig = FileParser().parseToplevelMap(fileParsed); // Parse CLI argument defines. final cliConfig = DefinesParser().parse(commandLineDefines); // Parse environment. final environmentConfig = EnvironmentParser().parse(environment); return Config._( CliSource(cliConfig, workingDirectory?.normalizePath()), EnvironmentSource(environmentConfig), FileSource(fileConfig, fileSourceUri?.normalizePath()), ); } /// Constructs a config by parsing the three sources. /// /// If provided, [commandLineDefines] must be a list of '='. /// /// If provided, [workingDirectory] is used to resolves paths inside /// [commandLineDefines]. /// /// If provided, [environment] must be a map containing environment variables. /// /// If provided, [fileContents] must be valid JSON or YAML. /// /// If provided [fileSourceUri] is used to resolve paths inside /// [fileContents] and to provide better error messages on parsing the /// configuration file. factory Config.fromConfigFileContents({ List commandLineDefines = const [], Uri? workingDirectory, Map environment = const {}, String? fileContents, Uri? fileSourceUri, }) { // Parse config file. final Map fileConfig; if (fileContents != null) { fileConfig = FileParser().parse( fileContents, sourceUrl: fileSourceUri, ); } else { fileConfig = {}; } // Parse CLI argument defines. final cliConfig = DefinesParser().parse(commandLineDefines); // Parse environment. final environmentConfig = EnvironmentParser().parse(environment); return Config._( CliSource(cliConfig, workingDirectory), EnvironmentSource(environmentConfig), FileSource(fileConfig, fileSourceUri), ); } /// Constructs a config by parsing CLI arguments and loading the config file. /// /// The [arguments] must be commandline arguments. /// /// If provided, [environment] must be a map containing environment variables. /// If not provided, [environment] defaults to [Platform.environment]. /// /// If provided, [workingDirectory] is used to resolves paths inside /// [environment]. /// If not provided, [workingDirectory] defaults to [Directory.current]. /// /// This async constructor is intended to be used directly in CLI files. static Future fromArguments({ required List arguments, Map? environment, Uri? workingDirectory, }) async { final results = CliParser().parse(arguments); // Load config file. final configFile = results['config'] as String?; String? fileContents; Uri? fileSourceUri; if (configFile != null) { fileContents = await File(configFile).readAsString(); fileSourceUri = Uri.file(configFile); } return Config.fromConfigFileContents( commandLineDefines: results['define'] as List, workingDirectory: workingDirectory ?? Directory.current.uri, environment: environment ?? Platform.environment, fileContents: fileContents, fileSourceUri: fileSourceUri, ); } /// Constructs a config by parsing CLI arguments and loading the config file. /// /// The [arguments] must be commandline arguments. /// /// If provided, [environment] must be a map containing environment variables. /// If not provided, [environment] defaults to [Platform.environment]. /// /// If provided, [workingDirectory] is used to resolves paths inside /// [environment]. /// If not provided, [workingDirectory] defaults to [Directory.current]. /// /// This synchronous constructor is intended to be used directly in CLI files. static Config fromArgumentsSync({ required List arguments, Map? environment, Uri? workingDirectory, }) { final results = CliParser().parse(arguments); // Load config file. final configFile = results['config'] as String?; String? fileContents; Uri? fileSourceUri; if (configFile != null) { fileContents = File(configFile).readAsStringSync(); fileSourceUri = Uri.file(configFile); } return Config.fromConfigFileContents( commandLineDefines: results['define'] as List, workingDirectory: workingDirectory ?? Directory.current.uri, environment: environment ?? Platform.environment, fileContents: fileContents, fileSourceUri: fileSourceUri, ); } /// Lookup a string value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// Throws if one of the configs does not contain the expected value type. /// /// If [validValues] is provided, throws if an unxpected value is provided. String string(String key, {Iterable? validValues}) { final value = optionalString(key, validValues: validValues); _throwIfNull(key, value); return value!; } /// Lookup an optional string value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// If [validValues] is provided, throws if an unxpected value is provided. String? optionalString(String key, {Iterable? validValues}) { String? value; for (final source in _sources) { value ??= source.optionalString(key); } if (value != null && validValues != null) { Source.throwIfUnexpectedValue(key, value, validValues); } return value; } /// Lookup an optional string list in this config. /// /// If none of the sources provide a list, lookup will fail. /// If an empty list is provided by one of the sources, lookup wil succeed. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// If [combineAllConfigs] combines results from cli, environment, and /// config file. Otherwise, precedence rules apply. /// /// If provided, [splitCliPattern] splits cli defines. /// For example: `-Dfoo=bar;baz` can be split on `;`. /// If not provided, a list can still be provided with multiple cli defines. /// For example: `-Dfoo=bar -Dfoo=baz`. /// /// If provided, [splitEnvironmentPattern] splits environment values. List stringList( String key, { core.bool combineAllConfigs = true, String? splitCliPattern, String? splitEnvironmentPattern, }) { final value = optionalStringList( key, combineAllConfigs: combineAllConfigs, splitCliPattern: splitCliPattern, splitEnvironmentPattern: splitEnvironmentPattern, ); _throwIfNull(key, value); return value!; } /// Lookup an optional string list in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// If [combineAllConfigs] combines results from cli, environment, and /// config file. Otherwise, precedence rules apply. /// /// If provided, [splitCliPattern] splits cli defines. /// For example: `-Dfoo=bar;baz` can be split on `;`. /// If not provided, a list can still be provided with multiple cli defines. /// For example: `-Dfoo=bar -Dfoo=baz`. /// /// If provided, [splitEnvironmentPattern] splits environment values. List? optionalStringList( String key, { core.bool combineAllConfigs = true, String? splitCliPattern, String? splitEnvironmentPattern, }) { List? result; for (final entry in { _cliSource: splitCliPattern, _environmentSource: splitEnvironmentPattern, _fileSource: null }.entries) { final source = entry.key; final splitPattern = entry.value; final value = source.optionalStringList(key, splitPattern: splitPattern); if (value != null) { if (combineAllConfigs) { (result ??= []).addAll(value); } else { return value; } } } return result; } static const boolStrings = { '0': false, '1': true, 'false': false, 'FALSE': false, 'no': false, 'NO': false, 'true': true, 'TRUE': true, 'yes': true, 'YES': true, }; /// Lookup a boolean value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be one of /// [boolStrings]. /// For the config file, it must be a boolean. /// /// Throws if one of the configs does not contain the expected value type. core.bool bool(String key) { final value = optionalBool(key); _throwIfNull(key, value); return value!; } /// Lookup an optional boolean value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be one of /// [boolStrings]. /// For the config file, it must be a boolean or null. core.bool? optionalBool(String key) { core.bool? value; for (final source in _sources) { value ??= source.optionalBool(key); } return value; } /// Lookup an integer value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be parseble /// by [core.int.parse]. /// For the config file, it must be an integer. core.int int(String key) { final value = optionalInt(key); _throwIfNull(key, value); return value!; } /// Lookup an optional integer value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be parseble /// by [core.int.parse]. /// For the config file, it must be an integer or null. core.int? optionalInt(String key) { core.int? value; for (final source in _sources) { value ??= source.optionalInt(key); } return value; } /// Lookup an double value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be parseble /// by [core.double.parse]. /// For the config file, it must be an double. core.double double(String key) { final value = optionalDouble(key); _throwIfNull(key, value); return value!; } /// Lookup an optional double value in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// For cli defines and environment variables, the value must be parseble /// by [core.double.parse]. /// For the config file, it must be an double or null. core.double? optionalDouble(String key) { core.double? value; for (final source in _sources) { value ??= source.optionalDouble(key); } return value; } /// Lookup a path in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// Throws if one of the configs does not contain the expected value type. /// /// If [resolveUri], resolves the paths in a source relative to the base /// uri of that source. The base uri for the config file is the path of the /// file. The base uri for environment values is the current working /// directory. /// /// If [mustExist], throws if the path doesn't resolve to a file or directory /// on the file system. /// /// Throws if one of the configs does not contain the expected value type. Uri path( String key, { core.bool resolveUri = true, core.bool mustExist = false, }) { final value = optionalPath(key, resolveUri: resolveUri, mustExist: mustExist); _throwIfNull(key, value); return value!; } /// Lookup an optional path in this config. /// /// First tries CLI argument defines, then environment variables, and /// finally the config file. /// /// Throws if one of the configs does not contain the expected value type. /// /// If [resolveUri], resolves the paths in a source relative to the base /// uri of that source. The base uri for the config file is the path of the /// file. The base uri for environment values is the current working /// directory. /// /// If [mustExist], throws if the path doesn't resolve to a file or directory /// on the file system. Uri? optionalPath( String key, { core.bool resolveUri = true, core.bool mustExist = false, }) { for (final source in _sources) { final path = source.optionalString(key); if (path != null) { final value = _pathToUri( path, resolveUri: resolveUri, baseUri: source.baseUri, ); if (mustExist) { _throwIfNotExists(key, value); } return value; } } return null; } Uri _pathToUri( String path, { required core.bool resolveUri, required Uri? baseUri, }) { final uri = Source.fileSystemPathToUri(path); if (resolveUri && baseUri != null) { return baseUri.resolveUri(uri); } return uri; } /// Lookup a list of paths in this config. /// /// If none of the sources provide a path, lookup will fail. /// If an empty list is provided by one of the sources, lookup wil succeed. /// /// If [combineAllConfigs] combines results from cli, environment, and /// config file. Otherwise, precedence rules apply. /// /// If provided, [splitCliPattern] splits cli defines. /// /// If provided, [splitEnvironmentPattern] splits environment values. /// /// If [resolveUri], resolves the paths in a source relative to the base /// uri of that source. The base uri for the config file is the path of the /// file. The base uri for environment values is the current working /// directory. List pathList( String key, { core.bool combineAllConfigs = true, String? splitCliPattern, String? splitEnvironmentPattern, core.bool resolveUri = true, }) { final value = optionalPathList( key, combineAllConfigs: combineAllConfigs, splitCliPattern: splitCliPattern, splitEnvironmentPattern: splitEnvironmentPattern, resolveUri: resolveUri, ); _throwIfNull(key, value); return value!; } /// Lookup an optional list of paths in this config. /// /// If [combineAllConfigs] combines results from cli, environment, and /// config file. Otherwise, precedence rules apply. /// /// If provided, [splitCliPattern] splits cli defines. /// /// If provided, [splitEnvironmentPattern] splits environment values. /// /// If [resolveUri], resolves the paths in a source relative to the base /// uri of that source. The base uri for the config file is the path of the /// file. The base uri for environment values is the current working /// directory. List? optionalPathList( String key, { core.bool combineAllConfigs = true, String? splitCliPattern, String? splitEnvironmentPattern, core.bool resolveUri = true, }) { List? result; for (final entry in { _cliSource: splitCliPattern, _environmentSource: splitEnvironmentPattern, _fileSource: null }.entries) { final source = entry.key; final splitPattern = entry.value; final paths = source.optionalStringList( key, splitPattern: splitPattern, ); if (paths != null) { final value = [ for (final path in paths) _pathToUri( path, resolveUri: resolveUri, baseUri: source.baseUri, ) ]; if (combineAllConfigs) { (result ??= []).addAll(value); } else { return value; } } } return result; } /// Lookup a value of type [T] in this configuration. /// /// Does not support specialized options such as `splitPattern`. One must /// use the specialized methods such as [optionalStringList] for that. /// /// If sources cannot lookup type [T], they return null. T valueOf(String key) { T? value; for (final source in _sources) { value ??= source.optionalValueOf(key); } if (null is! T) { _throwIfNull(key, value); } return value as T; } void _throwIfNull(String key, Object? value) { if (value == null) { throw FormatException('No value was provided for required key: $key'); } } void _throwIfNotExists(String key, Uri value) { final fileSystemEntity = value.fileSystemEntity; if (!fileSystemEntity.existsSync()) { throw FormatException("Path '$value' for key '$key' doesn't exist."); } } @override String toString() => 'Config($_sources)'; } extension on Uri { FileSystemEntity get fileSystemEntity { if (path.endsWith(Platform.pathSeparator) || path.endsWith('/')) { return Directory.fromUri(this); } return File.fromUri(this); } }