| // 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); |
| } |
| } |