Add IOStreamConfigurationStore

This commit adds the new interface IOStreamConfigurationStore that provides two
new methods, one for reading configurations from InputStreams and a second
for writing configurations to OutputStreams. This interfaces is implemented
by the YamlConfigurationStore class.
dev
Exlll 12 months ago
parent ef7c611939
commit a5d05e256c

@ -1,14 +1,18 @@
# ConfigLib
**A Minecraft library for saving, loading, updating, and commenting YAML configuration files.**
**A Minecraft library for saving, loading, updating, and commenting YAML
configuration files.**
This library facilitates creating, saving, loading, updating, and commenting YAML configuration
files. It does so by automatically mapping instances of configuration classes to serializable maps
which are first transformed into YAML and then saved to some specified file.
This library facilitates creating, saving, loading, updating, and commenting
YAML configuration files. It does so by automatically mapping instances of
configuration classes to serializable maps which are first transformed into YAML
and then saved to some specified file.
Information on how to [import](#import) this library can be found at the end of this documentation.
For a step-by-step tutorial that shows most features of this library in action check out
the [Tutorial](https://github.com/Exlll/ConfigLib/wiki/Tutorial) page on the wiki!
Information on how to [import](#import) this library can be found at the end of
this documentation. For a step-by-step tutorial that shows most features of this
library in action check out
the [Tutorial](https://github.com/Exlll/ConfigLib/wiki/Tutorial) page on the
wiki!
## Features
@ -30,12 +34,13 @@ the [Tutorial](https://github.com/Exlll/ConfigLib/wiki/Tutorial) page on the wik
## 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](#import) this library is located
at the end of this documentation.
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](#import) this library is located at the end of this documentation.
For a step-by-step tutorial with a more advanced example check out
the [Tutorial](https://github.com/Exlll/ConfigLib/wiki/Tutorial) page on the wiki.
the [Tutorial](https://github.com/Exlll/ConfigLib/wiki/Tutorial) page on the
wiki.
If you want support for Bukkit classes like `ItemStack`, check out
the [Configuration properties](#configuration-properties) section.
@ -96,8 +101,8 @@ public final class Example {
}
```
By running the above code, a new YAML configuration is created at `/tmp/config.yml`. Its content
looks like this:
By running the above code, a new YAML configuration is created
at `/tmp/config.yml`. Its content looks like this:
```yaml
host: 127.0.0.1
@ -119,33 +124,38 @@ blockedUsers:
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` has no comment. This is due to limitations of
the YAML library. Configurations in lists, sets, or maps cannot have their comments printed.
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` 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 Java record type or 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. A _configuration element_ is either a class field or a record component of a
In the following sections the term _configuration type_ refers to any Java
record type or 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. A
_configuration element_ is either a class field or a record component of a
configuration type.
### Declaring configuration types
To declare a configuration type, either define a Java record or annotate a class
with `@Configuration` and make sure that it has a no-args constructor. The no-args constructor can
be `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.
with `@Configuration` and make sure that it has a no-args constructor. The
no-args constructor can be `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.
Now simply add components to your record or fields to your class whose type is any of the supported
types listed in the next section. You can (and should) initialize all fields of a configuration
class with non-null default values.
Now simply add components to your record or fields to your class whose type is
any of the supported types listed in the next section. You can (and should)
initialize all fields of a configuration class with non-null default values.
### Supported types
A configuration type may only contain configuration elements of the following types:
A configuration type may only contain configuration elements of the following
types:
| Type class | Types |
|-----------------------------|--------------------------------------------------------------------|
@ -161,8 +171,8 @@ A configuration type may only contain configuration elements of the following ty
| `ConfigurationSerializable` | All Bukkit classes that implement this interface, like `ItemStack` |
| Collections | (Nested) Lists, sets, maps*, or arrays of previously listed types |
(*) Map keys can only be of simple or enum type, i.e. they cannot be in the `Collections`,
`Configurations`, or `ConfigurationSerializable` type class.
(*) Map keys can only be of simple or enum type, i.e. they cannot be in
the `Collections`, `Configurations`, or `ConfigurationSerializable` type class.
For all types that are not listed in the table above, you can provide your
own [custom serializer](#custom-serializers).
@ -204,7 +214,8 @@ public final class SupportedTypes {
<details>
<summary>Examples of unsupported types</summary>
The following class contains examples of types that this library does (and will) not support:
The following class contains examples of types that this library does (and will)
not support:
```java
public final class UnsupportedTypes<T> {
@ -223,31 +234,38 @@ public final class UnsupportedTypes<T> {
}
```
**NOTE:** Even though this library does not support these types, it is still possible to serialize
them by providing a custom serializer via the [`@SerializeWith`](#the-serializewith-annotation)
annotation. That serializer then has to be applied to top-level type (i.e. `nesting` must be set
**NOTE:** Even though this library does not support these types, it is still
possible to serialize them by providing a custom serializer via
the [`@SerializeWith`](#the-serializewith-annotation) annotation. That
serializer then has to be applied to top-level type (i.e. `nesting` must be set
to `0`, which is the default).
</details>
### 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:
* The `save` method saves a configuration to a file. The file is created if it does not exist and
is overwritten otherwise.
* The `load` method creates a new configuration instance and populates it with values taken from a
file. For classes, the no-args constructor is used to create a new instance. For records, the
canonical constructor is called.
* The `update` method 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 otherwise updates
it to reflect changes to (the configuration elements of) the configuration type.
There are two ways to load and save configurations. Which way you choose depends
on your liking. Both ways have five methods in common:
* The `save` method converts a configuration to a string in YAML format and
saves that string to a file. The file is created if it does not exist and is
overwritten otherwise.
* The `load` method creates a new configuration instance and populates it with
values taken from a file. For classes, the no-args constructor is used to
create a new instance. For records, the canonical constructor is called.
* The `update` method 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 otherwise updates it to reflect changes to (the
configuration elements of) the configuration type.
* The `write` method works the same way as the `save` method but writes the
string to a `java.io.OutputStream`.
* The `read` method works the same way as the `load` method but reads the values
from a `java.io.InputStream`.
<details>
<summary>Example of <code>update</code> behavior when configuration file exists</summary>
Let's say you have the following configuration type:
Let's say you have the following configuration type
```java
@Configuration
@ -264,31 +282,34 @@ 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:
Now, when you call the `update` method for that configuration type and file
using any of the two options listed below, 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 the following content (note that `k` has been dropped):
```yaml
i: 20
j: 11
```
<hr>
</details>
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`.
To exemplify the usage of these five 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`.
```java
@Configuration
public final class Config { /* some fields */ }
```
#### Way 1
#### Option 1
The first way is to create a configuration store and use it directly to save, load, or update
configurations.
The first option is to create a `YamlConfigurationStore` instance and use it to
save, load, or update configurations.
```java
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder().build();
@ -299,9 +320,14 @@ store.save(config1, configurationFile);
Config config2 = store.update(configurationFile);
```
#### Way 2
Using a `YamlConfigurationStore` directly is always more efficient than the
second option show below, especially if you are calling any of its method
multiple times.
#### Option 2
The second way is to use the static methods from the `YamlConfigurations` class.
The second option is to use the static methods from the `YamlConfigurations`
class.
```java
Config config1 = YamlConfigurations.load(configurationFile, Config.class);
@ -309,9 +335,9 @@ YamlConfigurations.save(configurationFile, Config.class, config1);
Config config2 = YamlConfigurations.update(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 of the `load`
method are:
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 of the `load` method are:
```java
// overload 1
@ -328,10 +354,10 @@ Config config2 = YamlConfigurations.load(
<hr>
All three methods can also be passed a Java record instead of a class. To provide default values
for records when calling the `update` method, you can add a constructor with no parameters that
initializes its components. This constructor is only called if the configuration file does not
exist.
All five methods can also be passed a Java record instead of a class. To provide
default values for records when calling the `update` method, you can add a
constructor with no parameters that initializes its components. This constructor
is only called if the configuration file does not exist.
```java
record User(String name, String email) {
@ -342,19 +368,21 @@ User user = YamlConfigurations.update(configurationFile, User.class);
### 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. Alternatively, you can use the `toBuilder()` method of an
existing `YamlConfigurationProperties` to create a new builder that is initialized with values
takes from the properties object.
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. Alternatively, you can use
the `toBuilder()` method of an existing `YamlConfigurationProperties` to create
a new builder that is initialized with values takes from the properties object.
Check out the methods of the builder class to see which configuration options are available.
Check out the methods of the builder class to see which configuration options
are available.
#### Support for Bukkit classes like `ItemStack`
There is a special `YamlConfigurationProperties` object with name `BUKKIT_DEFAULT_PROPERTIES`
that adds support for Bukkit's `ConfigurationSerializable` types. If you want to use any of these
There is a special `YamlConfigurationProperties` object with
name `BUKKIT_DEFAULT_PROPERTIES` that adds support for
Bukkit's `ConfigurationSerializable` types. If you want to use any of these
types in your configuration, you have to use that object as a starting point:
```java
@ -363,20 +391,21 @@ YamlConfigurationProperties properties = ConfigLib.BUKKIT_DEFAULT_PROPERTIES.toB
.build();
```
To get access to this object, you have to import `configlib-paper` instead of `configlib-yaml` as
described in the [Import](#import) section.
To get access to this object, you have to import `configlib-paper` instead
of `configlib-yaml` as described in the [Import](#import) section.
### Comments
The configuration elements of a configuration type 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. The strings can contain `\n` characters. Empty strings are written as newlines (not as
The configuration elements of a configuration type 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. The strings can
contain `\n` characters. Empty strings are written as newlines (not as
comments).
If a configuration type _C_ that defines comments is used (as a configuration element) 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.
If a configuration type _C_ that defines comments is used (as a configuration
element) 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.
Serializing the following configuration as YAML ...
@ -420,56 +449,66 @@ address:
### 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`.
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.)
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.)
### 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. Record components cannot be filtered.
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. Record components cannot be filtered.
### Handling of missing and `null` values
#### Missing values
When a configuration file is read, values that correspond to a configuration element might be
missing. That can happen, for example, when somebody deleted that value from the configuration file,
when you add configuration elements to your configuration type, or when the `NameFormatter` that
was used to create that file is replaced.
When a configuration file is read, values that correspond to a configuration
element might be missing. That can happen, for example, when somebody deleted
that value from the configuration file, when you add configuration elements to
your configuration type, or when the `NameFormatter` that was used to create
that file is replaced.
In such cases, fields of configuration classes keep the default value you assigned to them and
record components are initialized with the default value of their corresponding type.
In such cases, fields of configuration classes keep the default value you
assigned to them and record components are initialized with the default value of
their corresponding type.
#### Null values
**NOTE:** Null values written to a configuration file generally don't give any indication about
which kinds of values the configuration expects. Therefore, they not only make it harder for the
users of that configuration file to properly configure it, but they might also prevent loading a
configuration if the values the users set are of the wrong type.
Although strongly discouraged, null values are supported and `ConfigurationProperties` let you
configure how they are handled when serializing and deserializing a configuration:
* By setting `outputNulls` to false, configuration elements, 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, null values read from the configuration file are treated as
missing and are, therefore, handled as described in the section above.
* By setting `inputNulls` to true, null values read from the configuration file override the
corresponding default values of a configuration class with null or set the component value of a
record type to null. If the configuration element type is primitive, an exception is thrown.
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.
**NOTE:** Null values written to a configuration file generally don't give any
indication about which kinds of values the configuration expects. Therefore,
they not only make it harder for the users of that configuration file to
properly configure it, but they might also prevent loading a configuration if
the values the users set are of the wrong type.
Although strongly discouraged, null values are supported
and `ConfigurationProperties` let you configure how they are handled when
serializing and deserializing a configuration:
* By setting `outputNulls` to false, configuration elements, 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, null values read from the configuration file
are treated as missing and are, therefore, handled as described in the section
above.
* By setting `inputNulls` to true, null values read from the configuration file
override the corresponding default values of a configuration class with null
or set the component value of a record type to null. If the configuration
element type is primitive, an exception is thrown.
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.
```java
YamlConfigurationProperties.newBuilder()
@ -480,13 +519,14 @@ YamlConfigurationProperties.newBuilder()
### Formatting the names of configuration elements
You can define how the names of configuration elements are formatted by configuring the
configuration properties with a custom formatter. Formatters are implementations of
the `NameFormatter` 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 `NameFormatters`
class.
You can define how the names of configuration elements are formatted by
configuring the configuration properties with a custom formatter. Formatters are
implementations of the `NameFormatter` 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 `NameFormatters` class.
The following code formats fields using the `IDENTITY` formatter (which is the default).
The following code formats fields using the `IDENTITY` formatter (which is the
default).
```java
YamlConfigurationProperties.newBuilder()
@ -496,9 +536,10 @@ YamlConfigurationProperties.newBuilder()
### Type conversion and serializer selection
Before instances of the types listed in the [supported types](#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:
Before instances of the types listed in the [supported types](#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 |
|-----------------------------|------------------|
@ -511,64 +552,74 @@ library knows how to handle). The conversion happens according to the following
| Utility types | `String` |
| Enums | `String` |
| Configurations | `Map<String, ?>` |
| `Set<S>` | `List<T>`* |
| `Set<S>` | `List<T>` |
| `List<S>` | `List<T>` |
| `S[]` | `List<T>` |
| `Map<S1, S2>` | `Map<T1, T2>` |
| `ConfigurationSerializable` | `String` |
(*) By default, sets are serialized as lists. This can be changed through the configuration
properties. This also means that `Set`s are valid target types.
#### Serializer selection
To convert the value of a configuration element `E` 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, by default, always selected based on the compile-time type of `E` and never on the
runtime type of its value.
To convert the value of a configuration element `E` 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, by default, always
selected based on the compile-time type of `E` and never on the runtime type of
its value.
<details>
<summary>Why should I care about this?</summary>
This distinction makes a difference (and might lead to confusion) when you have configuration
elements that are configuration classes, and you extend those classes. Concretely,
assume you have written two configuration classes `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.
This distinction makes a difference (and might lead to confusion) when you have
configuration elements that are configuration classes, and you extend those
classes. Concretely, assume you have written two configuration classes `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.
If you need such behavior, have a look at
the [`@Polymorphic`](#the-polymorphic-annotation) annotation.
</details>
<details>
<summary>Order of serializer selection</summary>
You can override the default selection by annotating a configuration
element with [`@SerializeWith`](#the-serializewith-annotation), by annotating a type
with `@SerializeWith`, or by adding your own serializer for `S` to the configuration properties.
When you do so, it can happen that there multiple serializers available for a particular
configuration element and its type. In that case, one of them chosen according to the following
precedence rules:
1. If the element is annotated with `@SerializeWith` and the `nesting` matches, the serializer
referenced by the annotation is selected.
2. Otherwise, if the `ConfigurationProperties` contain a serializer for the type in question, that
serializer is returned.
* Serializers created by factories that were added through `addSerializerFactory` for some type
take precedence over serializers added by `addSerializer` for the same type.
3. If the type is annotated `@SerializeWith`, the serializer referenced by the annotation is
element with [`@SerializeWith`](#the-serializewith-annotation), by annotating a
type with `@SerializeWith`, or by adding your own serializer for `S` to the
configuration properties. When you do so, it can happen that there multiple
serializers available for a particular configuration element and its type. In
that case, one of them chosen according to the following precedence rules:
1. If the element is annotated with `@SerializeWith` and the `nesting` matches,
the serializer referenced by the annotation is selected.
2. Otherwise, if the `ConfigurationProperties` contain a serializer for the type
in question, that serializer is returned.
* Serializers created by factories that were added
through `addSerializerFactory` for some type take precedence over
serializers added by `addSerializer` for the same type.
3. If the type is annotated `@SerializeWith`, the serializer referenced by the
annotation is selected.
4. If the type is annotated with an annotation which is annotated
with `@SerializeWith`, the serializer referenced by `@SerializeWith` is
returned.
5. If this library defines a serializer for that type, that serializer is
selected.
4. If the type is annotated with an annotation which is annotated with `@SerializeWith`, the
serializer referenced by `@SerializeWith` is returned.
5. If this library defines a serializer for that type, that serializer is selected.
6. Ultimately, if no serializer can be found, an exception is thrown.
For lists, sets, and maps, the algorithm is applied to their generic type arguments recursively
first.
For lists, sets, and maps, the algorithm is applied to their generic type
arguments recursively first.
</details>
##### The `SerializeWith` annotation
##### The `@SerializeWith` annotation
The `SerializeWith` annotation enforces the use of the specified serializer for a configuration
element or type. It can be applied to configuration elements (i.e. class fields and
record components), to types, and to other annotations.
The `@SerializeWith` annotation enforces the use of the specified serializer for
a configuration element or type. It can be applied to configuration elements
(i.e. class fields and record components), to types, and to other annotations.
```java
@SerializeWith(serializer = MyPointSerializer.class)
@ -580,21 +631,24 @@ Point point;
public final class SomeClass {/* ... */}
```
The serializer referenced by this annotation is selected regardless of whether the annotated type or
type of configuration element matches the type the serializer expects.
The serializer referenced by this annotation is selected regardless of whether
the annotated type or type of configuration element matches the type the
serializer expects.
If the annotation is applied to a configuration element and that element is an array, list, set, or
map, a nesting level can be set to apply the serializer not to the top-level type but to its
elements. For maps, the serializer is applied to the values and not the keys.
If the annotation is applied to a configuration element and that element is an
array, list, set, or map, a nesting level can be set to apply the serializer not
to the top-level type but to its elements. For maps, the serializer is applied
to the values and not the keys.
```java
@SerializeWith(serializer = MySetSerializer.class, nesting = 1)
List<Set<String>> list;
```
Setting `nesting` to an invalid value, i.e. a negative one or one that is greater than the number
of levels the element actually has, results in the serializer not being selected. For type
annotations, the `nesting` has no effect.
Setting `nesting` to an invalid value, i.e. a negative one or one that is
greater than the number of levels the element actually has, results in the
serializer not being selected. For type annotations, the `nesting` has no
effect.
<details>
<summary>More <code>nesting</code> examples</summary>
@ -608,12 +662,13 @@ List<Set<String>> list;
* a nesting of `0` would apply the serializer to `list` (which is of
type `List<Set<String>>`),
* a nesting of `1` would apply it to the `Set<String>` elements within `list`, and
* a nesting of `1` would apply it to the `Set<String>` elements within `list`,
and
* a nesting of `2` would apply it to the strings within the sets of `list`.
However, since the referenced serializer `MySetSerializer` most likely expects `Set`s as input,
setting `nesting` to `0` or `2` would result in an exception being thrown when the configuration
is serialized.
However, since the referenced serializer `MySetSerializer` most likely
expects `Set`s as input, setting `nesting` to `0` or `2` would result in an
exception being thrown when the configuration is serialized.
Some more examples:
@ -647,12 +702,13 @@ Map<Integer, Map<String, Double>> map;
#### The `@Polymorphic` annotation
The `@Polymorphic` annotation indicates that the annotated type is polymorphic. Serializers for
polymorphic types are not selected based on the compile-time types of configuration elements, but
instead are chosen at runtime based on the actual types of their values.
The `@Polymorphic` annotation indicates that the annotated type is polymorphic.
Serializers for polymorphic types are not selected based on the compile-time
types of configuration elements, but instead are chosen at runtime based on the
actual types of their values.
This enables adding instances of subclasses / implementations of a polymorphic type to collections.
The subtypes must be valid configurations.
This enables adding instances of subclasses / implementations of a polymorphic
type to collections. The subtypes must be valid configurations.
```java
@Polymorphic
@ -665,10 +721,11 @@ static final class Impl2 extends A { ... }
List<A> as = List.of(new Impl1(...), new Impl2(...), ...);
```
For correct deserialization, if an instance of polymorphic type (or one of its implementations /
subclasses) is serialized, an additional property that holds type information is added to its
serialization. By default, that type information is the Java class name of the actual type. It is
possible to provide type aliases by using the `PolymorphicTypes` annotation.
For correct deserialization, if an instance of polymorphic type (or one of its
implementations / subclasses) is serialized, an additional property that holds
type information is added to its serialization. By default, that type
information is the Java class name of the actual type. It is possible to provide
type aliases by using the `PolymorphicTypes` annotation.
```java
@Polymorphic
@ -684,21 +741,25 @@ record Impl2(...) implements B { ... }
### Custom serializers
If you want to add support for a type that is not a Java record or whose class is not annotated
with `@Configuration`, or if you don't like how one of the supported types is serialized by default,
you can write your own custom serializer.
If you want to add support for a type that is not a Java record or whose class
is not annotated with `@Configuration`, or if you don't like how one of the
supported types is serialized by default, you can write your own 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 [type conversion](#type-conversion-and-serializer-selection) section.
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 [type conversion](#type-conversion-and-serializer-selection) section.
The serializer then has to be registered through a `ConfigurationProperties` object or alternatively
be applied to a configuration element or type
with [`@SerializeWith`](#the-serializewith-annotation). If you want to use the `@SerializeWith`
annotation, your serializer class must either have a constructor with no parameters or one with
exactly one parameter of type [`SerializerContext`](#the-serializercontext-interface).
The serializer then has to be registered through a `ConfigurationProperties`
object or alternatively be applied to a configuration element or type
with [`@SerializeWith`](#the-serializewith-annotation). If you want to use
the `@SerializeWith` annotation, your serializer class must either have a
constructor with no parameters or one with exactly one parameter of
type [`SerializerContext`](#the-serializercontext-interface).
The following `Serializer` serializes instances of `java.awt.Point` into strings and vice versa.
The following `Serializer` serializes instances of `java.awt.Point` into strings
and vice versa.
```java
public final class PointSerializer implements Serializer<Point, String> {
@ -725,12 +786,13 @@ YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder(
##### The `SerializerContext` interface
Instances of the `SerializerContext` interface contain contextual information for custom
serializers. A context object gives access to the configuration properties, configuration element,
and the annotated type for which the serializer was selected.
Instances of the `SerializerContext` interface contain contextual information
for custom serializers. A context object gives access to the configuration
properties, configuration element, and the annotated type for which the
serializer was selected.
Context objects can be obtained when adding serializer factories through the `addSerializerFactory`
method:
Context objects can be obtained when adding serializer factories through
the `addSerializerFactory` method:
```java
public final class PointSerializer implements Serializer<Point, String> {
@ -749,21 +811,22 @@ YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder(
.build();
```
Custom serializers used with `@SerializeWith` are allowed to declare a constructor with one
parameter of type `SerializerContext`. If such a constructor exists, a context object is passed to
it when the serializer is instantiated by this library.
Custom serializers used with `@SerializeWith` are allowed to declare a
constructor with one parameter of type `SerializerContext`. If such a
constructor exists, a context object is passed to it when the serializer is
instantiated by this library.
### Changing the type of configuration elements
Changing the type of configuration elements is not supported. If you change the type of one of
these but your configuration file still contains a value of the old type, a type mismatch will
occur when loading a configuration from that file. Instead, remove the old element and add a new one
with a different name.
Changing the type of configuration elements is not supported. If you change the
type of one of these but your configuration file still contains a value of the
old type, a type mismatch will occur when loading a configuration from that
file. Instead, remove the old element and add a new one with a different name.
### Recursive type definitions
Recursive type definitions are currently not allowed but might be supported in a future version if
this feature is requested.
Recursive type definitions are currently not allowed but might be supported in a
future version if this feature is requested.
<details>
<summary>Examples of recursive type definitions</summary>
@ -797,54 +860,63 @@ public final class RecursiveTypDefinitions {
This project contains three classes of modules:
* The `configlib-core` module contains most of the logic of this library. In it, you can find (among
other things), the object mapper that converts configuration instances to maps (and vice versa),
most serializers, and the classes responsible for the extraction of comments. It does not
contain anything Minecraft related.
* The `configlib-yaml` module contains the classes that can save configuration instances as YAML
files and instantiate new instances from such files. This module does not contain anything
Minecraft related, either.
* The `configlib-paper`, `configlib-velocity`, and `configlib-waterfall` modules contain basic
plugins that are used to conveniently load this library. These three modules shade the `-core`
module, the `-yaml` module, and the YAML parser when the `shadowJar` task is executed. The shaded
jar files are released on the [releases page](https://github.com/Exlll/ConfigLib/releases).
* The `configlib-paper` module additionally contains the `ConfigLib.BUKKIT_DEFAULT_PROPERTIES`
object which adds support for the serialization of Bukkit classes like `ItemStack` as
* The `configlib-core` module contains most of the logic of this library. In it,
you can find (among other things), the object mapper that converts
configuration instances to maps (and vice versa), most serializers, and the
classes responsible for the extraction of comments. It does not contain
anything Minecraft related.
* The `configlib-yaml` module contains the classes that can save configuration
instances as YAML files and instantiate new instances from such files. This
module does not contain anything Minecraft related, either.
* The `configlib-paper`, `configlib-velocity`, and `configlib-waterfall` modules
contain basic plugins that are used to conveniently load this library. These
three modules shade the `-core` module, the `-yaml` module, and the YAML
parser when the `shadowJar` task is executed. The shaded jar files are
released on the [releases page](https://github.com/Exlll/ConfigLib/releases).
* The `configlib-paper` module additionally contains
the `ConfigLib.BUKKIT_DEFAULT_PROPERTIES` object which adds support for
the serialization of Bukkit classes like `ItemStack` as
described [here](#support-for-bukkit-classes-like-itemstack).
The GitHub repository of this project uses two branches:
* The `master` branch contains the functionality of the latest release version.
* The `dev` branch contains the newest, possibly unstable features and refactorings.
* The `dev` branch contains the newest, possibly unstable features and
refactorings.
If you plan to contribute to this project, please base your commits on the `dev` branch.
**If you plan to contribute to this project, please base your commits on
the `dev` branch.**
## Import
To use this library, import it into your project with Maven or Gradle. Examples of how to do that
are at the end of this section within the spoilers. Currently, there are two repositories from
which you can choose: [jitpack.io](https://jitpack.io/#Exlll/ConfigLib) and GitHub (which requires
authentication, see this [issue](https://github.com/Exlll/ConfigLib/issues/12) if you have any
To use this library, import it into your project with Maven or Gradle. Examples
of how to do that are at the end of this section within the spoilers. Currently,
there are two repositories from which you can
choose: [jitpack.io](https://jitpack.io/#Exlll/ConfigLib) and GitHub (which
requires authentication, see
this [issue](https://github.com/Exlll/ConfigLib/issues/12) if you have any
problems).
This library has additional dependencies (namely, a YAML parser) which are not exposed by the
artifact you import. You can download _plugin versions_ of this library that bundle all its
dependencies. The artifacts of these versions can be found on
the [releases page](https://github.com/Exlll/ConfigLib/releases) where you can identify them by
their `-paper-`, `-waterfall-`, and `-velocity-` infix and `-all` suffix. Except for
the `-paper-` version, the other plugin versions currently do not add any additional features.
A benefit of these versions is that they make it easier for you to update this library if you have
written multiple plugins that use it. If you plan to use these versions, don't forget to add the
plugin as a dependency to 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 `-yaml` version with its
YAML parser yourself.
This library has additional dependencies (namely, a YAML parser) which are not
exposed by the artifact you import. You can download _plugin versions_ of this
library that bundle all its dependencies. The artifacts of these versions can be
found on the [releases page](https://github.com/Exlll/ConfigLib/releases) where
you can identify them by their `-paper-`, `-waterfall-`, and `-velocity-` infix
and `-all` suffix. Except for the `-paper-` version, the other plugin versions
currently do not add any additional features. A benefit of these versions is
that they make it easier for you to update this library if you have written
multiple plugins that use it. If you plan to use these versions, don't forget to
add the plugin as a dependency to 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 `-yaml` version with its YAML parser yourself.
### Import examples
If you want serialization support for Bukkit classes like `ItemStack`, replace `configlib-yaml`
with `configlib-paper` (see [here](#support-for-bukkit-classes-like-itemstack)).
If you want serialization support for Bukkit classes like `ItemStack`,
replace `configlib-yaml` with `configlib-paper`
(see [here](#support-for-bukkit-classes-like-itemstack)).
<details>
<summary>
@ -888,7 +960,8 @@ dependencies { implementation("com.github.Exlll.ConfigLib:configlib-yaml:v4.2.0"
</summary>
Importing via GitHub requires authentication. Check
this [issue](https://github.com/Exlll/ConfigLib/issues/12) if you have any trouble with that.
this [issue](https://github.com/Exlll/ConfigLib/issues/12) if you have any
trouble with that.
**Maven**
@ -923,13 +996,15 @@ dependencies { implementation("de.exlll:configlib-yaml:4.2.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](https://github.com/Exlll/ConfigLib/issues/new) where we can discuss the
details.
This section contains ideas for upcoming features. If you want any of these to
happen any time soon,
please [open an issue](https://github.com/Exlll/ConfigLib/issues/new) where we
can discuss the details.
- JSON, TOML, XML support
- Post-load/Pre-save hooks
- More features and control over updating/versioning
- More control over the ordering of fields, especially in parent/child class scenarios
- More control over the ordering of fields, especially in parent/child class
scenarios
- Recursive definitions
- Shadowing of fields

@ -45,13 +45,14 @@ public interface FileConfigurationStore<T> {
* </li>
* <li>
* Otherwise, if the file exists, a new configuration instance is created, initialized with the
* values taken from the configuration file, and immediately saved to reflect possible changes
* values taken from the configuration file, and immediately saved to reflect potential changes
* of the configuration type.
* </li>
* </ul>
*
* @param configurationFile the configuration file that is updated
* @return a newly created configuration initialized with values taken from the configuration file
* @return a newly created configuration initialized with values taken from the configuration
* file or a default configuration
* @throws ConfigurationException if the configuration cannot be deserialized
* @throws NullPointerException if {@code configurationFile} is null
* @throws RuntimeException if loading or saving the configuration throws an exception

@ -0,0 +1,38 @@
package de.exlll.configlib;
import java.io.InputStream;
import java.io.OutputStream;
/**
* Instances of this class read and write configurations from input streams and to output streams,
* respectively.
* <p>
* The details of how configurations are serialized and deserialized are defined by the
* implementations of this interface.
*
* @param <T> the configuration type
*/
public interface IOStreamConfigurationStore<T> {
/**
* Writes a configuration instance to the given output stream.
*
* @param configuration the configuration
* @param outputStream the output stream the configuration is written to
* @throws ConfigurationException if the configuration contains invalid values or
* cannot be serialized
* @throws NullPointerException if any argument is null
* @throws RuntimeException if writing the configuration throws an exception
*/
void write(T configuration, OutputStream outputStream);
/**
* Reads a configuration from the given input stream.
*
* @param inputStream the input stream the configuration is read from
* @return a newly created configuration initialized with values read from {@code inputStream}
* @throws ConfigurationException if the configuration cannot be deserialized
* @throws NullPointerException if {@code inputStream} is null
* @throws RuntimeException if reading the input stream throws an exception
*/
T read(InputStream inputStream);
}

@ -15,8 +15,6 @@ import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.stream.Collectors.joining;
public final class TestUtils {
public static final PointSerializer POINT_SERIALIZER = new PointSerializer();
public static final PointIdentitySerializer POINT_IDENTITY_SERIALIZER =
@ -279,8 +277,8 @@ public final class TestUtils {
}
public static String readFile(Path file) {
try (Stream<String> lines = Files.lines(file)) {
return lines.collect(joining("\n"));
try {
return Files.readString(file);
} catch (IOException e) {
throw new RuntimeException(e);
}
@ -312,4 +310,4 @@ public final class TestUtils {
public static List<String> createListOfPlatformSpecificFilePaths(String... paths) {
return Stream.of(paths).map(TestUtils::createPlatformSpecificFilePath).toList();
}
}
}

@ -10,23 +10,26 @@ import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.Tag;
import org.snakeyaml.engine.v2.representer.StandardRepresenter;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import static de.exlll.configlib.Validator.requireNonNull;
/**
* A configuration store that saves and loads configurations as YAML text files.
* A configuration store for YAML configurations. This class provides two pairs of methods:
* One pair for loading configurations from and saving them as YAML text files, and a second pair
* for reading configurations from input streams and writing them to output streams.
*
* @param <T> the configuration type
*/
public final class YamlConfigurationStore<T> implements FileConfigurationStore<T> {
public final class YamlConfigurationStore<T> implements
FileConfigurationStore<T>,
IOStreamConfigurationStore<T> {
private static final Dump YAML_DUMPER = newYamlDumper();
private static final Load YAML_LOADER = newYamlLoader();
private final YamlConfigurationProperties properties;
@ -47,13 +50,24 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
this.extractor = new CommentNodeExtractor(properties);
}
@Override
public void write(T configuration, OutputStream outputStream) {
requireNonNull(configuration, "configuration");
requireNonNull(outputStream, "output stream");
var extractedCommentNodes = extractor.extractCommentNodes(configuration);
var yamlFileWriter = new YamlWriter(outputStream, properties);
var dumpedYaml = tryDump(configuration);
yamlFileWriter.writeYaml(dumpedYaml, extractedCommentNodes);
}
@Override
public void save(T configuration, Path configurationFile) {
requireNonNull(configuration, "configuration");
requireNonNull(configurationFile, "configuration file");
tryCreateParentDirectories(configurationFile);
var extractedCommentNodes = extractor.extractCommentNodes(configuration);
var yamlFileWriter = new YamlFileWriter(configurationFile, properties);
var yamlFileWriter = new YamlWriter(configurationFile, properties);
var dumpedYaml = tryDump(configuration);
yamlFileWriter.writeYaml(dumpedYaml, extractedCommentNodes);
}
@ -80,12 +94,41 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
}
}
@Override
public T read(InputStream inputStream) {
requireNonNull(inputStream, "input stream");
try {
var yaml = YAML_LOADER.loadFromInputStream(inputStream);
var conf = requireYamlMapForRead(yaml);
return serializer.deserialize(conf);
} catch (YamlEngineException e) {
String msg = "The input stream does not contain valid YAML.";
throw new ConfigurationException(msg, e);
}
}
private Map<?, ?> requireYamlMapForRead(Object yaml) {
if (yaml == null) {
String msg = "The input stream is empty or only contains null.";
throw new ConfigurationException(msg);
}
if (!(yaml instanceof Map<?, ?> map)) {
String msg = "The contents of the input stream do not represent a configuration. " +
"A valid configuration contains a YAML map but instead a " +
"'" + yaml.getClass() + "' was found.";
throw new ConfigurationException(msg);
}
return map;
}
@Override
public T load(Path configurationFile) {
requireNonNull(configurationFile, "configuration file");
try (var reader = Files.newBufferedReader(configurationFile)) {
var yaml = YAML_LOADER.loadFromReader(reader);
var conf = requireYamlMap(yaml, configurationFile);
var conf = requireYamlMapForLoad(yaml, configurationFile);
return serializer.deserialize(conf);
} catch (YamlEngineException e) {
String msg = "The configuration file at %s does not contain valid YAML.";
@ -95,20 +138,20 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
}
}
private Map<?, ?> requireYamlMap(Object yaml, Path configurationFile) {
private Map<?, ?> requireYamlMapForLoad(Object yaml, Path configurationFile) {
if (yaml == null) {
String msg = "The configuration file at %s is empty or only contains null.";
throw new ConfigurationException(msg.formatted(configurationFile));
}
if (!(yaml instanceof Map<?, ?>)) {
if (!(yaml instanceof Map<?, ?> map)) {
String msg = "The contents of the YAML file at %s do not represent a configuration. " +
"A valid configuration file contains a YAML map but instead a " +
"'" + yaml.getClass() + "' was found.";
throw new ConfigurationException(msg.formatted(configurationFile));
}
return (Map<?, ?>) yaml;
return map;
}
@Override
@ -137,134 +180,6 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
return new Load(settings);
}
/**
* A writer that writes YAML to a file.
*/
static final class YamlFileWriter {
private final Path configurationFile;
private final YamlConfigurationProperties properties;
private BufferedWriter writer;
YamlFileWriter(Path configurationFile, YamlConfigurationProperties properties) {
this.configurationFile = requireNonNull(configurationFile, "configuration file");
this.properties = requireNonNull(properties, "configuration properties");
}
public void writeYaml(String yaml, Queue<CommentNode> nodes) {
try (BufferedWriter writer = Files.newBufferedWriter(configurationFile)) {
this.writer = writer;
writeHeader();
writeContent(yaml, nodes);
writeFooter();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
this.writer = null;
}
}
private void writeHeader() throws IOException {
if (properties.getHeader() != null) {
writeAsComment(properties.getHeader());
writer.newLine();
}
}
private void writeFooter() throws IOException {
if (properties.getFooter() != null) {
writer.newLine();
writeAsComment(properties.getFooter());
}
}
private void writeAsComment(String comment) throws IOException {
String[] lines = comment.split("\n");
writeComments(Arrays.asList(lines), 0);
}
private void writeComments(List<String> comments, int indentLevel) throws IOException {
String indent = " ".repeat(indentLevel);
for (String comment : comments) {
if (comment.isEmpty()) {
writer.newLine();
continue;
}
String line = indent + "# " + comment;
writeLine(line);
}
}
private void writeLine(String line) throws IOException {
writer.write(line);
writer.newLine();
}
private void writeContent(String yaml, Queue<CommentNode> nodes) throws IOException {
if (nodes.isEmpty()) {
writer.write(yaml);
} else {
writeCommentedYaml(yaml, nodes);
}
}
private void writeCommentedYaml(String yaml, Queue<CommentNode> nodes)
throws IOException {
/*
* The following algorithm is necessary since no Java YAML library seems
* to properly support comments, at least not the way I want them.
*
* The algorithm writes YAML line by line and keeps track of the current
* context with the help of elementNames lists which come from the nodes in
* the 'nodes' queue. The 'nodes' queue contains nodes in the order in
* which fields and records components were extracted, which happened in
* DFS manner and with fields of a parent class being read before the fields
* of a child. That order ultimately represents the order in which the
* YAML file is structured.
*/
var node = nodes.poll();
var currentIndentLevel = 0;
for (final String line : yaml.split("\n")) {
if (node == null) {
writeLine(line);
continue;
}
final var elementNames = node.elementNames();
final var indent = " ".repeat(currentIndentLevel);
final var lineStart = indent + elementNames.get(currentIndentLevel) + ":";
if (!line.startsWith(lineStart)) {
writeLine(line);
continue;
}
final var commentIndentLevel = elementNames.size() - 1;
if (currentIndentLevel++ == commentIndentLevel) {
writeComments(node.comments(), commentIndentLevel);
if ((node = nodes.poll()) != null) {
currentIndentLevel = lengthCommonPrefix(node.elementNames(), elementNames);
}
}
writeLine(line);
}
}
static int lengthCommonPrefix(List<String> l1, List<String> l2) {
final int maxLen = Math.min(l1.size(), l2.size());
int result = 0;
for (int i = 0; i < maxLen; i++) {
String s1 = l1.get(i);
String s2 = l2.get(i);
if (s1.equals(s2))
result++;
else return result;
}
return result;
}
}
/**
* A custom representer that prevents aliasing.
*/

@ -1,10 +1,13 @@
package de.exlll.configlib;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Path;
import java.util.function.Consumer;
/**
* This class contains convenience methods for loading, saving, and updating configurations.
* This class contains convenience methods for reading, writing, loading, saving,
* and updating configurations.
*/
public final class YamlConfigurations {
private YamlConfigurations() {}
@ -78,6 +81,72 @@ public final class YamlConfigurations {
return store.load(configurationFile);
}
/**
* Reads a configuration of the given type from the given input stream using a
* {@code YamlConfigurationProperties} object with default values.
*
* @param inputStream the input stream the configuration is read from
* @param configurationType the type of configuration
* @param <T> the configuration type
* @return a newly created configuration initialized with values read from {@code inputStream}
* @throws ConfigurationException if the configuration cannot be deserialized
* @throws NullPointerException if any parameter is null
* @throws RuntimeException if reading the configuration throws an exception
* @see YamlConfigurationStore#read(InputStream)
*/
public static <T> T read(InputStream inputStream, Class<T> configurationType) {
final var properties = YamlConfigurationProperties.newBuilder().build();
return read(inputStream, configurationType, properties);
}
/**
* Reads a configuration of the given type from the given input stream using a
* {@code YamlConfigurationProperties} object that is built by a builder. The builder is
* initialized with default values and can be configured by the {@code propertiesConfigurer}.
*
* @param inputStream the input stream the configuration is read from
* @param configurationType the type of configuration
* @param propertiesConfigurer the consumer used to configure the builder
* @param <T> the configuration type
* @return a newly created configuration initialized with values read from {@code inputStream}
* @throws ConfigurationException if the configuration cannot be deserialized
* @throws NullPointerException if any parameter is null
* @throws RuntimeException if reading the configuration throws an exception
* @see YamlConfigurationStore#read(InputStream)
*/
public static <T> T read(
InputStream inputStream,
Class<T> configurationType,
Consumer<YamlConfigurationProperties.Builder<?>> propertiesConfigurer
) {
final var builder = YamlConfigurationProperties.newBuilder();
propertiesConfigurer.accept(builder);
return read(inputStream, configurationType, builder.build());
}
/**
* Reads a configuration of the given type from the given input stream using the given
* {@code YamlConfigurationProperties} object.
*
* @param inputStream the input stream the configuration is read from
* @param configurationType the type of configuration
* @param properties the configuration properties
* @param <T> the configuration type
* @return a newly created configuration initialized with values read from {@code inputStream}
* @throws ConfigurationException if the configuration cannot be deserialized
* @throws NullPointerException if any parameter is null
* @throws RuntimeException if reading the configuration throws an exception
* @see YamlConfigurationStore#read(InputStream)
*/
public static <T> T read(
InputStream inputStream,
Class<T> configurationType,
YamlConfigurationProperties properties
) {
final var store = new YamlConfigurationStore<>(configurationType, properties);
return store.read(inputStream);
}
/**
* Updates a YAML configuration file with a configuration of the given type using a
* {@code YamlConfigurationProperties} object with default values.
@ -224,4 +293,79 @@ public final class YamlConfigurations {
final var store = new YamlConfigurationStore<>(configurationType, properties);
store.save(configuration, configurationFile);
}
/**
* Writes a configuration instance to the given output stream using a
* {@code YamlConfigurationProperties} object with default values.
*
* @param configuration the configuration that is saved
* @param configurationType the type of configuration
* @param outputStream the output stream the configuration is written to
* @param <T> the configuration type
* @throws ConfigurationException if the configuration contains invalid values or
* cannot be serialized
* @throws NullPointerException if any argument is null
* @throws RuntimeException if writing the configuration throws an exception
* @see YamlConfigurationStore#write(Object, OutputStream)
*/
public static <T> void write(
OutputStream outputStream,
Class<T> configurationType,
T configuration
) {
final var properties = YamlConfigurationProperties.newBuilder().build();
write(outputStream, configurationType, configuration, properties);
}
/**
* Writes a configuration instance to the given output stream using a
* {@code YamlConfigurationProperties} object that is built by a builder. The builder is
* initialized with default values and can be configured by the {@code propertiesConfigurer}.
*
* @param configuration the configuration that is saved
* @param configurationType the type of configuration
* @param outputStream the output stream the configuration is written to
* @param propertiesConfigurer the consumer used to configure the builder
* @param <T> the configuration type
* @throws ConfigurationException if the configuration contains invalid values or
* cannot be serialized
* @throws NullPointerException if any argument is null
* @throws RuntimeException if writing the configuration throws an exception
* @see YamlConfigurationStore#write(Object, OutputStream)
*/
public static <T> void write(
OutputStream outputStream,
Class<T> configurationType,
T configuration,
Consumer<YamlConfigurationProperties.Builder<?>> propertiesConfigurer
) {
final var builder = YamlConfigurationProperties.newBuilder();
propertiesConfigurer.accept(builder);
write(outputStream, configurationType, configuration, builder.build());
}
/**
* Writes a configuration instance to the given output stream using the given
* {@code YamlConfigurationProperties} object.
*
* @param configuration the configuration that is saved
* @param configurationType the type of configuration
* @param outputStream the output stream the configuration is written to
* @param properties the configuration properties
* @param <T> the configuration type
* @throws ConfigurationException if the configuration contains invalid values or
* cannot be serialized
* @throws NullPointerException if any argument is null
* @throws RuntimeException if writing the configuration throws an exception
* @see YamlConfigurationStore#write(Object, OutputStream)
*/
public static <T> void write(
OutputStream outputStream,
Class<T> configurationType,
T configuration,
YamlConfigurationProperties properties
) {
final var store = new YamlConfigurationStore<>(configurationType, properties);
store.write(configuration, outputStream);
}
}

@ -0,0 +1,151 @@
package de.exlll.configlib;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import static de.exlll.configlib.Validator.requireNonNull;
/**
* A writer that writes YAML to a file.
*/
final class YamlWriter {
private final OutputStream outputStream;
private final YamlConfigurationProperties properties;
private BufferedWriter writer;
YamlWriter(OutputStream outputStream, YamlConfigurationProperties properties) {
this.outputStream = requireNonNull(outputStream, "output stream");
this.properties = requireNonNull(properties, "configuration properties");
}
YamlWriter(Path configurationFile, YamlConfigurationProperties properties) {
requireNonNull(configurationFile, "configuration file");
try {
this.outputStream = Files.newOutputStream(configurationFile);
this.properties = properties;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public void writeYaml(String yaml, Queue<CommentNode> nodes) {
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream))) {
this.writer = writer;
writeHeader();
writeContent(yaml, nodes);
writeFooter();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
this.writer = null;
}
}
private void writeHeader() throws IOException {
if (properties.getHeader() != null) {
writeAsComment(properties.getHeader());
writer.newLine();
}
}
private void writeFooter() throws IOException {
if (properties.getFooter() != null) {
writer.newLine();
writeAsComment(properties.getFooter());
}
}
private void writeAsComment(String comment) throws IOException {
String[] lines = comment.split("\n");
writeComments(Arrays.asList(lines), 0);
}
private void writeComments(List<String> comments, int indentLevel) throws IOException {
String indent = " ".repeat(indentLevel);
for (String comment : comments) {
if (comment.isEmpty()) {
writer.newLine();
continue;
}
String line = indent + "# " + comment;
writeLine(line);
}
}
private void writeLine(String line) throws IOException {
writer.write(line);
writer.newLine();
}
private void writeContent(String yaml, Queue<CommentNode> nodes) throws IOException {
if (nodes.isEmpty()) {
writer.write(yaml);
} else {
writeCommentedYaml(yaml, nodes);
}
}
private void writeCommentedYaml(String yaml, Queue<CommentNode> nodes)
throws IOException {
/*
* The following algorithm is necessary since no Java YAML library seems
* to properly support comments, at least not the way I want them.
*
* The algorithm writes YAML line by line and keeps track of the current
* context with the help of elementNames lists which come from the nodes in
* the 'nodes' queue. The 'nodes' queue contains nodes in the order in
* which fields and records components were extracted, which happened in
* DFS manner and with fields of a parent class being read before the fields
* of a child. That order ultimately represents the order in which the
* YAML file is structured.
*/
var node = nodes.poll();
var currentIndentLevel = 0;
for (final String line : yaml.split("\n")) {
if (node == null) {
writeLine(line);
continue;
}
final var elementNames = node.elementNames();
final var indent = " ".repeat(currentIndentLevel);
final var lineStart = indent + elementNames.get(currentIndentLevel) + ":";
if (!line.startsWith(lineStart)) {
writeLine(line);
continue;
}
final var commentIndentLevel = elementNames.size() - 1;
if (currentIndentLevel++ == commentIndentLevel) {
writeComments(node.comments(), commentIndentLevel);
if ((node = nodes.poll()) != null) {
currentIndentLevel = lengthCommonPrefix(node.elementNames(), elementNames);
}
}
writeLine(line);
}
}
static int lengthCommonPrefix(List<String> l1, List<String> l2) {
final int maxLen = Math.min(l1.size(), l2.size());
int result = 0;
for (int i = 0; i < maxLen; i++) {
String s1 = l1.get(i);
String s2 = l2.get(i);
if (s1.equals(s2))
result++;
else return result;
}
return result;
}
}

@ -6,7 +6,10 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.Point;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@ -18,9 +21,9 @@ class YamlConfigurationStoreTest {
private final FileSystem fs = Jimfs.newFileSystem();
private final String yamlFilePath = createPlatformSpecificFilePath("/tmp/config.yml");
private final Path yamlFile = fs.getPath(yamlFilePath);
private final String abcFilePath = createPlatformSpecificFilePath("/a/b/c.yml");
private final Path yamlFile = fs.getPath(yamlFilePath);
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@BeforeEach
void setUp() throws IOException {
@ -54,6 +57,21 @@ class YamlConfigurationStoreTest {
);
}
@Test
void writeRequiresNonNullArguments() {
YamlConfigurationStore<A> store = newDefaultStore(A.class);
assertThrowsNullPointerException(
() -> store.write(null, new ByteArrayOutputStream()),
"configuration"
);
assertThrowsNullPointerException(
() -> store.write(new A(), null),
"output stream"
);
}
@Test
void loadRequiresNonNullArguments() {
YamlConfigurationStore<A> store = newDefaultStore(A.class);
@ -64,6 +82,16 @@ class YamlConfigurationStoreTest {
);
}
@Test
void readRequiresNonNullArguments() {
YamlConfigurationStore<A> store = newDefaultStore(A.class);
assertThrowsNullPointerException(
() -> store.read(null),
"input stream"
);
}
@Test
void updateRequiresNonNullArguments() {
YamlConfigurationStore<A> store = newDefaultStore(A.class);
@ -75,15 +103,18 @@ class YamlConfigurationStoreTest {
}
@Test
void save() {
void saveAndWrite() {
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
.header("The\nHeader")
.footer("The\nFooter")
.outputNulls(true)
.setNameFormatter(String::toUpperCase)
.build();
YamlConfigurationStore<A> store = new YamlConfigurationStore<>(A.class, properties);
store.save(new A(), yamlFile);
store.write(new A(), outputStream);
String expected =
"""
@ -95,13 +126,15 @@ class YamlConfigurationStoreTest {
I: null
# The
# Footer\
# Footer
""";
assertEquals(expected, readFile(yamlFile));
assertEquals(expected, outputStream.toString());
}
@Test
void saveRecord() {
void saveAndWriteRecord() {
record R(String s, @Comment("A comment") Integer i) {}
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
.header("The\nHeader")
@ -110,7 +143,9 @@ class YamlConfigurationStoreTest {
.setNameFormatter(String::toUpperCase)
.build();
YamlConfigurationStore<R> store = new YamlConfigurationStore<>(R.class, properties);
store.save(new R("S1", null), yamlFile);
store.write(new R("S1", null), outputStream);
String expected =
"""
@ -122,9 +157,11 @@ class YamlConfigurationStoreTest {
I: null
# The
# Footer\
# Footer
""";
assertEquals(expected, readFile(yamlFile));
assertEquals(expected, outputStream.toString());
}
@Configuration
@ -135,36 +172,40 @@ class YamlConfigurationStoreTest {
}
@Test
void load() throws IOException {
void loadAndRead() throws IOException {
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
.inputNulls(true)
.setNameFormatter(String::toUpperCase)
.build();
YamlConfigurationStore<B> store = new YamlConfigurationStore<>(B.class, properties);
Files.writeString(
yamlFile,
"""
# The
# Header
S: S2
t: T2
I: null
# The
# Footer\
"""
);
B config = store.load(yamlFile);
assertEquals("S2", config.s);
assertEquals("T1", config.t);
assertNull(config.i);
String actual = """
# The
# Header
S: S2
t: T2
I: null
# The
# Footer
""";
Files.writeString(yamlFile, actual);
outputStream.writeBytes(actual.getBytes());
B config1 = store.load(yamlFile);
assertEquals("S2", config1.s);
assertEquals("T1", config1.t);
assertNull(config1.i);
B config2 = store.read(inputFromOutput());
assertEquals("S2", config2.s);
assertEquals("T1", config2.t);
assertNull(config2.i);
}
@Test
void loadRecord() throws IOException {
void loadAndReadRecord() throws IOException {
record R(String s, String t, Integer i) {}
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
.inputNulls(true)
@ -172,25 +213,29 @@ class YamlConfigurationStoreTest {
.build();
YamlConfigurationStore<R> store = new YamlConfigurationStore<>(R.class, properties);
Files.writeString(
yamlFile,
"""
# The
# Header
S: S2
t: T2
I: null
# The
# Footer\
"""
);
R config = store.load(yamlFile);
assertEquals("S2", config.s);
assertNull(config.t);
assertNull(config.i);
String actual = """
# The
# Header
S: S2
t: T2
I: null
# The
# Footer
""";
Files.writeString(yamlFile, actual);
outputStream.writeBytes(actual.getBytes());
R config1 = store.load(yamlFile);
assertEquals("S2", config1.s);
assertNull(config1.t);
assertNull(config1.i);
R config2 = store.read(inputFromOutput());
assertEquals("S2", config2.s);
assertNull(config2.t);
assertNull(config2.i);
}
@Configuration
@ -199,46 +244,63 @@ class YamlConfigurationStoreTest {
}
@Test
void loadInvalidYaml() throws IOException {
void loadAndReadInvalidYaml() throws IOException {
YamlConfigurationStore<C> store = newDefaultStore(C.class);
Files.writeString(
yamlFile,
"""
- - - - - a
a
"""
);
String actual = """
- - - - - a
a
""";
Files.writeString(yamlFile, actual);
outputStream.writeBytes(actual.getBytes());
assertThrowsConfigurationException(
() -> store.load(yamlFile),
String.format("The configuration file at %s does not contain valid YAML.", yamlFilePath)
);
assertThrowsConfigurationException(
() -> store.read(inputFromOutput()),
"The input stream does not contain valid YAML."
);
}
@Test
void loadEmptyYaml() throws IOException {
void loadAndReadEmptyYaml() throws IOException {
YamlConfigurationStore<C> store = newDefaultStore(C.class);
Files.writeString(yamlFile, "null");
outputStream.writeBytes("null".getBytes());
assertThrowsConfigurationException(
() -> store.load(yamlFile),
String.format("The configuration file at %s is empty or only contains null.", yamlFilePath)
String.format("The configuration file at %s is empty or only contains null.", yamlFilePath)
);
assertThrowsConfigurationException(
() -> store.read(inputFromOutput()),
"The input stream is empty or only contains null."
);
}
@Test
void loadNonMapYaml() throws IOException {
void loadAndReadNonMapYaml() throws IOException {
YamlConfigurationStore<C> store = newDefaultStore(C.class);
Files.writeString(yamlFile, "a");
outputStream.writeBytes("a".getBytes());
assertThrowsConfigurationException(
() -> store.load(yamlFile),
String.format("The contents of the YAML file at %s do not represent a " +
"configuration. A valid configuration file contains a YAML map but instead a " +
"'class java.lang.String' was found.", yamlFilePath)
String.format(
"The contents of the YAML file at %s do not represent a " +
"configuration. A valid configuration file contains a YAML map but instead a " +
"'class java.lang.String' was found.", yamlFilePath)
);
assertThrowsConfigurationException(
() -> store.read(inputFromOutput()),
"The contents of the input stream do not represent a configuration. " +
"A valid configuration contains a YAML map but instead a " +
"'class java.lang.String' was found."
);
}
@ -248,17 +310,16 @@ class YamlConfigurationStoreTest {
}
@Test
void saveConfigurationWithInvalidTargetType() {
void saveAndWriteConfigurationWithInvalidTargetType() {
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder()
.addSerializer(Point.class, POINT_IDENTITY_SERIALIZER)
.build();
YamlConfigurationStore<D> store = new YamlConfigurationStore<>(D.class, properties);
assertThrowsConfigurationException(
() -> store.save(new D(), yamlFile),
"The given configuration could not be converted into YAML. \n" +
"Do all custom serializers produce valid target types?"
);
String exceptionMessage = "The given configuration could not be converted into YAML. \n" +
"Do all custom serializers produce valid target types?";
assertThrowsConfigurationException(() -> store.save(new D(), yamlFile), exceptionMessage);
assertThrowsConfigurationException(() -> store.write(new D(), outputStream), exceptionMessage);
}
@Test
@ -305,7 +366,7 @@ class YamlConfigurationStoreTest {
assertFalse(Files.exists(yamlFile));
E config = store.update(yamlFile);
assertEquals("i: 10\nj: 11", readFile(yamlFile));
assertEquals("i: 10\nj: 11\n", readFile(yamlFile));
assertEquals(10, config.i);
assertEquals(11, config.j);
}
@ -324,7 +385,7 @@ class YamlConfigurationStoreTest {
"""
i: 0
c: "\\0"
s: null\
s: null
""",
readFile(yamlFile)
);
@ -346,7 +407,7 @@ class YamlConfigurationStoreTest {
"""
i: 10
c: c
s: s\
s: s
""",
readFile(yamlFile)
);
@ -384,7 +445,7 @@ class YamlConfigurationStoreTest {
E config = store.update(yamlFile);
assertEquals(20, config.i);
assertEquals(11, config.j);
assertEquals("i: 20\nj: 11", readFile(yamlFile));
assertEquals("i: 20\nj: 11\n", readFile(yamlFile));
}
@Test
@ -396,11 +457,15 @@ class YamlConfigurationStoreTest {
R config = store.update(yamlFile);
assertEquals(20, config.i);
assertEquals(0, config.j);
assertEquals("i: 20\nj: 0", readFile(yamlFile));
assertEquals("i: 20\nj: 0\n", readFile(yamlFile));
}
private static <T> YamlConfigurationStore<T> newDefaultStore(Class<T> configType) {
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder().build();
return new YamlConfigurationStore<>(configType, properties);
}
private InputStream inputFromOutput() {
return new ByteArrayInputStream(outputStream.toByteArray());
}
}

@ -5,7 +5,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@ -17,6 +20,7 @@ class YamlConfigurationsTest {
private static final FieldFilter includeI = field -> field.getName().equals("i");
private final FileSystem fs = Jimfs.newFileSystem();
private final Path yamlFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.yml"));
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@BeforeEach
void setUp() throws IOException {
@ -32,13 +36,6 @@ class YamlConfigurationsTest {
private static final class Config {
int i = 10;
int j = 11;
public Config() {}
public Config(int i, int j) {
this.i = i;
this.j = j;
}
}
@Test
@ -46,13 +43,28 @@ class YamlConfigurationsTest {
Config configuration = new Config();
YamlConfigurations.save(yamlFile, Config.class, configuration);
assertEquals("i: 10\nj: 11", TestUtils.readFile(yamlFile));
assertEquals("i: 10\nj: 11\n", TestUtils.readFile(yamlFile));
configuration.i = 20;
YamlConfigurations.save(yamlFile, Config.class, configuration);
assertEquals("i: 20\nj: 11", TestUtils.readFile(yamlFile));
assertEquals("i: 20\nj: 11\n", TestUtils.readFile(yamlFile));
}
@Test
void writeYamlConfiguration1() {
Config configuration = new Config();
YamlConfigurations.write(outputStream, Config.class, configuration);
assertEquals("i: 10\nj: 11\n", outputStream.toString());
outputStream.reset();
configuration.i = 20;
YamlConfigurations.write(outputStream, Config.class, configuration);
assertEquals("i: 20\nj: 11\n", outputStream.toString());
}
@Test
void saveYamlConfiguration2() {
Config configuration = new Config();
@ -61,7 +73,18 @@ class YamlConfigurationsTest {
yamlFile, Config.class, configuration,
builder -> builder.setFieldFilter(includeI)
);
assertEquals("i: 10", TestUtils.readFile(yamlFile));
assertEquals("i: 10\n", TestUtils.readFile(yamlFile));
}
@Test
void writeYamlConfiguration2() {
Config configuration = new Config();
YamlConfigurations.write(
outputStream, Config.class, configuration,
builder -> builder.setFieldFilter(includeI)
);
assertEquals("i: 10\n", outputStream.toString());
}
@Test
@ -72,23 +95,48 @@ class YamlConfigurationsTest {
yamlFile, Config.class, configuration,
YamlConfigurationProperties.newBuilder().setFieldFilter(includeI).build()
);
assertEquals("i: 10", TestUtils.readFile(yamlFile));
assertEquals("i: 10\n", TestUtils.readFile(yamlFile));
}
@Test
void writeYamlConfiguration3() {
Config configuration = new Config();
YamlConfigurations.write(
outputStream, Config.class, configuration,
YamlConfigurationProperties.newBuilder().setFieldFilter(includeI).build()
);
assertEquals("i: 10\n", outputStream.toString());
}
@Test
void loadYamlConfiguration1() {
writeString("i: 20\nk: 30");
writeStringToFile("i: 20\nk: 30");
Config config = YamlConfigurations.load(yamlFile, Config.class);
assertConfigEquals(config, 20, 11);
writeString("i: 20\nj: 30");
writeStringToFile("i: 20\nj: 30");
config = YamlConfigurations.load(yamlFile, Config.class);
assertConfigEquals(config, 20, 30);
}
@Test
void readYamlConfiguration1() {
writeStringToStream("i: 20\nk: 30");
Config config = YamlConfigurations.read(inputFromOutput(), Config.class);
assertConfigEquals(config, 20, 11);
outputStream.reset();
writeStringToStream("i: 20\nj: 30");
config = YamlConfigurations.read(inputFromOutput(), Config.class);
assertConfigEquals(config, 20, 30);
}
@Test
void loadYamlConfiguration2() {
writeString("i: 20\nj: 30");
writeStringToFile("i: 20\nj: 30");
Config config = YamlConfigurations.load(
yamlFile, Config.class,
builder -> builder.setFieldFilter(includeI)
@ -96,9 +144,19 @@ class YamlConfigurationsTest {
assertConfigEquals(config, 20, 11);
}
@Test
void readYamlConfiguration2() {
writeStringToStream("i: 20\nj: 30");
Config config = YamlConfigurations.read(
inputFromOutput(), Config.class,
builder -> builder.setFieldFilter(includeI)
);
assertConfigEquals(config, 20, 11);
}
@Test
void loadYamlConfiguration3() {
writeString("i: 20\nj: 30");
writeStringToFile("i: 20\nj: 30");
Config config = YamlConfigurations.load(
yamlFile, Config.class,
@ -108,16 +166,28 @@ class YamlConfigurationsTest {
assertConfigEquals(config, 20, 11);
}
@Test
void readYamlConfiguration3() {
writeStringToStream("i: 20\nj: 30");
Config config = YamlConfigurations.read(
inputFromOutput(), Config.class,
YamlConfigurationProperties.newBuilder().setFieldFilter(includeI).build()
);
assertConfigEquals(config, 20, 11);
}
@Test
void updateYamlConfiguration1() {
Config config = YamlConfigurations.update(yamlFile, Config.class);
assertConfigEquals(config, 10, 11);
assertEquals("i: 10\nj: 11", TestUtils.readFile(yamlFile));
assertEquals("i: 10\nj: 11\n", TestUtils.readFile(yamlFile));
writeString("i: 20\nk: 30");
writeStringToFile("i: 20\nk: 30");
config = YamlConfigurations.update(yamlFile, Config.class);
assertConfigEquals(config, 20, 11);
assertEquals("i: 20\nj: 11", TestUtils.readFile(yamlFile));
assertEquals("i: 20\nj: 11\n", TestUtils.readFile(yamlFile));
}
@Test
@ -127,7 +197,7 @@ class YamlConfigurationsTest {
builder -> builder.setFieldFilter(includeI)
);
assertConfigEquals(config, 10, 11);
assertEquals("i: 10", TestUtils.readFile(yamlFile));
assertEquals("i: 10\n", TestUtils.readFile(yamlFile));
}
@Test
@ -137,7 +207,7 @@ class YamlConfigurationsTest {
YamlConfigurationProperties.newBuilder().setFieldFilter(includeI).build()
);
assertConfigEquals(config, 10, 11);
assertEquals("i: 10", TestUtils.readFile(yamlFile));
assertEquals("i: 10\n", TestUtils.readFile(yamlFile));
}
private static void assertConfigEquals(Config config, int i, int j) {
@ -145,11 +215,19 @@ class YamlConfigurationsTest {
assertEquals(j, config.j);
}
private void writeString(String string) {
private void writeStringToFile(String string) {
try {
Files.writeString(yamlFile, string);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private void writeStringToStream(String string) {
outputStream.writeBytes(string.getBytes());
}
private InputStream inputFromOutput() {
return new ByteArrayInputStream(outputStream.toByteArray());
}
}

@ -1,505 +0,0 @@
package de.exlll.configlib;
import com.google.common.jimfs.Jimfs;
import de.exlll.configlib.YamlConfigurationStore.YamlFileWriter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.snakeyaml.engine.v2.api.Dump;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.function.Consumer;
import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SuppressWarnings("unused")
class YamlFileWriterTest {
private final FileSystem fs = Jimfs.newFileSystem();
private final Path yamlFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.yml"));
@BeforeEach
void setUp() throws IOException {
Files.createDirectories(yamlFile.getParent());
}
@AfterEach
void tearDown() throws IOException {
fs.close();
}
@Configuration
static final class A {
String s = "";
}
@Test
void writeYamlWithNoComments() {
writeConfig(A.class);
assertFileContentEquals("s: ''");
}
@Test
void writeYamlWithHeaderAndFooter() {
writeConfig(
A.class,
builder -> builder
.header("This is a \n\n \nheader.")
.footer("That is a\n\n \nfooter.")
);
assertFileContentEquals(
"""
# This is a\s
# \s
# header.
s: ''
# That is a
# \s
# footer.\
"""
);
}
@Configuration
static final class B {
@Comment("Hello")
String s = "s";
}
@Test
void writeYamlSingleComment() {
writeConfig(B.class);
assertFileContentEquals(
"""
# Hello
s: s\
"""
);
}
@Configuration
static final class C {
@Comment({"Hello", "World"})
Map<String, Integer> mapStringInteger = Map.of("1", 2);
@Comment({"world", "hello"})
Map<Integer, String> mapIntegerString = Map.of(2, "1");
}
@Test
void writeYamlMultipleComments() {
writeConfig(C.class);
assertFileContentEquals(
"""
# Hello
# World
mapStringInteger:
'1': 2
# world
# hello
mapIntegerString:
2: '1'\
"""
);
}
@Configuration
static final class D {
@Comment({"Hello", "", " ", "World"})
String s1 = "s1";
@Comment({"", "", " ", "How are ", "you?", ""})
String s2 = "s2";
}
@Test
void writeYamlEmptyComments() {
writeConfig(D.class);
assertFileContentEquals(
"""
# Hello
# \s
# World
s1: s1
# \s
# How are\s
# you?
s2: s2\
"""
);
}
@Configuration
static final class E1 {
@Comment("m")
Map<String, Map<String, Integer>> m = Map.of("c", Map.of("i", 1));
@Comment("e2")
E2 e2 = new E2();
}
@Configuration
static final class E2 {
Map<String, Integer> m = Map.of("i", 1);
@Comment("e3")
E3 e3 = new E3();
@Comment("j")
int j = 10;
}
@Configuration
static final class E3 {
@Comment("i")
int i = 1;
}
@Test
void writeYamlNestedComments1() {
writeConfig(E1.class);
assertFileContentEquals(
"""
# m
m:
c:
i: 1
# e2
e2:
m:
i: 1
# e3
e3:
# i
i: 1
# j
j: 10\
"""
);
}
@Configuration
static final class F1 {
Map<String, Integer> m1 = Map.of("i", 1);
F2 f2 = new F2();
@Comment("f1.m2")
Map<String, Integer> m2 = Map.of("i", 1);
}
@Configuration
static final class F2 {
@Comment("f2.i")
int i;
}
@Test
void writeYamlNestedComments2() {
writeConfig(F1.class);
assertFileContentEquals(
"""
m1:
i: 1
f2:
# f2.i
i: 0
# f1.m2
m2:
i: 1\
"""
);
}
@Configuration
static final class G1 {
@Comment("g1.g2")
G2 g2 = new G2();
}
@Configuration
static final class G2 {
G3 g3 = new G3();
}
@Configuration
static final class G3 {
G4 g4 = new G4();
}
@Configuration
static final class G4 {
@Comment({"g4.g3 1", "g4.g3 2"})
int g3;
@Comment("g4.g4")
int g4;
}
@Test
void writeYamlNestedComments3() {
writeConfig(G1.class);
assertFileContentEquals(
"""
# g1.g2
g2:
g3:
g4:
# g4.g3 1
# g4.g3 2
g3: 0
# g4.g4
g4: 0\
"""
);
}
@Configuration
static final class H1 {
@Comment("h2.1")
H2 h21 = new H2();
@Comment("h2.2")
H2 h22 = null;
}
@Configuration
static final class H2 {
@Comment("j")
int j = 10;
}
@Test
void writeYamlNullFields() {
writeConfig(H1.class);
assertFileContentEquals(
"""
# h2.1
h21:
# j
j: 10\
"""
);
writeConfig(H1.class, builder -> builder.outputNulls(true));
assertFileContentEquals(
"""
# h2.1
h21:
# j
j: 10
# h2.2
h22: null\
"""
);
}
@Configuration
static class J1 {
@Comment("sj1")
String sJ1 = "sj1";
}
static final class J2 extends J1 {
@Comment("sj2")
String sJ2 = "sj2";
}
@Configuration
static class K1 {
@Comment("k1.j1")
J1 k1J1 = new J1();
@Comment("k1.j2")
J2 k1J2 = new J2();
}
static final class K2 extends K1 {
@Comment("k2.j1")
J1 k2J1 = new J1();
@Comment("k2.j2")
J2 k2J2 = new J2();
}
@Test
void writeYamlInheritance() {
writeConfig(K2.class);
assertFileContentEquals(
"""
# k1.j1
k1J1:
# sj1
sJ1: sj1
# k1.j2
k1J2:
# sj1
sJ1: sj1
# sj2
sJ2: sj2
# k2.j1
k2J1:
# sj1
sJ1: sj1
# k2.j2
k2J2:
# sj1
sJ1: sj1
# sj2
sJ2: sj2\
"""
);
}
record R1(@Comment("Hello") int i, int j, @Comment("World") int k) {}
@Configuration
static class L1 {
@Comment("l1")
R1 r1 = new R1(1, 2, 3);
}
@Test
void writeYamlConfigWithRecord() {
writeConfig(L1.class);
assertFileContentEquals(
"""
# l1
r1:
# Hello
i: 1
j: 2
# World
k: 3\
"""
);
}
record R2(@Comment("r2i") int i, int j, @Comment("r2k") int k) {}
record R3(@Comment("r3r2") R2 r2) {}
record R4(@Comment("r4m1") M1 m1, @Comment("r4r3") R3 r3) {}
@Configuration
static class M1 {
@Comment("m1r2")
R2 r2 = new R2(1, 2, 3);
@Comment("m1r3")
R3 r3 = new R3(new R2(4, 5, 6));
}
@Configuration
static class M2 {
@Comment("m2r4")
R4 r4 = new R4(new M1(), new R3(new R2(7, 8, 9)));
}
@Test
void writeYamlConfigWithRecordNested() {
writeConfig(M2.class);
assertFileContentEquals(
"""
# m2r4
r4:
# r4m1
m1:
# m1r2
r2:
# r2i
i: 1
j: 2
# r2k
k: 3
# m1r3
r3:
# r3r2
r2:
# r2i
i: 4
j: 5
# r2k
k: 6
# r4r3
r3:
# r3r2
r2:
# r2i
i: 7
j: 8
# r2k
k: 9\
"""
);
}
@Test
void lengthCommonPrefix() {
List<String> ab = List.of("a", "b");
List<String> abc = List.of("a", "b", "c");
List<String> abcd = List.of("a", "b", "c", "d");
List<String> aef = List.of("a", "e", "f");
List<String> def = List.of("d", "e", "f");
assertEquals(2, YamlFileWriter.lengthCommonPrefix(ab, ab));
assertEquals(2, YamlFileWriter.lengthCommonPrefix(abc, ab));
assertEquals(2, YamlFileWriter.lengthCommonPrefix(ab, abc));
assertEquals(2, YamlFileWriter.lengthCommonPrefix(ab, abcd));
assertEquals(3, YamlFileWriter.lengthCommonPrefix(abc, abc));
assertEquals(3, YamlFileWriter.lengthCommonPrefix(abc, abcd));
assertEquals(1, YamlFileWriter.lengthCommonPrefix(ab, aef));
assertEquals(1, YamlFileWriter.lengthCommonPrefix(abcd, aef));
assertEquals(0, YamlFileWriter.lengthCommonPrefix(ab, def));
assertEquals(0, YamlFileWriter.lengthCommonPrefix(abcd, def));
}
String readFile() {
return TestUtils.readFile(yamlFile);
}
record YamlFileWriterArguments(
String yaml,
Queue<CommentNode> nodes,
YamlConfigurationProperties properties
) {}
void assertFileContentEquals(String expected) {
assertEquals(expected, readFile());
}
void writeConfig(Class<?> cls) {
writeConfig(cls, builder -> {});
}
<T> void writeConfig(Class<T> cls, Consumer<YamlConfigurationProperties.Builder<?>> configurer) {
YamlFileWriterArguments args = argsFromConfig(
cls,
Reflect.callNoParamConstructor(cls),
configurer
);
YamlFileWriter writer = new YamlFileWriter(yamlFile, args.properties);
writer.writeYaml(args.yaml, args.nodes);
}
static <T> YamlFileWriterArguments argsFromConfig(
Class<T> t,
T c,
Consumer<YamlConfigurationProperties.Builder<?>> configurer
) {
YamlConfigurationProperties.Builder<?> builder = YamlConfigurationProperties.newBuilder();
configurer.accept(builder);
YamlConfigurationProperties properties = builder.build();
ConfigurationSerializer<T> serializer = new ConfigurationSerializer<>(t, properties);
Map<?, ?> serialize = serializer.serialize(c);
Dump dump = YamlConfigurationStore.newYamlDumper();
String yaml = dump.dumpToString(serialize);
CommentNodeExtractor extractor = new CommentNodeExtractor(properties);
Queue<CommentNode> nodes = extractor.extractCommentNodes(c);
return new YamlFileWriterArguments(yaml, nodes, properties);
}
}

@ -0,0 +1,569 @@
package de.exlll.configlib;
import com.google.common.jimfs.Jimfs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.snakeyaml.engine.v2.api.Dump;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.function.Consumer;
import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SuppressWarnings("unused")
class YamlWriterTest {
private final FileSystem fs = Jimfs.newFileSystem();
private final Path yamlFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.yml"));
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@BeforeEach
void setUp() throws IOException {
Files.createDirectories(yamlFile.getParent());
}
@AfterEach
void tearDown() throws IOException {
fs.close();
}
@Configuration
static final class A {
String s = "";
}
@Test
void writeYamlWithNoComments() {
writeConfigToFile(A.class);
writeConfigToStream(A.class);
assertFileContentEquals("s: ''\n");
assertStreamContentEquals("s: ''\n");
}
@Test
void writeYamlWithHeaderAndFooter() {
Consumer<YamlConfigurationProperties.Builder<?>> builderConsumer = builder -> builder
.header("This is a \n\n \nheader.")
.footer("That is a\n\n \nfooter.");
writeConfigToFile(A.class, builderConsumer);
writeConfigToStream(A.class, builderConsumer);
String expected = """
# This is a\s
# \s
# header.
s: ''
# That is a
# \s
# footer.
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class B {
@Comment("Hello")
String s = "s";
}
@Test
void writeYamlSingleComment() {
writeConfigToFile(B.class);
writeConfigToStream(B.class);
String expected = """
# Hello
s: s
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class C {
@Comment({"Hello", "World"})
Map<String, Integer> mapStringInteger = Map.of("1", 2);
@Comment({"world", "hello"})
Map<Integer, String> mapIntegerString = Map.of(2, "1");
}
@Test
void writeYamlMultipleComments() {
writeConfigToFile(C.class);
writeConfigToStream(C.class);
String expected = """
# Hello
# World
mapStringInteger:
'1': 2
# world
# hello
mapIntegerString:
2: '1'
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class D {
@Comment({"Hello", "", " ", "World"})
String s1 = "s1";
@Comment({"", "", " ", "How are ", "you?", ""})
String s2 = "s2";
}
@Test
void writeYamlEmptyComments() {
writeConfigToFile(D.class);
writeConfigToStream(D.class);
String expected = """
# Hello
# \s
# World
s1: s1
# \s
# How are\s
# you?
s2: s2
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class E1 {
@Comment("m")
Map<String, Map<String, Integer>> m = Map.of("c", Map.of("i", 1));
@Comment("e2")
E2 e2 = new E2();
}
@Configuration
static final class E2 {
Map<String, Integer> m = Map.of("i", 1);
@Comment("e3")
E3 e3 = new E3();
@Comment("j")
int j = 10;
}
@Configuration
static final class E3 {
@Comment("i")
int i = 1;
}
@Test
void writeYamlNestedComments1() {
writeConfigToFile(E1.class);
writeConfigToStream(E1.class);
String expected = """
# m
m:
c:
i: 1
# e2
e2:
m:
i: 1
# e3
e3:
# i
i: 1
# j
j: 10
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class F1 {
Map<String, Integer> m1 = Map.of("i", 1);
F2 f2 = new F2();
@Comment("f1.m2")
Map<String, Integer> m2 = Map.of("i", 1);
}
@Configuration
static final class F2 {
@Comment("f2.i")
int i;
}
@Test
void writeYamlNestedComments2() {
writeConfigToFile(F1.class);
writeConfigToStream(F1.class);
String expected = """
m1:
i: 1
f2:
# f2.i
i: 0
# f1.m2
m2:
i: 1
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class G1 {
@Comment("g1.g2")
G2 g2 = new G2();
}
@Configuration
static final class G2 {
G3 g3 = new G3();
}
@Configuration
static final class G3 {
G4 g4 = new G4();
}
@Configuration
static final class G4 {
@Comment({"g4.g3 1", "g4.g3 2"})
int g3;
@Comment("g4.g4")
int g4;
}
@Test
void writeYamlNestedComments3() {
writeConfigToFile(G1.class);
writeConfigToStream(G1.class);
String expected = """
# g1.g2
g2:
g3:
g4:
# g4.g3 1
# g4.g3 2
g3: 0
# g4.g4
g4: 0
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static final class H1 {
@Comment("h2.1")
H2 h21 = new H2();
@Comment("h2.2")
H2 h22 = null;
}
@Configuration
static final class H2 {
@Comment("j")
int j = 10;
}
@Test
void writeYamlNullFields1() {
writeConfigToFile(H1.class);
writeConfigToStream(H1.class);
String expected = """
# h2.1
h21:
# j
j: 10
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Test
void writeYamlNullFields2() {
writeConfigToFile(H1.class, builder -> builder.outputNulls(true));
writeConfigToStream(H1.class, builder -> builder.outputNulls(true));
String expected = """
# h2.1
h21:
# j
j: 10
# h2.2
h22: null
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Configuration
static class J1 {
@Comment("sj1")
String sJ1 = "sj1";
}
static final class J2 extends J1 {
@Comment("sj2")
String sJ2 = "sj2";
}
@Configuration
static class K1 {
@Comment("k1.j1")
J1 k1J1 = new J1();
@Comment("k1.j2")
J2 k1J2 = new J2();
}
static final class K2 extends K1 {
@Comment("k2.j1")
J1 k2J1 = new J1();
@Comment("k2.j2")
J2 k2J2 = new J2();
}
@Test
void writeYamlInheritance() {
writeConfigToFile(K2.class);
writeConfigToStream(K2.class);
String expected = """
# k1.j1
k1J1:
# sj1
sJ1: sj1
# k1.j2
k1J2:
# sj1
sJ1: sj1
# sj2
sJ2: sj2
# k2.j1
k2J1:
# sj1
sJ1: sj1
# k2.j2
k2J2:
# sj1
sJ1: sj1
# sj2
sJ2: sj2
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
record R1(@Comment("Hello") int i, int j, @Comment("World") int k) {}
@Configuration
static class L1 {
@Comment("l1")
R1 r1 = new R1(1, 2, 3);
}
@Test
void writeYamlConfigWithRecord() {
writeConfigToFile(L1.class);
writeConfigToStream(L1.class);
String expected = """
# l1
r1:
# Hello
i: 1
j: 2
# World
k: 3
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
record R2(@Comment("r2i") int i, int j, @Comment("r2k") int k) {}
record R3(@Comment("r3r2") R2 r2) {}
record R4(@Comment("r4m1") M1 m1, @Comment("r4r3") R3 r3) {}
@Configuration
static class M1 {
@Comment("m1r2")
R2 r2 = new R2(1, 2, 3);
@Comment("m1r3")
R3 r3 = new R3(new R2(4, 5, 6));
}
@Configuration
static class M2 {
@Comment("m2r4")
R4 r4 = new R4(new M1(), new R3(new R2(7, 8, 9)));
}
@Test
void writeYamlConfigWithRecordNested() {
writeConfigToFile(M2.class);
writeConfigToStream(M2.class);
String expected = """
# m2r4
r4:
# r4m1
m1:
# m1r2
r2:
# r2i
i: 1
j: 2
# r2k
k: 3
# m1r3
r3:
# r3r2
r2:
# r2i
i: 4
j: 5
# r2k
k: 6
# r4r3
r3:
# r3r2
r2:
# r2i
i: 7
j: 8
# r2k
k: 9
""";
assertFileContentEquals(expected);
assertStreamContentEquals(expected);
}
@Test
void lengthCommonPrefix() {
List<String> ab = List.of("a", "b");
List<String> abc = List.of("a", "b", "c");
List<String> abcd = List.of("a", "b", "c", "d");
List<String> aef = List.of("a", "e", "f");
List<String> def = List.of("d", "e", "f");
assertEquals(2, YamlWriter.lengthCommonPrefix(ab, ab));
assertEquals(2, YamlWriter.lengthCommonPrefix(abc, ab));
assertEquals(2, YamlWriter.lengthCommonPrefix(ab, abc));
assertEquals(2, YamlWriter.lengthCommonPrefix(ab, abcd));
assertEquals(3, YamlWriter.lengthCommonPrefix(abc, abc));
assertEquals(3, YamlWriter.lengthCommonPrefix(abc, abcd));
assertEquals(1, YamlWriter.lengthCommonPrefix(ab, aef));
assertEquals(1, YamlWriter.lengthCommonPrefix(abcd, aef));
assertEquals(0, YamlWriter.lengthCommonPrefix(ab, def));
assertEquals(0, YamlWriter.lengthCommonPrefix(abcd, def));
}
String readFile() {
return TestUtils.readFile(yamlFile);
}
String readOutputStream() {
return outputStream.toString();
}
void assertFileContentEquals(String expected) {
assertEquals(expected, readFile());
}
void assertStreamContentEquals(String expected) {
assertEquals(expected, readOutputStream());
}
void writeConfigToFile(Class<?> cls) {
writeConfigToFile(cls, builder -> {});
}
<T> void writeConfigToFile(Class<T> cls, Consumer<YamlConfigurationProperties.Builder<?>> configurer) {
YamlWriterArguments args = argsFromConfig(
cls,
Reflect.callNoParamConstructor(cls),
configurer
);
YamlWriter writer = new YamlWriter(yamlFile, args.properties);
writer.writeYaml(args.yaml, args.nodes);
}
void writeConfigToStream(Class<?> cls) {
writeConfigToStream(cls, builder -> {});
}
<T> void writeConfigToStream(Class<T> cls, Consumer<YamlConfigurationProperties.Builder<?>> configurer) {
YamlWriterArguments args = argsFromConfig(
cls,
Reflect.callNoParamConstructor(cls),
configurer
);
YamlWriter writer = new YamlWriter(outputStream, args.properties);
writer.writeYaml(args.yaml, args.nodes);
}
record YamlWriterArguments(
String yaml,
Queue<CommentNode> nodes,
YamlConfigurationProperties properties
) {}
static <T> YamlWriterArguments argsFromConfig(
Class<T> t,
T c,
Consumer<YamlConfigurationProperties.Builder<?>> configurer
) {
YamlConfigurationProperties.Builder<?> builder = YamlConfigurationProperties.newBuilder();
configurer.accept(builder);
YamlConfigurationProperties properties = builder.build();
ConfigurationSerializer<T> serializer = new ConfigurationSerializer<>(t, properties);
Map<?, ?> serialize = serializer.serialize(c);
Dump dump = YamlConfigurationStore.newYamlDumper();
String yaml = dump.dumpToString(serialize);
CommentNodeExtractor extractor = new CommentNodeExtractor(properties);
Queue<CommentNode> nodes = extractor.extractCommentNodes(c);
return new YamlWriterArguments(yaml, nodes, properties);
}
}
Loading…
Cancel
Save