Skip to content

Commit

Permalink
Add IOStreamConfigurationStore
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Exlll committed Dec 1, 2023
1 parent ef7c611 commit a5d05e2
Show file tree
Hide file tree
Showing 11 changed files with 1,525 additions and 996 deletions.
577 changes: 326 additions & 251 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
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 =
Expand Down Expand Up @@ -279,8 +277,8 @@ public static <T, C extends Collection<T[]>> boolean collectionOfArraysDeepEqual
}

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);
}
Expand Down Expand Up @@ -312,4 +310,4 @@ public static String createPlatformSpecificFilePath(String path) {
public static List<String> createListOfPlatformSpecificFilePaths(String... paths) {
return Stream.of(paths).map(TestUtils::createPlatformSpecificFilePath).toList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@
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;
Expand All @@ -47,13 +50,24 @@ public YamlConfigurationStore(Class<T> configurationType, YamlConfigurationPrope
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);
}
Expand All @@ -80,12 +94,41 @@ private String tryDump(T configuration) {
}
}

@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.";
Expand All @@ -95,20 +138,20 @@ public T load(Path configurationFile) {
}
}

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
Expand Down Expand Up @@ -137,134 +180,6 @@ static Load newYamlLoader() {
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.
*/
Expand Down
Loading

0 comments on commit a5d05e2

Please sign in to comment.