Skip to content
powercas_gamer edited this page Jan 9, 2024 · 4 revisions

Type serializers are Configurate's mechanism for converting between types native to each configuration format and what is requested by a user. Configurate comes with a library of serializers that cover many common classes in the JDK. These are:

  • java.net.URI
  • java.net.URL
  • java.util.UUID -- supporting UUIDs in both RFC and Mojang (without dashes) formats
  • Any class annotated with @ConfigSerializable -- processed through the Object Mapper
  • byte, short, int, long, float, and double numbers,
  • char
  • boolean
  • java.lang.String
  • java.util.Map -- deserialized values are LinkedHashMap instances
  • java.util.List -- deserialized values are currently instances of ArrayList
  • Any enum class
  • java.util.regex.Pattern
  • Any type of array -- primitive and Object[]
  • java.util.Set -- deserialized values are currently instances of HashSet
  • ConfigurationNode -- the underlying node will be copied.
  • java.util.File and java.nio.file.Path

Registering type serializers

To be able to locate type serializers, they have to be registered for specific types. Standard serializers are included in the default collection, accessible at TypeSerializerCollection.defaults(). Custom serializers have to be registered while building a child. Configurate provides two types of registration -- register, which registers for the specified type and all its subtypes, and registerExact, which only registers for the type itself.

Creating a custom type serializer

While Configurate's stock serializers and object mappers should be the first choices for converting objects, sometimes it is necessary to perform custom serialization -- if you don't control the types being serialized, or Configurate needs to be decoupled from the types that are being serialized. In that case, custom implementations of TypeSerializer can be created and registered.

Configurate provides AbstractListChildSerializer and ScalarSerializer as base classes for data types that are derived from a list data structure in a configuration and a scalar value respectively. They provide additional operations and validation that might be relevant for their respective value types.

As an example of a serializer, let's look at how an imaginary type, Beverage, with the following API, would be serialized:

/* A drink. */
public interface Beverage {

    /* Create a new drink description */
    static Beverage of(final int temperature, final boolean sweet) {
        // [...]
    }

    /* The beverage's temperature in degrees Celsius, between 0 and 100. */
    int temperature();

    /* Whether the beverage is sweet. */
    boolean sweet();
}

Implementing a serializer is fairly straightforward, using the methods you're already familiar with from a ConfigurationNode. This one does some basic validation on provided input as well:

final class BeverageSerializer implements TypeSerializer<Beverage> {
    static final BeverageSerializer INSTANCE = new BeverageSerializer();

    private static final String TEMPERATURE = "temperature";
    private static final String SWEET = "sweet";

    private BeverageSerializer() {
    }

    private ConfigurationNode nonVirtualNode(final ConfigurationNode source, final Object... path) throws SerializationException {
        if (!source.hasChild(path)) {
            throw new SerializationException("Required field " + Arrays.toString(path) + " was not present in node");
        }
        return source.node(path);
    }
    
    @Override
    public Beverage deserialize(final Type type, final ConfigurationNode source) throws SerializationException {
        final ConfigurationNode temperatureNode = nonVirtualNode(source, TEMPERATURE);
        final int temperature = temperatureNode.getInt();
        if (temperature < 0 || temperature > 100) { // beverages are liquid, which is between 0 and 100 degrees celsius
            // Throw an exception that specifically refers to the temperature field and its type, not the containing node
            throw new SerializationException(temperatureNode, int.class, "A beverage must be in liquid form, but this one had a temperature of " + temperature + "deg C!");
        }
        final boolean sweet = nonVirtualNode(source, SWEET).getBoolean();

        return Beverage.of(temperature, sweet);
    }

    @Override
    public void serialize(final Type type, final @Nullable Beverage bev, final ConfigurationNode target) throws SerializationException {
        if (bev == null) {
            target.raw(null);
            return;
        }

        target.node(TEMPERATURE).set(bev.temperature());
        target.node(SWEET).set(bev.sweet());
    }
}

To use this serializer, simply register it when creating a new loader:

YamlConfigurationLoader.builder()
    .defaultOptions(opts -> opts.serializers(build -> build.register(Beverage.class, BeverageSerializer.INSTANCE)))
    // [... any other configuration ...]

Of course, this registration can be combined with any other customizations to the default options, or any further custom serializers.

Making a collection of serializers for others

The process is not much different when making serializers for distribution. However, the individual serializer classes should usually not be exposed API -- instead, a collection should be built containing custom serializers. This collection is then the only thing exposed in-api. Users can add all serializers at once using TypeSerializerCollection.Builder.registerAll().

For example, given an imaginary library called Axlotl providing some serializers, end users could create a loader that used those serializers like so:

public ConfigurationLoader<?> createLoader() {
    return HoconConfigurationLoader.builder()
      .path(Paths.get("somefile.conf"))
      .defaultOptions(opts -> opts.serializers(build -> build.registerAll(AxlotlSerializers.collection())))
      .build()
}