You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ConfigLib/README.md

22 KiB

ConfigLib

A Minecraft library for mapping objects to YAML configuration files.

This library facilitates creating, saving, loading, updating, and documenting YAML configuration files. It does so by automatically mapping instances of configuration classes to serializable maps which are then transformed into YAML and saved to some specified file.

Features

  • Automatic creation, saving, loading, and updating of configuration files
  • Support for all primitive types, their wrapper types, and Strings
  • Support for BigInteger, BigDecimal, LocalDate, LocalTime, and LocalDateTime
  • Support for (nested) lists, sets, arrays, and maps
  • Support for enums and POJOs (+ inheritance!)
  • Option to exclude fields from being converted
  • Option to add explanatory comments by annotating fields
  • Option to format field names before conversion
  • Option to customize null handling
  • Option to provide custom serializers
  • ...and a few more!

Usage example

This section contains a short usage example to get you started. The whole range of features is discussed in the following sections. Information on how to import this library is located at the end of this documentation.

public final class Example {
    // To create a configuration annotate the class with @Configuration and make sure that
    // it has a no-args constructor. That's it! Now you can add fields to it which can all
    // be private; setters are not required!
    @Configuration
    public static class BaseConfiguration {
        private String host = "127.0.0.1";
        private int port = 1234;
        // The library supports lists, sets, and maps.
        private Set<String> blockedAddresses = Set.of();
        // Fields can be ignored by making them final, transient, static or by
        // annotating them with @Ignore.
        private final double ignoreMe = 3.14;
    }

    // This class does not need to be annotated with @Configuration because it
    // extends a class which already is!
    public static final class UserConfiguration extends BaseConfiguration {
        // You can add comments with the @Comment annotation. Each string in the comment
        // array is written (as a comment) on a new line.
        @Comment({"The admin user has full access.", "Choose a proper password!"})
        User admin = new User("root", "toor"); // The User class is a @Configuration!
        List<User> blockedUsers = List.of(
                new User("user1", null), // null values are supported
                new User("user2", null)
        );
    }

    @Configuration
    public static final class User {
        private String username;
        @Comment("Please choose a strong password.")
        private String password;

        // Configuration classes require a no-args constructor.
        // The constructor can be private though.
        private User() {}

        public User(String username, String password) {/* initialize */}
    }

    public static void main(String[] args) {
        final var props = YamlConfigurationProperties.newBuilder().build();
        final var store = new YamlConfigurationStore<>(UserConfiguration.class, props);
        final var configFile = Paths.get("/tmp/config.yml");

        // Save a new instance to the configuration file
        store.save(new UserConfiguration(), configFile);

        // Load a new instance from the configuration file
        UserConfiguration configuration = store.load(configFile);
        System.out.println(configuration.admin.username);
        System.out.println(configuration.blockedUsers);

        // Modify and save the configuration file
        configuration.blockedUsers.add(new User("user3", "pass3"));
        store.save(configuration, configFile);
    }
}

By running the above code, a new YAML configuration is created at /tmp/config.yml. Its content looks like this:

host: 127.0.0.1
port: 1234
blockedAddresses: [ ]
# The admin user has full access.
# Choose a proper password!
admin:
  username: root
  # Please choose a strong password.
  password: toor
blockedUsers:
  - username: user1
  - username: user2
  - username: user3
    password: pass3

Two things are noticeable here:

  1. Not every user in the blockedUsers list has a password mapping. This is because null values are not output by default. That behavior can be changed by the builder.
  2. The password of the user with username user3 that has no comment. This is due to limitations of the YAML library. Configurations in lists, sets, or maps cannot have their comments printed.

General information

In the following sections the term configuration type refers to any non-generic class that is directly or indirectly (i.e. through subclassing) annotated with @de.exlll.configlib.Configuration. Accordingly, the term configuration refers to an instance of such a type.

Declaring configuration types

To declare a configuration type, annotate a class with @Configuration and make sure that it has a no-args constructor. The no-args constructor can be set private. Inner classes (i.e. the ones that are nested but not static) have an implicit synthetic constructor with at least one argument and are therefore not supported.

Add fields to your class whose type is any of the supported types listed in the next section. You should initialize all fields of reference types with non-null default values, though you can leave them null. Handling of null values is discussed in one of the sections further below.

Supported types

A configuration type may only contain fields of the following types:

Type class Types
Boolean types boolean, and Boolean
Integer types byte, short, int, long, and their respective wrapper types
Floating point types float, double, and their respective wrapper types
Characters and strings char, Character, String
Big numeric types BigInteger, BigDecimal
Time related types LocalTime, LocalDate, LocalDateTime
Enums Any Java enum
Configurations Any configuration type
Collections (Nested) Lists, sets, maps*, or arrays of previously listed types

(*) Map keys cannot be in the Configurations or Collections type class.

Examples of supported types

The following class contains examples of types that this library supports:

public final class SupportedTypes {
    boolean supported;
    Character supported;
    ExampleEnum supported;    // where 'ExampleEnum' is some Java enum type
    ExampleConf supported;    // where 'ExampleConf' is another configuration type
    java.awt.Point supported; // only if a custom serializer is registered
    /* collection types */
    List<BigInteger> supported;
    Set<Double> supported;
    LocalDate[] supported;
    Map<ExampleEnum, ExampleConf> supported;
    /* nested collection types */
    List<Map<ExampleEnum, LocalDate>> supported;
    int[][] supported;
    Map<Integer, List<Map<Short, Set<ExampleConf>>>> supported;
}
Examples of unsupported types

The following class contains examples of types that this library does (and will) not support:

public final class UnsupportedTypes<T> {
    Map<Point, String> unsupported;        // invalid map key
    Map<List<String>, String> unsupported; // invalid map key
    Box<String> unsupported;               // custom parameterized type
    List<? extends String> unsupported;    // wildcard type
    List<?> unsupported;                   // wildcard type
    List<?>[] unsupported;                 // wildcard type
    T unsupported;                         // type variable
    List unsupported;                      // raw type
    List[] unsupported;                    // raw type
    List<String>[] unsupported;            // generic array type
    Set<Integer>[] unsupported;            // generic array type
    Map<Byte, Byte>[] unsupported;         // generic array type
}

Loading and saving configurations

There are two ways to load and save configurations. Which way you choose depends on your liking. Both ways have three methods in common:

  • save saves a configuration to a file
  • load creates a new configuration instance and populates it with values taken from a file
  • update is a combination of load and save and the method you'd usually want to use: it takes care of creating the configuration file if it does not exist and updates it otherwise to reflect changes to (the fields of) the configuration type.
Example of update behavior when configuration file exists

Let's say you have the following configuration type:

@Configuration 
public final class C {
    int i = 10; 
    int j = 11; 
}

... and a YAML configuration file that contains:

i: 20
k: 30

Now, when you use one of the methods below to call update for that configuration type and file, the configuration instance that update returns will have its i variable initialized to 20 and its j variable will have its default of 11. After the operation, the configuration file will contain:

i: 20
j: 11

To exemplify the usage of these three methods we assume for the following sections that you have implemented the configuration type below and have access to some regular java.nio.file.Path object configurationFile.

@Configuration
public final class Config { /* some fields */ }

Way 1

The first way is to create a configuration store and use it directly to save, load, or update configurations.

YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder().build();
YamlConfigurationStore<Config> store = new YamlConfigurationStore<>(Config.class, properties);

Config config1 = store.load(configurationFile);
store.save(config1, configurationFile);
Config config2 = store.update(configurationFile);

Way 2

The second way is to use the static methods from the Configurations class.

Config config1 = Configurations.loadYamlConfiguration(configurationFile, Config.class);
Configurations.saveYamlConfiguration(configurationFile, Config.class, config1);
Config config2 = Configurations.updateYamlConfiguration(configurationFile, Config.class);

Each of these methods has two additional overloads: One that takes a properties object and another that lets you configure a properties object builder. For example, the overloads for the loadYamlConfiguration method are:

// overload 1
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
    .inputNulls(true)
    .outputNulls(false)
    .build();
Config c1 = Configurations.loadYamlConfiguration(configurationFile, Config.class, properties);

// overload 2
Config c2 = Configurations.loadYamlConfiguration(
    configurationFile,
    Config.class,
    builder -> builder.inputNulls(true).outputNulls(false)
); 

Configuration properties

Instances of the ConfigurationProperties class allow customization of how configurations are stored and loaded. To create such an instance, instantiate a new builder using the YamlConfigurationProperties.newBuilder() method, configure it, and finally call its build() method. Check out the several methods of the builder to see which configuration options are available.

Ignoring and filtering fields

Fields that are final, static, transient or annotated with @Ignore are neither serialized nor updated during deserialization. You can filter out additional fields by providing an instance of FieldFilter to the configuration properties.

Handling of null values

Configuration properties let you configure how null values are handled when serializing and deserializing a configuration:

  • By setting outputNulls to false, fields and collection elements that are null are not output. Any comments that belong to such fields are also not written.
  • By setting inputNulls to false, fields and collection elements that are null are not input. That means that fields will keep their default values.
Example null handling configuration

The following code forbids null values to be output but allows null values to be input. By default, both are forbidden which makes the call to outputNulls in this case redundant.

YamlConfigurationProperties.newBuilder()
        .outputNulls(false)
        .inputNulls(true)
        .build();

Field formatting

You can define how fields are formatted by configuring the configuration properties with a custom formatter. Field formatters are implementations of the FieldFormatter interface. You can implement this interface yourself or use one of the several formatters this library provides. These pre-defined formatters can be found in the FieldFormatters class.

Example FieldFormatter configuration

The following code formats fields using the IDENTITY formatter (which is the default).

YamlConfigurationProperties.newBuilder()
        .setFieldFormatter(FieldFormatters.IDENTITY)
        .build();

Subclassing

Subclassing of configurations types is supported. Subclasses of configuration types don't need to be annotated with @Configuration. When a configuration is written, the fields of parent classes are written before the fields of the child in a top to bottom manner. Parent configurations can be abstract.

Shadowing of fields

Shadowing of fields refers to the situation where a subclass of configuration has a field that has the same name as a field in one of its super classes. Shadowing of fields is currently not supported. (This restriction might easily be lifted. If you need this feature, please open an issue and describe how to handle name clashes.)

Comments

The fields of a configuration can be annotated with the @Comment annotation. This annotation takes an array of strings. Each of these strings is written onto a new line as a comment. Empty strings are written as newlines.

Example of @Comment usage

Serializing the following configuration as YAML ...

@Configuration
public final class ExampleConfiguration {
    @Comment({"Hello", "", " ", "World"})
    private String commentedField = "commented field";
}

... results in the YAML file shown below:

 # Hello

 #  
 # World
 commentedField: commented field

If a configuration type C that defines comments is used (as a field) within another configuration type, the comments of C are written with the proper indentation. However, if instances of C are stored inside a collection, their comments are not printed when the collection is written.

Recursive type definitions

Recursive type definitions are currently not allowed but might be supported in a future version if this feature is requested.

Examples of recursive type definitions

Neither direct nor indirect recursive type definitions are supported.

public final class RecursiveTypDefinitions {
    // Direct recursive definition
    @Configuration
    static final class R {
        R r;
    }

    // Indirect recursive definition
    @Configuration
    static final class R1 {
        R2 r2;
    }

    @Configuration
    static final class R2 {
        R1 r1;
    }
}

Type conversion and custom serializers

Before instances of the types listed in the supported types section can be stored, they need to be converted into serializable types (i.e. into types the underlying YAML library knows how to handle). The conversion happens according to the following table:

Source type Target type
Boolean types Boolean
Integer types Long
Floating point types Double
Characters and strings String
Big numeric types String
Time related types String
Enums String
Configurations Map<String, ?>
Set<S> List<T>*
List<S> List<T>
S[] List<T>
Map<S1, S2> Map<T1, T2>

(*) By default, sets are serialized as lists. This can be changed through the configuration properties. This also means that Sets are valid target types.

Serializer selection

To convert the value of a field F with (source) type S into a serializable value of some target type, a serializer has to be selected. Serializers are instances of the de.exlll.configlib.Serializer interface and are selected based on S. Put differently, serializers are always selected based on the compile-time type of F and never on the runtime type of its value.

This distinction makes a difference (and might lead to confusion) when you have fields whose type is a configuration type or a collection of some configuration type, and you extend that configuration type. Concretely, assume you have and written two configuration types A and B where B extends A. Then, if you use A a = new B() in your main configuration, only the fields of a A will be stored when you save your main configuration. That is because the serializer of field a was selected based on the compile-time type of a which is A and not B. The same happens if you have a List<A> and put instances of B (or some other subclass of A) in it.

Custom serializers

If you want to add support for a type whose class is not annotated with @Configuration, you can register a custom serializer. Serializers are instances of the de.exlll.configlib.Serializer interface. When implementing that interface you have to make sure that you convert your source type into one of the valid target types listed in the table above. The serializer then has to be registered through a ConfigurationProperties object.

Example Serializer implementation

The following Serializer serializes instances of java.awt.Point into strings.

public final class PointSerializer implements Serializer<Point, String> {
    @Override
    public String serialize(Point element) {
        return element.x + ":" + element.y;
    }

    @Override
    public Point deserialize(String element) {
        String[] parts = element.split(":");
        int x = Integer.parseInt(parts[0]);
        int y = Integer.parseInt(parts[1]);
        return new Point(x, y);
    }
}

Custom serializers takes precedence over the serializers provided by this library.

Changing the type of fields

Changing the type of fields is not supported. If you change the type of one of your fields but your configuration file still contains a value of the old type, a type mismatch will occur when reading that file. Instead, remove the old field and add a new one with a different name.

Import

INFO: I'm currently looking for an easier way for you to import this library that does not require authentication with GitHub. Please check this issue if you have authentication problems.

To use this library, import it into your project with either Maven or Gradle as shown in the two sections below. This library has additional dependencies (namely, a YAML parser) which are not included in the artifact you import.

This repository provides plugin versions of this library which bundle all its dependencies, so you don't have to worry about them. Also, these versions make it easier for you to update this library if you have written multiple plugins that use it.

The plugin versions can be downloaded from the releases page where you can identify them by their -paper-, -waterfall-, and -velocity- infix and -all suffix. Other than that, the plugin versions currently don't add any additional functionality. If you use these versions, don't forget to add them as a dependency in the plugin.yml (for Paper and Waterfall) or to the dependencies array (for Velocity) of your own plugin.

Alternatively, if you don't want to use an extra plugin, you can shade the -core version and the YAML parser yourself.

Maven

<repository>
    <id>de.exlll</id>
    <url>https://maven.pkg.github.com/Exlll/ConfigLib</url>
</repository>

<dependency>
    <groupId>de.exlll</groupId>
    <artifactId>configlib-core</artifactId>
    <version>3.0.0</version>
</dependency>

Gradle

repositories { maven { url 'https://maven.pkg.github.com/Exlll/ConfigLib' } }

dependencies { implementation 'de.exlll:configlib-core:3.0.0' }
repositories { maven { url = uri("https://maven.pkg.github.com/Exlll/ConfigLib") } }

dependencies { implementation("de.exlll:configlib-core:3.0.0") }

Future work

This section contains ideas for upcoming features. If you want any of these to happen any time soon, please open an issue where we can discuss the details.

  • Optional fields
  • Post load / pre save hooks
  • TOML support
  • Change the order of fields in parent/child class scenarios
  • Recursive definitions