Placeholders

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}.

config.yaml
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

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.

config.yaml
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:

  1. Conversion at instantiation of each Ticket model
  2. 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;
}
}

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.

main.dart
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);
});
}
}