blob: 6312029398516319225850eb79fbb6287a6cc657 [file] [log] [blame]
// 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);
}
}