embedded_config
A package which allows application configurations to be embedded directly into source code at build-time.
This package aims to solve the problem of needing a different application configuration per build environment (e.g. development vs. production). Using Dart's build system (build_runner), separate build.yaml files can be created per environment in combination with this package to embed different configurations. The required environment for the build can then be chosen using build_runner's --config
option.
Configuration can come from JSON files, YAML files, environment variables, and build.yaml files.
Contents
- Usage
- Merging configuration
- Inline configuration sources
- Complex configuration models
- Environment variables
Usage
1. Installing
This package makes use of package:build_runner. To avoid making build_runner a normal non-dev dependency, embedded_config has a partner package embedded_config_annotations.
The embedded_config_annotations package should be added as a normal dependency and embedded_config should be added as a dev dependency:
dependencies:
embedded_config_annotations: ^0.3.0
dev_dependencies:
embedded_config: ^0.4.0
The build_runner package should also be added as a dev dependency to your application.
2. Create an embedded config class
Configuration is embedded into your application by generating a class that extends an abstract "embedded config" class in your application. The generated extending class contains hard-coded configuration values, which come from configuration sources (we'll get to that next).
Create an abstract class annotated with @EmbeddedConfig
. This class must also have a default const
constructor. The @EmbeddedConfig
annotation is given a configuration source key which we'll connect later. In this class, you may define abstract getters which map to keys from configuration sources.
// file: app_config.dart
import 'package:embedded_config_annotations/embedded_config_annotations.dart';
// Add the generated file as a part
part 'app_config.embedded.dart';
@EmbeddedConfig('app_config')
abstract class AppConfig {
String get apiUrl;
const AppConfig();
}
Embedded config getters
By default, the name of the getter defined in Dart will be used as the configuration key when mapping configuration to the generated class. This has one exception, if the getter is private then the leading _
will be excluded.
A key different then the getter's name can be used by annotating the getter with @EmbeddedPropertyName
:
// This will cause the `api_url` configuration key to be mapped to this
// getter instead of `apiUrl`.
@EmbeddedPropertyName('api_url')
String get apiUrl;
Getter return types can be any of the following (including nullable versions of each):
String
int
double
num
bool
dynamic
List
(orList<T>
)Map
(orMap<String, V>
)- Other annotated embedded config classes in the same library
Note: When the element type of a list is explicitly
String
or the value type of a map is explicitlyString
, values from config sources will automatically be converted to strings.Example: A getter of
List<String>
given the config value of[24]
will get an embedded value of["24"]
.
Note: Non-nullable getters will result in that property being required. If none of the provided configuration sources define a non-null value for a non-nullable getter, then an error will be thrown at build-time.
3. Map configuration source(s)
Next, we need the actual configuration. Configuration can be specified in two places: configuration files in your application package and build.yaml files. Both of these sources can additionally make use of environment variables.
Valid configuration files include:
- JSON (
.json
) - YAML (
.yaml
,.yml
)
Configuration sources are mapped to annotated classes inside of your application's build.yaml files. This allows you to swap out configuration values for different builds.
In any of the application package's build.*.yaml files, configure the embedded_config
builder to map configuration sources:
targets:
$default:
builders:
embedded_config:
options:
# Maps the configuration source key 'app_config' to the
# JSON file at 'lib/app_config.json'
#
# The key 'app_config' is the same key given to the
# @EmbeddedConfig annotation on the embedded config class
app_config: 'lib/app_config.json'
# This can also be written like so, both mean the same thing
# app_config:
# source: 'lib/app_config.json'
To complete the example, given the AppConfig
class defined earlier, the app_config.json
file could contain:
{
"apiUrl": "/api"
}
When the application package is then built using the build.yaml file configuring embedded_config
, a file is generated containing the hard-coded configuration values extending the app_config.dart
file defined earlier. These generated files are always in the same directory as the annotated class's file and is named <original file name>.embedded.dart
(in this case it would be named app_config.embedded.dart
).
The contents of the generated file in this case would look like:
part of 'app_config.dart';
class _$AppConfigEmbedded extends AppConfig {
const _$AppConfigEmbedded();
@override
final apiUrl = '/api';
}
The annotated AppConfig
class can then expose the generated class in any way you choose. Since embedded configs are marked with const
constructors, one nice way is to expose it as a static const
singleton value:
// ...
@EmbeddedConfig('app_config')
abstract class AppConfig {
static const AppConfig instance = _$AppConfigEmbedded();
// ...
}
Merging configuration
Multiple configuration sources can be mapped to a single annotated class. This could allow you to define a "base" app_config.json
and then environment specific app_config.dev.json
and app_config.prod.json
files. A build.dev.yaml
file could map the dev
json onto the base file, and build.prod.yaml
with prod
.
For example, lets define two files app_config.json
and app_config.dev.json
:
// app_config.json
{
"prop1": "value1",
"sub": {
"prop2": true
}
}
// app_config.dev.json
{
"prop1": "value2",
"sub": {
"prop3": "value3"
}
}
Then, in build.dev.yaml
specify both as a source with dev
being last so that it overrides the base file:
targets:
$default:
builders:
embedded_config:
options:
app_config:
# Order matters, later sources override earlier sources!
source:
- 'lib/app_config.json'
- 'lib/app_config.dev.json'
When merging configurations, values are overridden at the lowest level possible! This means that building with build.dev.yaml
in this example would use the equivalent of this merged JSON document:
{
"prop1": "value2",
"sub": {
"prop2": true,
"prop3": "value3"
}
}
Keys cannot be removed when merging configurations. Properties can of course still be set to null
(in this example the sub
object could be "removed" by setting it to null
, but prop2
inside of sub
cannot be removed otherwise).
Inline configuration sources
Configuration can also be specified directly in build.yaml files. This is done through the inline
property:
targets:
$default:
builders:
embedded_config:
options:
app_config:
inline:
apiUrl: '/api2'
Inline configuration can also be combined with file sources, however inline will always be applied last and override all file sources. See Merging configuration to understand how inline sources would override file sources, it works the same as a file overriding another file.
Complex configuration models
When configuration is not a flat set of key/value pairs, multiple annotated embedded config classes can be used. This works by setting the path
property of the @EmbeddedConfig
annotation. The path is a list of configuration keys ordered from the root of the configuration to the desired sub-object.
For example, to embed the following configuration:
{
"prop1": "value1",
"sub": {
"prop2": "value2",
"sub2": {
"prop3": "value3"
}
}
}
You would need to create three annotated classes:
import 'package:embedded_config_annotations/embedded_config_annotations.dart';
// Embeds the top-level of the configuration
@EmbeddedConfig('app_config')
abstract class AppConfig {
String get prop1;
// Other embedded config classes in the same Dart library
// can be referenced as a getter type
AppSubConfig get sub;
const AppConfig();
}
// Embeds the top-level contents of the "sub" object
@EmbeddedConfig('app_config', path: ['sub'])
abstract class AppSubConfig {
String get prop2;
AppSub2Config get sub2;
const AppSubConfig();
}
// Embeds the top-level contents of the "sub2" object inside
// of the "sub" object
@EmbeddedConfig('app_config', path: ['sub', 'sub2'])
abstract class AppSub2Config {
String get prop3;
const AppSub2Config();
}
The path
property can also be used outside of this use-case and does not require a 'parent' class to also be defined.
Note: Any annotated classes which reference each other must be declared in the same Dart library.
Environment variables
Environment variables can be substituted for any string value in the configuration. This is done by starting a value with $
. For example, $BUILD_ID
would be substituted with the value of the environment variable BUILD_ID
.
Escaping environment variables
Note: If you need to substitute a value with an environment variable whose name starts with
$
, then you can simply write it as$$NAME
, which will simply look for the environment variable literally named$NAME
and not use any kind of escaping.
If a configuration value literally starts with $
and is not intended to be substituted for an environment variable, you can escape it with a \
. For example, to embed the literal value $BUILD_ID
your configuration would need the value \$BUILD_ID
.
This also means that embedding the literal value \$BUILD_ID
requires the configuration value \\$BUILD_ID
and so forth as any value starting with the regular expression ^\\+\$
has the first instance of \$
replaced with just $
.
Note: You do not need to escape every
$
character in your configuration value, only the first instance of it and only if the value starts with\
and$
characters. Trying to escape any other$
will result in the\
character remaining in the value.
Example: Embedding a build identifier from CI
The following is an example of how you could embed a build identifier from CI exposed as an environment variable into your application:
import 'package:embedded_config_annotations/embedded_config_annotations.dart';
@EmbeddedConfig('environment')
abstract class Environment {
String get buildId;
const Environment();
}
You could specify the environment variable in a JSON source:
{
"buildId": "$BUILD_ID"
}
Or, more simply in this case, specify it inline in build.yaml:
targets:
$default:
builders:
embedded_config:
options:
environment:
inline:
buildId: '$BUILD_ID'