blob: 3d67be9dc4cdb2eb1df1dddc32dc5de35cbbc12e [file] [log] [blame]
import 'package:logging/logging.dart';
import 'package:yaml/yaml.dart';
final _logger = Logger('ffigen.config_provider.config');
/// Base class for all ConfigSpecs to extend.
///
/// [TE] - type input for [transform], [RE] - type input for [result].
///
/// Validation -
///
/// - [customValidation] is called after the ConfigSpec hierarchical validations
/// are completed.
///
/// Extraction -
///
/// - The data is first validated, if invalid it throws a
/// ConfigSpecExtractionError.
/// - The extracted data from the child(s) is collected and the value is
/// transformed via [transform] (if specified).
/// - Finally the [result] closure is called (if specified).
abstract class ConfigSpec<TE extends Object?, RE extends Object?> {
/// Used to generate and refer the reference definition generated in json
/// schema. Must be unique for a nested Schema.
String? schemaDefName;
/// Used to generate the description field in json schema.
String? schemaDescription;
/// Custom validation hook, called post validation if successful.
bool Function(ConfigValue node)? customValidation;
/// Used to transform the payload to another type before passing to parent
/// nodes and [result].
RE Function(ConfigValue<TE> node)? transform;
/// Called when final result is prepared via [_extractNode].
void Function(ConfigValue<RE> node)? result;
ConfigSpec({
required this.schemaDefName,
required this.schemaDescription,
required this.customValidation,
required this.transform,
required this.result,
});
bool _validateNode(ConfigValue o, {bool log = true});
ConfigValue<RE> _extractNode(ConfigValue o);
/// ConfigSpec objects should call [_getJsonRefOrSchemaNode] instead to get the
/// child json schema.
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs);
Map<String, dynamic> _getJsonRefOrSchemaNode(Map<String, dynamic> defs) {
if (schemaDefName == null) {
return _generateJsonSchemaNode(defs);
}
defs.putIfAbsent(schemaDefName!, () => _generateJsonSchemaNode(defs));
return {r"$ref": "#/\$defs/$schemaDefName"};
}
Map<String, dynamic> generateJsonSchema(String schemaId) {
final defs = <String, dynamic>{};
final schemaMap = _generateJsonSchemaNode(defs);
return {
r"$id": schemaId,
r"$comment":
"This file is generated. To regenerate run: dart tool/generate_json_schema.dart in github.com/dart-lang/ffigen",
r"$schema": "https://json-schema.org/draft/2020-12/schema",
...schemaMap,
r"$defs": defs,
};
}
/// Run validation on an object [value].
bool validate(dynamic value) {
return _validateNode(ConfigValue(path: [], value: value));
}
/// Extract ConfigSpecNode from [value]. This will call the [transform] for all
/// underlying ConfigSpecs if valid.
/// Should ideally only be called if [validate] returns True. Throws
/// [ConfigSpecExtractionError] if any validation fails.
ConfigValue extract(dynamic value) {
return _extractNode(ConfigValue(path: [], value: value));
}
}
/// An individual value in a config for a specific [path] instantiated from a [ConfigSpec].
///
/// A config value contains both the [value] that users of the configuration would want
/// to use, as well as the [rawValue] that was provided as input to the configuration.
class ConfigValue<TE> {
/// The path to this node.
///
/// E.g - ["path", "to", "arr", "[1]", "item"]
final List<String> path;
/// Get a string representation for path.
///
/// E.g - "path -> to -> arr -> [1] -> item"
String get pathString => path.join(" -> ");
/// Contains the underlying node value after all transformations and
/// default values have been applied.
final TE value;
/// Contains the raw underlying node value. Would be null for fields populated
/// but default values
final Object? rawValue;
ConfigValue({
required this.path,
required this.value,
Object? rawValue,
bool nullRawValue = false,
}) : rawValue = nullRawValue ? null : (rawValue ?? value);
/// Copy object with a different value.
ConfigValue<T> withValue<T>(T value, Object? rawValue) {
return ConfigValue<T>(
path: path,
value: value,
rawValue: rawValue,
nullRawValue: rawValue == null,
);
}
/// Transforms this with a nullable [transform] or return itself
/// and calls the [result] callback
ConfigValue<RE> transformOrThis<RE extends Object?>(
RE Function(ConfigValue<TE> value)? transform,
void Function(ConfigValue<RE> node)? resultCallback,
) {
ConfigValue<RE> returnValue;
if (transform != null) {
returnValue = this.withValue(transform.call(this), rawValue);
} else {
returnValue = this.withValue(this.value as RE, rawValue);
}
resultCallback?.call(returnValue);
return returnValue;
}
/// Returns true if [value] is of Type [T].
bool checkType<T>({bool log = true}) {
if (value is! T) {
if (log) {
_logger.severe(
"Expected value of key '$pathString' to be of type '$T' (Got ${value.runtimeType}).");
}
return false;
}
return true;
}
}
class ConfigSpecExtractionError extends Error {
final ConfigValue? item;
final String message;
ConfigSpecExtractionError(this.item, [this.message = "Invalid ConfigSpec"]);
@override
String toString() {
if (item != null) {
return "$runtimeType: $message @ ${item!.pathString}";
}
return "$runtimeType: $message";
}
}
class HeterogeneousMapEntry {
final String key;
final ConfigSpec valueConfigSpec;
final Object? Function(ConfigValue<void> o)? defaultValue;
void Function(ConfigValue<Object?> node)? resultOrDefault;
final bool required;
HeterogeneousMapEntry({
required this.key,
required this.valueConfigSpec,
this.defaultValue,
this.resultOrDefault,
this.required = false,
});
}
enum AdditionalProperties { Allow, Warn, Error }
/// ConfigSpec for a Map which has a fixed set of known keys.
///
/// [CE] typecasts result from entries->{}->valueConfigSpec.
///
/// [RE] typecasts result returned by this node.
class HeterogeneousMapConfigSpec<CE extends Object?, RE extends Object?>
extends ConfigSpec<Map<dynamic, CE>, RE> {
final List<HeterogeneousMapEntry> entries;
final Set<String> allKeys;
final Set<String> requiredKeys;
final AdditionalProperties additionalProperties;
HeterogeneousMapConfigSpec({
required this.entries,
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
this.additionalProperties = AdditionalProperties.Warn,
}) : requiredKeys = {
for (final kv in entries.where((kv) => kv.required)) kv.key
},
allKeys = {for (final kv in entries) kv.key};
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<Map>(log: log)) {
return false;
}
var result = true;
final inputMap = (o.value as Map);
for (final requiredKey in requiredKeys) {
if (!inputMap.containsKey(requiredKey)) {
if (log) {
_logger.severe(
"Key '${[...o.path, requiredKey].join(' -> ')}' is required.");
}
result = false;
}
}
for (final entry in entries) {
final path = [...o.path, entry.key.toString()];
if (!inputMap.containsKey(entry.key)) {
continue;
}
final configSpecNode =
ConfigValue(path: path, value: inputMap[entry.key]);
if (!entry.valueConfigSpec._validateNode(configSpecNode, log: log)) {
result = false;
continue;
}
}
if (additionalProperties != AdditionalProperties.Allow) {
for (final key in inputMap.keys) {
if (!allKeys.contains(key)) {
if (log) {
_logger.severe("Unknown key - '${[...o.path, key].join(' -> ')}'.");
}
if (additionalProperties == AdditionalProperties.Error) {
result = false;
}
}
}
}
if (!result && customValidation != null) {
return customValidation!.call(o);
}
return result;
}
dynamic _getAllDefaults(ConfigValue o) {
final result = <dynamic, CE>{};
for (final entry in entries) {
final path = [...o.path, entry.key];
if (entry.defaultValue != null) {
result[entry.key] = entry.defaultValue!
.call(ConfigValue(path: path, value: null)) as CE;
} else if (entry.valueConfigSpec is HeterogeneousMapConfigSpec) {
final defaultValue =
(entry.valueConfigSpec as HeterogeneousMapConfigSpec)
._getAllDefaults(ConfigValue(path: path, value: null));
if (defaultValue != null) {
result[entry.key] =
(entry.valueConfigSpec as HeterogeneousMapConfigSpec)
._getAllDefaults(ConfigValue(path: path, value: null)) as CE;
}
}
if (result.containsKey(entry.key) && entry.resultOrDefault != null) {
// Call resultOrDefault hook for HeterogeneousMapEntry.
entry.resultOrDefault!.call(ConfigValue(
path: path, value: result[entry.key], nullRawValue: true));
}
}
return result.isEmpty
? null
: o
.withValue(result, null)
.transformOrThis(transform, this.result)
.value;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<Map>(log: false)) {
throw ConfigSpecExtractionError(o);
}
final inputMap = (o.value as Map);
final childExtracts = <dynamic, CE>{};
for (final requiredKey in requiredKeys) {
if (!inputMap.containsKey(requiredKey)) {
throw ConfigSpecExtractionError(
null, "Invalid config spec, missing required key - $requiredKey.");
}
}
for (final entry in entries) {
final path = [...o.path, entry.key.toString()];
if (!inputMap.containsKey(entry.key)) {
// No value specified, fill in with default value instead.
if (entry.defaultValue != null) {
childExtracts[entry.key] = entry.defaultValue!
.call(ConfigValue(path: path, value: null)) as CE;
} else if (entry.valueConfigSpec is HeterogeneousMapConfigSpec) {
final defaultValue =
(entry.valueConfigSpec as HeterogeneousMapConfigSpec)
._getAllDefaults(ConfigValue(path: path, value: null));
if (defaultValue != null) {
childExtracts[entry.key] = (entry.valueConfigSpec
as HeterogeneousMapConfigSpec)
._getAllDefaults(ConfigValue(path: path, value: null)) as CE;
}
}
} else {
// Extract value from node.
final configSpecNode =
ConfigValue(path: path, value: inputMap[entry.key]);
if (!entry.valueConfigSpec._validateNode(configSpecNode, log: false)) {
throw ConfigSpecExtractionError(configSpecNode);
}
childExtracts[entry.key] =
entry.valueConfigSpec._extractNode(configSpecNode).value as CE;
}
if (childExtracts.containsKey(entry.key) &&
entry.resultOrDefault != null) {
// Call resultOrDefault hook for HeterogeneousMapEntry.
entry.resultOrDefault!.call(ConfigValue(
path: path, value: childExtracts[entry.key], nullRawValue: true));
}
}
if (additionalProperties == AdditionalProperties.Error) {
for (final key in inputMap.keys) {
if (!allKeys.contains(key)) {
throw ConfigSpecExtractionError(
o, "Invalid ConfigSpec: additional properties not allowed.");
}
}
}
return o
.withValue(childExtracts, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "object",
if (additionalProperties != AdditionalProperties.Allow)
"additionalProperties": false,
if (schemaDescription != null) "description": schemaDescription!,
if (entries.isNotEmpty)
"properties": {
for (final kv in entries)
kv.key: kv.valueConfigSpec._getJsonRefOrSchemaNode(defs)
},
if (requiredKeys.isNotEmpty) "required": requiredKeys.toList(),
};
}
}
/// ConfigSpec for a Map that can have any number of keys.
///
/// [CE] typecasts result from keyValueConfigSpecs->{}->valueConfigSpec.
///
/// [RE] typecasts result returned by this node.
class MapConfigSpec<CE extends Object?, RE extends Object?>
extends ConfigSpec<Map<dynamic, CE>, RE> {
/// Both [keyRegexp] - [valueConfigSpec] pair is used to match a set of
/// key-value input. Atleast one entry must match against an input for it
/// to be considered valid.
///
/// Note: [keyRegexp] will be matched against key.toString()
final List<({String keyRegexp, ConfigSpec valueConfigSpec})>
keyValueConfigSpecs;
MapConfigSpec({
required this.keyValueConfigSpecs,
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<Map>(log: log)) {
return false;
}
var result = true;
final inputMap = (o.value as Map);
for (final MapEntry(key: key, value: value) in inputMap.entries) {
final configSpecNode =
ConfigValue(path: [...o.path, key.toString()], value: value);
var keyValueMatch = false;
/// Running first time with no logs.
for (final (keyRegexp: keyRegexp, valueConfigSpec: valueConfigSpec)
in keyValueConfigSpecs) {
if (RegExp(keyRegexp, dotAll: true).hasMatch(key.toString()) &&
valueConfigSpec._validateNode(configSpecNode, log: false)) {
keyValueMatch = true;
break;
}
}
if (!keyValueMatch) {
result = false;
// No configSpec matched, running again to print logs this time.
if (log) {
_logger.severe(
"'${configSpecNode.pathString}' must match atleast one of the allowed key regex and configSpec.");
for (final (keyRegexp: keyRegexp, valueConfigSpec: valueConfigSpec)
in keyValueConfigSpecs) {
if (!RegExp(keyRegexp, dotAll: true).hasMatch(key.toString())) {
_logger.severe(
"'${configSpecNode.pathString}' does not match regex - '$keyRegexp' (Input - $key)");
continue;
}
if (valueConfigSpec._validateNode(configSpecNode, log: log)) {
continue;
}
}
}
}
}
if (!result && customValidation != null) {
return customValidation!.call(o);
}
return result;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<Map>(log: false)) {
throw ConfigSpecExtractionError(o);
}
final inputMap = (o.value as Map);
final childExtracts = <dynamic, CE>{};
for (final MapEntry(key: key, value: value) in inputMap.entries) {
final configSpecNode =
ConfigValue(path: [...o.path, key.toString()], value: value);
var keyValueMatch = false;
for (final (keyRegexp: keyRegexp, valueConfigSpec: valueConfigSpec)
in keyValueConfigSpecs) {
if (RegExp(keyRegexp, dotAll: true).hasMatch(key.toString()) &&
valueConfigSpec._validateNode(configSpecNode, log: false)) {
childExtracts[key] =
valueConfigSpec._extractNode(configSpecNode).value as CE;
keyValueMatch = true;
break;
}
}
if (!keyValueMatch) {
throw ConfigSpecExtractionError(configSpecNode);
}
}
return o
.withValue(childExtracts, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "object",
if (schemaDescription != null) "description": schemaDescription!,
if (keyValueConfigSpecs.isNotEmpty)
"patternProperties": {
for (final (keyRegexp: keyRegexp, valueConfigSpec: valueConfigSpec)
in keyValueConfigSpecs)
keyRegexp: valueConfigSpec._getJsonRefOrSchemaNode(defs)
}
};
}
}
/// ConfigSpec for a List.
///
/// [CE] typecasts result from [childConfigSpec].
///
/// [RE] typecasts result returned by this node.
class ListConfigSpec<CE extends Object?, RE extends Object?>
extends ConfigSpec<List<CE>, RE> {
final ConfigSpec childConfigSpec;
ListConfigSpec({
required this.childConfigSpec,
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<YamlList>(log: log)) {
return false;
}
final inputList = (o.value as YamlList).cast<dynamic>();
var result = true;
for (final (i, input) in inputList.indexed) {
final configSpecNode =
ConfigValue(path: [...o.path, "[$i]"], value: input);
if (!childConfigSpec._validateNode(configSpecNode, log: log)) {
result = false;
continue;
}
}
if (!result && customValidation != null) {
return customValidation!.call(o);
}
return result;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<YamlList>(log: false)) {
throw ConfigSpecExtractionError(o);
}
final inputList = (o.value as YamlList).cast<dynamic>();
final childExtracts = <CE>[];
for (final (i, input) in inputList.indexed) {
final configSpecNode =
ConfigValue(path: [...o.path, i.toString()], value: input);
if (!childConfigSpec._validateNode(configSpecNode, log: false)) {
throw ConfigSpecExtractionError(configSpecNode);
}
childExtracts
.add(childConfigSpec._extractNode(configSpecNode).value as CE);
}
return o
.withValue(childExtracts, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "array",
if (schemaDescription != null) "description": schemaDescription!,
"items": childConfigSpec._getJsonRefOrSchemaNode(defs),
};
}
}
/// ConfigSpec for a String.
///
/// [RE] typecasts result returned by this node.
class StringConfigSpec<RE extends Object?> extends ConfigSpec<String, RE> {
final String? pattern;
final RegExp? _regexp;
StringConfigSpec({
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
this.pattern,
}) : _regexp = pattern == null ? null : RegExp(pattern, dotAll: true);
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<String>(log: log)) {
return false;
}
if (!(_regexp?.hasMatch(o.value as String) ?? true)) {
if (log) {
_logger.severe(
"Expected value of key '${o.pathString}' to match pattern $pattern (Input - ${o.value}).");
}
return false;
}
if (customValidation != null) {
return customValidation!.call(o);
}
return true;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<String>(log: false)) {
throw ConfigSpecExtractionError(o);
}
return o
.withValue(o.value as String, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "string",
if (schemaDescription != null) "description": schemaDescription!,
if (pattern != null) "pattern": pattern,
};
}
}
/// ConfigSpec for an Int.
///
/// [RE] typecasts result returned by this node.
class IntConfigSpec<RE extends Object?> extends ConfigSpec<int, RE> {
IntConfigSpec({
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<int>(log: log)) {
return false;
}
if (customValidation != null) {
return customValidation!.call(o);
}
return true;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<int>(log: false)) {
throw ConfigSpecExtractionError(o);
}
return o
.withValue(o.value as int, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "integer",
if (schemaDescription != null) "description": schemaDescription!,
};
}
}
/// ConfigSpec for an object where only specific values are allowed.
/// [CE] is the type for elements in [allowedValues].
///
/// [RE] typecasts result returned by this node.
class EnumConfigSpec<CE extends Object?, RE extends Object?>
extends ConfigSpec<CE, RE> {
Set<CE> allowedValues;
EnumConfigSpec({
required this.allowedValues,
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!allowedValues.contains(o.value)) {
if (log) {
_logger.severe(
"'${o.pathString}' must be one of the following - $allowedValues (Got ${o.value})");
}
return false;
}
if (customValidation != null) {
return customValidation!.call(o);
}
return true;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!allowedValues.contains(o.value)) {
throw ConfigSpecExtractionError(o);
}
return o
.withValue(o.value as CE, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"enum": allowedValues.toList(),
if (schemaDescription != null) "description": schemaDescription!,
};
}
}
/// ConfigSpec for a bool.
///
/// [RE] typecasts result returned by this node.
class BoolConfigSpec<RE> extends ConfigSpec<bool, RE> {
BoolConfigSpec({
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
if (!o.checkType<bool>(log: log)) {
return false;
}
if (customValidation != null) {
return customValidation!.call(o);
}
return true;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
if (!o.checkType<bool>(log: false)) {
throw ConfigSpecExtractionError(o);
}
return o
.withValue(o.value as bool, o.rawValue)
.transformOrThis(transform, result);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
"type": "boolean",
if (schemaDescription != null) "description": schemaDescription!,
};
}
}
/// ConfigSpec that requires atleast one underlying match.
///
/// [TE] typecasts the result returned by the the first valid [childConfigSpecs].
///
/// [RE] typecasts result returned by this node.
class OneOfConfigSpec<TE extends Object?, RE extends Object?>
extends ConfigSpec<TE, RE> {
final List<ConfigSpec> childConfigSpecs;
OneOfConfigSpec({
required this.childConfigSpecs,
super.schemaDefName,
super.schemaDescription,
super.customValidation,
super.transform,
super.result,
});
@override
bool _validateNode(ConfigValue o, {bool log = true}) {
// Running first time with no logs.
for (final spec in childConfigSpecs) {
if (spec._validateNode(o, log: false)) {
if (customValidation != null) {
return customValidation!.call(o);
}
return true;
}
}
// No configSpec matched, running again to print logs this time.
if (log) {
_logger.severe(
"'${o.pathString}' must match atleast one of the allowed configSpec -");
for (final spec in childConfigSpecs) {
spec._validateNode(o, log: log);
}
}
return false;
}
@override
ConfigValue<RE> _extractNode(ConfigValue o) {
for (final spec in childConfigSpecs) {
if (spec._validateNode(o, log: false)) {
return o
.withValue(spec._extractNode(o).value as TE, o.rawValue)
.transformOrThis(transform, result);
}
}
throw ConfigSpecExtractionError(o);
}
@override
Map<String, dynamic> _generateJsonSchemaNode(Map<String, dynamic> defs) {
return {
if (schemaDescription != null) "description": schemaDescription!,
r"$oneOf": childConfigSpecs
.map((child) => child._getJsonRefOrSchemaNode(defs))
.toList(),
};
}
}