Placeholders
Placeholders can be used to replace values in configuration files and are useful for to inject values from external sources into your code.
Introduction
When a developer designs an application for distribution, they generally need to be able to customise its configurations,
so we generally end up with configuration files written in yaml
or json
.
This configuration imposes two major constraints:
- The data structure is important and is required for the application to function properly
- The values are static and cannot be changed without modifying the configuration file.
This second point is certainly the most important, as it is necessary to allow the application to function in a totally transparent way depending on the environment in which it is evolving.
We can therefore conclude that it is necessary to be able to inject values into the configuration files.
Usage
Define a config.yaml
configuration file containing a sayHello
key with the value Hello {username} {emoji}
.
sayHello: Hello {username} {emoji}
Static
void main() async {
final config = await File('config.yaml').readAsYaml();
final placeholder = Placeholder(values: {
'emoji': '๐',
});
final value = placeholder.apply(config['sayHello']);
print(value); // Hello {username} ๐
}
We then instantiated a Placeholder
object containing a set of values to inject into our configuration file.
Finally, we applied our placeholder to our configuration value.
We can see that the emoji
slot has been replaced by ๐ but not the username
key. This is because the username
value has not been declared in our placeholder.
Dynamic
The Placeholder
component can also be used to declare dynamic values, i.e. values that can be modified at runtime.
Let's take the example of the MessageCreate
event in our Discord client.
void main() async {
final config = await File('config.yaml').readAsYaml();
final placeholder = Placeholder(values: {
'emoji': '๐',
});
client.events.server.messageCreate((ServerMessage message) async {
if (!message.authorIsBot) {
final author = await message.resolveMember();
final str = placeholder.apply(config['sayHello'], values: {
'username': message.author.username,
});
await message.reply(content: str); // Hello John ๐
}
});
}
Case study
In this exercise, we will try out the example of a ticketing module.
- Sending a drop-down menu to choose a ticket type
- Creation of a chat room with specific permissions
The declaration enabling this variability is provided thanks to the notion of environment via data injection in the runtime.
Find out how environments work in the dedicated section.
Declaration
First, we will declare the environment variables required for our ticketing module to function correctly.
tickets:
requestRole:
name: Request role
description: Request a role on the server
roles:
- 1333087322459344936
- 1333087372446797947
ROLE_ADMIN=1333087322459344936
ROLE_MODERATOR=1333087372446797947
In this example, we declare a ticket with the unique identifier requestRole
, which will be used to request a role on
the server, and we also have a list of roles authorised to handle this type of ticket.
We can now make a modification to our configuration file.
tickets:
requestRole:
name: Request role
description: Request a role on the server
roles:
- {env.ROLE_ADMIN}
- {env.ROLE_MODERATOR}
Data model
Let's declare the data model that allows us to interact with our configuration file.
final class Ticket {
final String uid;
final String name;
final String description;
final List<int> roles;
Ticket({
required this.uid,
required this.name,
required this.description,
required this.roles,
});
factory Ticket.of(String uid, Map<String, dynamic> payload) {
return Ticket(
uid: payload['uid'],
name: payload['name'],
description: payload['description'],
roles: List.from(payload['roles'])
.map(Snowflake.parse)
.toList(),
);
}
}
We can now load our configuration file and instantiate our data model.
void main() async {
final config = await File('config.yaml').readAsYaml();
final List<Ticket> tickets = config['tickets'].entries
.map((entry) => Ticket.of(entry.key, entry.value))
.toList();
print(tickets);
}
Injection
At this stage, we have converted our configuration file into a data model that can be used by our application.
We now need to apply our environment variables to our entities. There are two ways of doing this:
- Conversion at instantiation of each
Ticket
model - Conversion on reading the value
In our case, we'll opt for an injection on reading the value when choosing a ticket type from a select menu.
We'll use an interactive component, more information in the dedicated section.
final class MessageTicketComponent implements InteractiveSelectMenu {
@override
String get customId => 'tickets::trigger';
final List<Ticket> tickets;
MessageTicketComponent(this.tickets);
@override
SelectMenuBuilderContract build() {
final builder = SelectMenuBuilder.text(customId)
..setPlaceholder('Please select a ticket');
for (final ticket in tickets) {
builder.addOption(
label: ticket.name,
value: ticket.uid,
);
}
return builder;
}
}
final class MessageTicketComponent implements InteractiveSelectMenu {
@override
String get customId => 'tickets::trigger';
final List<Ticket> tickets;
MessageTicketComponent(this.tickets);
@override
Future<void> handle(SelectContext ctx, values) async {
if (ctx case ServerSelectContext ctx) {
final ticket = tickets.firstWhere((ticket) => ticket.uid == ticketUid);
final server = await ctx.message.resolveServer();
await server.channels.create(
ChannelBuilder.text()
..setName('Ticket of ${ticket.name}')
..setPermissionOverwrite([
ChannelPermissionOverwrite(
id: server.id.value,
deny: [Permission.viewChannel],
type: ChannelPermissionOverwriteType.role,
),
for (final role in ticket.roles) {
ChannelPermissionOverwrite(
id: role,
allow: [Permission.viewChannel],
type: ChannelPermissionOverwriteType.role,
);
}
]);
}
}
@override
SelectMenuBuilderContract build() { ...}
}
final class MessageTicketComponent implements InteractiveSelectMenu {
@override
String get customId => 'tickets::trigger';
final placeholder = EnvPlaceholder();
final List<Ticket> tickets;
MessageTicketComponent(this.tickets);
@override
Future<void> handle(SelectContext ctx, values) async {
if (ctx case ServerSelectContext ctx) {
final ticket = tickets.firstWhere((ticket) => ticket.uid == ticketUid);
final server = await ctx.message.resolveServer();
await server.channels.create(
ChannelBuilder.text()
..setName('Ticket of ${ticket.name}')
..setPermissionOverwrite([
for (final role in ticket.roles) {
ChannelPermissionOverwrite(
id: role,
id: placeholder.apply(role),
allow: [Permission.viewChannel],
type: ChannelPermissionOverwriteType.role,
);
}
]);
}
}
@override
SelectMenuBuilderContract build() { ...}
}
Now that our component has been created, we need to send our menu to a target channel.
For our example, we'll use the channel in which the command will be executed.
final class TicketCommand with Component implements CommandDeclaration {
Future<void> handle(ServerCommandContext ctx) async {
if (ctx.channel case ServerTextChannel channel) {
final menu = components.get('tickets::trigger');
await channel.send(
content: 'Please select a ticket',
components: [
RowBuilder()
..addComponent(menu.build())
]);
}
}
@override
CommandDeclarationBuilder build() {
return CommandDeclarationBuilder()
..setName('ticket')
..setDescription('This is a ticket command')
..setHandle(handle);
}
}
Finally, we need to register our component with our client.
void main() async {
final config = await File('config.yaml').readAsYaml();
final List<Ticket> tickets = config['tickets'].entries
.map((entry) => Ticket.of(entry.key, entry.value))
.toList();
final client = ClientBuilder()
.build();
client.register(() => MessageTicketComponent(tickets));
client.register(TicketCommand.new);
await client.init();
}
Extension
Create your own placeholder by implementing the PlaceholderContract
interface.
final class MyPlaceholder implements PlaceholderContract {
final Map<String, dynamic> _values = {};
@override
String get identifier => 'my';
@override
Map<String, dynamic> get values => _values;
@override
String apply(String value) {
return values.entries.fold(value, (acc, element) {
final finalValue = switch (element.value) {
String() => element.value,
int() => element.value.toString(),
_ => throw Exception('Invalid type')
};
return acc
.replaceAll('{${element.key}}', finalValue)
.replaceAll('{{ ${element.key} }}', finalValue);
});
}
}
abstract interface class PlaceholderContract {
Map<String, dynamic> get values;
String? get identifier;
String apply(String value);
}