| // 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 `<key>=<value>`. | |
| /// | |
| /// 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<String> commandLineDefines = const [], | |
| Uri? workingDirectory, | |
| Map<String, String> environment = const {}, | |
| Map<String, dynamic> 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 `<key>=<value>`. | |
| /// | |
| /// 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<String> commandLineDefines = const [], | |
| Uri? workingDirectory, | |
| Map<String, String> environment = const {}, | |
| String? fileContents, | |
| Uri? fileSourceUri, | |
| }) { | |
| // Parse config file. | |
| final Map<String, dynamic> 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<Config> fromArguments({ | |
| required List<String> arguments, | |
| Map<String, String>? 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<String>, | |
| 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<String> arguments, | |
| Map<String, String>? 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<String>, | |
| 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<String>? 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<String>? 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<String> 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<String>? optionalStringList( | |
| String key, { | |
| core.bool combineAllConfigs = true, | |
| String? splitCliPattern, | |
| String? splitEnvironmentPattern, | |
| }) { | |
| List<String>? 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<Uri> 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<Uri>? optionalPathList( | |
| String key, { | |
| core.bool combineAllConfigs = true, | |
| String? splitCliPattern, | |
| String? splitEnvironmentPattern, | |
| core.bool resolveUri = true, | |
| }) { | |
| List<Uri>? 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<T>(String key) { | |
| T? value; | |
| for (final source in _sources) { | |
| value ??= source.optionalValueOf<T>(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); | |
| } | |
| } |