diff --git a/core/src/main/java/io/jstach/rainbowgum/LogEvent.java b/core/src/main/java/io/jstach/rainbowgum/LogEvent.java index 9a341f75..24e47bb8 100644 --- a/core/src/main/java/io/jstach/rainbowgum/LogEvent.java +++ b/core/src/main/java/io/jstach/rainbowgum/LogEvent.java @@ -212,6 +212,13 @@ default void formattedMessage(Appendable a) { */ public LogEvent freeze(); + /** + * Freeze and replace with the given timestamp. + * @param timestamp instant to replace timestamp in this. + * @return a copy of this with the given timestamp. + */ + public LogEvent freeze(Instant timestamp); + } enum EmptyLogEvent implements LogEvent { @@ -276,6 +283,11 @@ public LogEvent freeze() { return this; } + @Override + public LogEvent freeze(Instant timestamp) { + return this; + } + } record OneArgLogEvent(Instant timestamp, String threadName, long threadId, System.Logger.Level level, String loggerName, @@ -295,6 +307,10 @@ public int argCount() { } public LogEvent freeze() { + return freeze(timestamp); + } + + public LogEvent freeze(Instant timestamp) { StringBuilder sb = new StringBuilder(message.length()); formattedMessage(sb); return new DefaultLogEvent(timestamp, threadName, threadId, level, loggerName, sb.toString(), @@ -320,6 +336,10 @@ public int argCount() { } public LogEvent freeze() { + return freeze(timestamp); + } + + public LogEvent freeze(Instant timestamp) { StringBuilder sb = new StringBuilder(message.length()); formattedMessage(sb); return new DefaultLogEvent(timestamp, threadName, threadId, level, loggerName, sb.toString(), @@ -344,11 +364,16 @@ public int argCount() { } public LogEvent freeze() { + return freeze(timestamp); + } + + public LogEvent freeze(Instant timestamp) { StringBuilder sb = new StringBuilder(message.length()); formattedMessage(sb); return new DefaultLogEvent(timestamp, threadName, threadId, level, loggerName, sb.toString(), keyValues.freeze(), null); } + } record DefaultLogEvent(Instant timestamp, String threadName, long threadId, System.Logger.Level level, @@ -379,4 +404,13 @@ public LogEvent freeze() { return this; } + public LogEvent freeze(Instant timestamp) { + if (keyValues instanceof MutableKeyValues mkvs) { + return new DefaultLogEvent(timestamp, threadName, threadId, level, loggerName, formattedMessage, mkvs, + throwable); + } + return new DefaultLogEvent(timestamp, threadName, threadId, level, loggerName, formattedMessage, keyValues, + throwable); + } + } diff --git a/core/src/main/java/io/jstach/rainbowgum/LogProperties.java b/core/src/main/java/io/jstach/rainbowgum/LogProperties.java index 14f1c192..c89a365e 100644 --- a/core/src/main/java/io/jstach/rainbowgum/LogProperties.java +++ b/core/src/main/java/io/jstach/rainbowgum/LogProperties.java @@ -1,5 +1,8 @@ package io.jstach.rainbowgum; +import java.io.IOException; +import java.io.StringReader; +import java.io.UncheckedIOException; import java.lang.System.Logger.Level; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -13,6 +16,7 @@ import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; +import java.util.Properties; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; @@ -128,11 +132,43 @@ public interface LogProperties { /** * Analogous to {@link System#getProperty(String)}. - * @param key property key. + * @param key property name. * @return property value. */ public @Nullable String valueOrNull(String key); + /** + * Gets a list or null if the key is missing and by default uses + * {@link #parseList(String)}. + * @param key property name. + * @return list or null. + * @apiNote the reason empty list is not returned for missing key is that it creates + * ambiguity so null is returned when key is missing. + */ + default @Nullable List listOrNull(String key) { + String s = valueOrNull(key); + if (s == null) { + return null; + } + return parseList(s); + } + + /** + * Gets a map or null if the key is missing and by default uses + * {@link #parseMap(String)}. + * @param key property name. + * @return map or null. + * @apiNote the reason empty map is not returned for missing key is that it creates + * ambiguity so null is returned when key is missing. + */ + default Map mapOrNull(String key) { + String s = valueOrNull(key); + if (s == null) { + return null; + } + return parseMap(s); + } + /** * Searches up a path using this properties to check for values. * @param root prefix. @@ -162,6 +198,121 @@ default int order() { return 0; } + /** + * Builder for properties. + */ + public final static class Builder { + + private String description = "custom"; + + private int order = 0; + + private Function function; + + private Builder() { + } + + /** + * Description for properties. + * @param description not null. + * @return this. + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * When log properties are coalesced this method is used to resolve order. A + * higher number gives higher precedence. + * @param order order. + * @return this. + */ + public Builder order(int order) { + this.order = order; + return this; + } + + /** + * Parses a string as {@link Properties}. + * @param properties properties as a string. + * @return this. + */ + public Builder fromProperties(String properties) { + function = Format.PROPERTIES.parse(properties); + return this; + } + + /** + * Parses a string as a URI query string. + * @param query uri percent encoded uri with separator as "&" and + * key value separator of "=". + * @return this. + */ + public Builder fromURIQuery(String query) { + function = Format.URI_QUERY.parse(query); + return this; + } + + /** + * Builds LogProperties based on builder config. + * @return this. + */ + public LogProperties build() { + if (function == null) { + throw new IllegalStateException("function is was not set"); + } + return new DefaultLogProperties(function, description, order); + } + + private record DefaultLogProperties(Function func, String description, + int order) implements LogProperties { + + @Override + public @Nullable String valueOrNull(String key) { + return func.apply(key); + } + + } + + enum Format { + + PROPERTIES() { + @Override + Function parse(String content) { + var m = new LinkedHashMap(); + StringReader reader = new StringReader(content); + try { + PropertiesParser.readProperties(reader, m::put); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + return m::get; + } + }, + URI_QUERY() { + @Override + Function parse(String content) { + var m = parseMap(content); + return m::get; + } + }; + + abstract Function parse(String content); + + } + + } + + /** + * LogProperties builder. + * @return builder. + */ + public static Builder builder() { + return new Builder(); + } + /** * Creates log properties from many log properties. * @param logProperties list of properties. @@ -395,12 +546,69 @@ private static void sneakyThrow(final Throwable x) throws } - private static RuntimeException throwPropertyError(String key, Exception e) { - throw new RuntimeException("Error for property. key: " + key, e); + /** + * Parent interface for property exceptions. + */ + sealed interface PropertyProblem { + + } + + /** + * Thrown if an error happens while converting a property. + */ + final static class PropertyConvertException extends RuntimeException implements PropertyProblem { + + private static final long serialVersionUID = -6260241455268426342L; + + /** + * Key. + */ + private final String key; + + /** + * Creates convert exception. + * @param key property key. + * @param message error message + * @param cause maybe null. + */ + PropertyConvertException(String key, String message, @Nullable Throwable cause) { + super(message, cause); + this.key = key; + } + + /** + * Property key. + * @return key. + */ + public String key() { + return this.key; + } + + } + + /** + * Throw if property is missing. + */ + final static class PropertyMissingException extends NoSuchElementException implements PropertyProblem { + + private static final long serialVersionUID = 4203076848052565692L; + + /** + * Creates a missing propety exception. + * @param s error message. + */ + PropertyMissingException(String s) { + super(s); + } + + } + + private static PropertyConvertException throwPropertyError(String key, Exception e) { + throw new PropertyConvertException(key, "Error for property. key: " + key, e); } private static RuntimeException throwMissingError(List keys) { - throw new NoSuchElementException("Property missing. key: " + keys); + throw new PropertyMissingException("Property missing. key: " + keys); } /** @@ -771,6 +979,16 @@ public Property map(PropertyFunction(propertyGetter.map(mapper), keys); } + /** + * Converts the value into a String that can be parsed by the builtin properties + * parsing of types. Supported types: String, Boolean, Integer, URI, Map, List. + * @param value to be converted to string. + * @return property representation of value. + */ + public String propertyString(T value) { + return propertyGetter.propertyString(value); + } + /** * Set a property if its not null. * @param value value to set. @@ -792,6 +1010,35 @@ public static RootPropertyGetter builder() { } } + // enum PropertyType { + // STRING, + // INTEGER, + // BOOLEAN, + // URI, + // MAP, + // LIST, + // UNKNOWN; + // + // public static PropertyType of(Object value) { + // return switch(value) { + // case String s -> STRING; + // case Integer i -> INTEGER; + // case Boolean b -> BOOLEAN; + // case Map m -> MAP; + // default -> UNKNOWN; + // }; + // } + // + // public static PropertyType of(Class c) { + // return switch (c) { + // case Class s -> STRING; + // default -> throw new IllegalArgumentException("Unexpected value: " + c); + // + // }; + // } + // + // } + /** * Extracts and converts from {@link LogProperties}. * @@ -808,6 +1055,14 @@ sealed interface PropertyGetter { @Nullable T valueOrNull(LogProperties props, String key); + /** + * Converts the value into a String that can be parsed by the builtin properties + * parsing of types. Supported types: String, Boolean, Integer, URI, Map, List. + * @param value to be converted to string. + * @return property representation of value. + */ + String propertyString(T value); + /** * Value or null. * @param props log properties. @@ -1014,7 +1269,12 @@ record RootPropertyGetter(String prefix, boolean search) implements PropertyGett if (search) { return props.search(prefix, key); } - return props.valueOrNull(LogProperties.concatKey(prefix, key)); + return props.valueOrNull(fullyQualifiedKey(key)); + } + + @Override + public String propertyString(String value) { + return value; } RuntimeException throwError(LogProperties props, String key, Exception e) { @@ -1058,7 +1318,7 @@ public String fullyQualifiedKey(String key) { * @return getter that will convert to integers. */ public PropertyGetter toInt() { - return this.map(Integer::parseInt); + return new FuncGetter<>(this, Integer::parseInt, String::valueOf); } /** @@ -1066,7 +1326,7 @@ public PropertyGetter toInt() { * @return getter that will convert to boolean. */ public PropertyGetter toBoolean() { - return this.map(Boolean::parseBoolean); + return new FuncGetter<>(this, Boolean::parseBoolean, String::valueOf); } /** @@ -1076,7 +1336,7 @@ public PropertyGetter toBoolean() { * @return getter that will convert to enum type. */ public > PropertyGetter toEnum(Class enumClass) { - return this.map(s -> Enum.valueOf(enumClass, s)); + return new FuncGetter<>(this, s -> Enum.valueOf(enumClass, s), String::valueOf); } /** @@ -1084,7 +1344,7 @@ public > PropertyGetter toEnum(Class enumClass) { * @return getter that will parse URIs. */ public PropertyGetter toURI() { - return this.map(URI::new); + return new FuncGetter<>(this, URI::new, String::valueOf); } } @@ -1102,6 +1362,18 @@ public sealed interface ChildPropertyGetter extends PropertyGetter { */ PropertyGetter parent(); + @Override + default String propertyString(T value) { + return switch (value) { + case String s -> s; + case Boolean b -> String.valueOf(b); + case Integer i -> String.valueOf(i); + case URI u -> String.valueOf(u); + case Map m -> MapGetter._propertyString(m); + default -> throw new RuntimeException("Unable to convert to property string. value = " + value); + }; + } + } /** @@ -1111,7 +1383,7 @@ public sealed interface ChildPropertyGetter extends PropertyGetter { * @return new property getter. */ default PropertyGetter map(PropertyFunction mapper) { - return new MapExtractor(this, mapper); + return new FuncGetter(this, mapper, null); } /** @@ -1166,8 +1438,54 @@ public T valueOrNull(LogProperties props, String key) { } -record MapExtractor(PropertyGetter parent, - PropertyFunction mapper) implements ChildPropertyGetter { +record BooleanGetter(RootPropertyGetter parent) implements ChildPropertyGetter { + + @Override + public @Nullable Boolean valueOrNull(LogProperties props, String key) { + var v = parent.valueOrNull(props, key); + return Boolean.parseBoolean(v); + } + +} + +record MapGetter(RootPropertyGetter parent) implements ChildPropertyGetter> { + + @Override + public @Nullable Map valueOrNull(LogProperties props, String key) { + return props.mapOrNull(parent.fullyQualifiedKey(key)); + } + + @Override + public String propertyString(Map value) { + return _propertyString(value); + } + + static String _propertyString(Map value) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (var e : value.entrySet()) { + if (first) { + first = true; + } + else { + sb.append("&"); + } + PercentCodec.encode(sb, String.valueOf(e.getKey()), StandardCharsets.UTF_8); + Object v = e.getValue(); + if (v != null) { + sb.append("="); + PercentCodec.encode(sb, String.valueOf(v), StandardCharsets.UTF_8); + } + } + return sb.toString(); + } + +} + +record FuncGetter(PropertyGetter parent, PropertyFunction mapper, + @Nullable PropertyFunction stringFunc) + implements + ChildPropertyGetter { @Override public @Nullable R valueOrNull(LogProperties props, String key) { @@ -1183,6 +1501,15 @@ record MapExtractor(PropertyGetter parent, return null; } + @Override + public String propertyString(R value) { + var f = stringFunc; + if (f != null) { + return stringFunc.apply(value); + } + return ChildPropertyGetter.super.propertyString(value); + } + } record CompositeLogProperties(LogProperties[] properties) implements LogProperties { diff --git a/core/src/main/java/io/jstach/rainbowgum/PropertiesParser.java b/core/src/main/java/io/jstach/rainbowgum/PropertiesParser.java new file mode 100644 index 00000000..6f38e9f5 --- /dev/null +++ b/core/src/main/java/io/jstach/rainbowgum/PropertiesParser.java @@ -0,0 +1,186 @@ +package io.jstach.rainbowgum; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.io.Reader; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Writes and parses properties. + */ +public final class PropertiesParser { + + private PropertiesParser() { + } + + /** + * Writes properties. + * @param map properties map. + * @return properties as a string. + */ + public static String writeProperties(Map map) { + StringBuilder sb = new StringBuilder(); + try { + writeProperties(map, sb); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + return sb.toString(); + + } + + /** + * Read properties. + * @param input from. + * @return properties as a map. + */ + public static Map readProperties(String input) { + Map m = new LinkedHashMap<>(); + StringReader sr = new StringReader(input); + try { + readProperties(sr, m::put); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + return m; + } + + /** + * Read properties. + * @param reader from + * @param consumer to + * @throws IOException on read failue + */ + static void readProperties(Reader reader, BiConsumer consumer) throws IOException { + Properties bp = prepareProperties(consumer); + bp.load(reader); + + } + + @SuppressWarnings({ "serial" }) + static void writeProperties(Map map, Appendable sb) throws IOException { + StringWriter sw = new StringWriter(); + new Properties() { + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public java.util.Enumeration keys() { + return Collections.enumeration(map.keySet()); + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public java.util.Set entrySet() { + return map.entrySet(); + } + + @Override + public Object get(Object key) { + return map.get(key); + } + }.store(sw, null); + LineNumberReader lr = new LineNumberReader(new StringReader(sw.toString())); + + String line; + while ((line = lr.readLine()) != null) { + if (!line.startsWith("#")) { + sb.append(line).append(System.lineSeparator()); + } + } + } + + enum PropertiesFormat { + + PROPERTIES, XML + + } + + static Map readProperties(InputStream s) throws IOException { + return readProperties(s, PropertiesFormat.PROPERTIES); + } + + private static Map readProperties(InputStream s, PropertiesFormat format) throws IOException { + final Map ordered = new LinkedHashMap<>(); + + try { + switch (format) { + case PROPERTIES: { + LineNumberReader lr = new LineNumberReader(new InputStreamReader(s, StandardCharsets.UTF_8)); + Properties bp = prepareProperties((k, v) -> { + if (ordered.containsKey(k)) { + throw new DuplicateKeyException(k, lr.getLineNumber()); + } + ordered.put(k, v); + }); + bp.load(lr); + break; + } + case XML: { + AtomicInteger i = new AtomicInteger(); + Properties bp = prepareProperties((k, v) -> { + i.incrementAndGet(); + if (ordered.containsKey(k)) { + throw new DuplicateKeyException(k, i.get()); + } + ordered.put(k, v); + }); + bp.loadFromXML(s); + break; + } + } + return ordered; + } + catch (DuplicateKeyException e) { + throw new IOException("Duplicate key detected. key: '" + e.key + "' line: " + e.lineNumber, e); + } + } + + private static Properties prepareProperties(BiConsumer consumer) throws IOException { + + // Hack to use properties class to load but our map for preserved order + @SuppressWarnings("serial") + @NonNullByDefault({}) + Properties bp = new Properties() { + @Override + public Object put(@Nullable Object key, @Nullable Object value) { + if (key != null && value != null) { + consumer.accept((String) key, (String) value); + } + return null; + } + }; + return bp; + } + + static class DuplicateKeyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + final String key; + + final int lineNumber; + + public DuplicateKeyException(String key, int lineNumber) { + super("Duplicate key detected. key: '" + key + "' line: " + lineNumber); + this.key = key; + this.lineNumber = lineNumber; + } + + } + +} diff --git a/rainbowgum-config-apt/src/main/resources/io/jstach/rainbowgum/apt/ConfigBuilder.java b/rainbowgum-config-apt/src/main/resources/io/jstach/rainbowgum/apt/ConfigBuilder.java index 9d5921c4..c5df1578 100644 --- a/rainbowgum-config-apt/src/main/resources/io/jstach/rainbowgum/apt/ConfigBuilder.java +++ b/rainbowgum-config-apt/src/main/resources/io/jstach/rainbowgum/apt/ConfigBuilder.java @@ -144,7 +144,7 @@ public void toProperties(java.util.function.BiConsumer consumer) $$#properties$$ $$#normal$$ if (this.$$name$$ != null) { - consumer.accept($$propertyVar$$.key(), String.valueOf(this.$$name$$)); + consumer.accept($$propertyVar$$.key(), $$propertyVar$$.propertyString(this.$$name$$)); } $$/normal$$ $$/properties$$ diff --git a/rainbowgum-json/src/main/java/io/jstach/rainbowgum/json/encoder/GelfEncoder.java b/rainbowgum-json/src/main/java/io/jstach/rainbowgum/json/encoder/GelfEncoder.java index 900a7601..e2718ce3 100644 --- a/rainbowgum-json/src/main/java/io/jstach/rainbowgum/json/encoder/GelfEncoder.java +++ b/rainbowgum-json/src/main/java/io/jstach/rainbowgum/json/encoder/GelfEncoder.java @@ -42,12 +42,17 @@ public class GelfEncoder extends LogEncoder.AbstractEncoder { this.prettyprint = prettyprint; } + /** + * Creates Gelf Encoder. + * @param name property name prefix. + * @param host host field in gelf. + * @param headers additional headers that will be prefix with "_". + * @param prettyPrint true will pretty prin the json, default is false. + * @return encoder. + */ @LogConfigurable(prefix = LogProperties.ENCODER_PREFIX) - public static GelfEncoder of(@LogConfigurable.PrefixParameter String name, String host, // - + static GelfEncoder of(@LogConfigurable.PrefixParameter String name, String host, // @LogConfigurable.ConvertParameter("convertHeaders") @Nullable Map headers, - - // String headers, @Nullable Boolean prettyPrint) { prettyPrint = prettyPrint == null ? false : prettyPrint; host = Objects.requireNonNull(host); diff --git a/rainbowgum-json/src/main/java/module-info.java b/rainbowgum-json/src/main/java/module-info.java index 6d8547ed..4e842a0b 100644 --- a/rainbowgum-json/src/main/java/module-info.java +++ b/rainbowgum-json/src/main/java/module-info.java @@ -1,10 +1,11 @@ /** - * Provides JSON formatters and encoders. + * Provides JSON encoders. * This module does not require external JSON libraries but instead * provides a zero dependency JSON writer. */ module io.jstach.rainbowgum.json { exports io.jstach.rainbowgum.json; + exports io.jstach.rainbowgum.json.encoder; requires transitive io.jstach.rainbowgum; requires static org.eclipse.jdt.annotation; diff --git a/rainbowgum-json/src/test/java/io/jstach/rainbowgum/json/encoder/GelfEncoderTest.java b/rainbowgum-json/src/test/java/io/jstach/rainbowgum/json/encoder/GelfEncoderTest.java new file mode 100644 index 00000000..0795932f --- /dev/null +++ b/rainbowgum-json/src/test/java/io/jstach/rainbowgum/json/encoder/GelfEncoderTest.java @@ -0,0 +1,66 @@ +package io.jstach.rainbowgum.json.encoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.jstach.rainbowgum.LogEvent; +import io.jstach.rainbowgum.PropertiesParser; +import io.jstach.rainbowgum.output.ListLogOutput; + +class GelfEncoderTest { + + @Test + void testBuilder() { + GelfEncoderBuilder b = new GelfEncoderBuilder("gelf"); + b.headers(Map.of("header1", "1")); + b.host("localhost"); + b.prettyPrint(true); + Map props = new LinkedHashMap<>(); + b.toProperties(props::put); + String expected = """ + logging.encoder.gelf.host=localhost + logging.encoder.gelf.headers=header1\\=1 + logging.encoder.gelf.prettyPrint=true + """; + String actual = PropertiesParser.writeProperties(props); + assertEquals(expected, actual); + + b = new GelfEncoderBuilder("gelf"); + String propString = actual; + props = PropertiesParser.readProperties(propString); + b.fromProperties(props::get); + + GelfEncoder encoder = b.build(); + + Instant instant = Instant.ofEpochMilli(1); + LogEvent e = LogEvent.of(System.Logger.Level.INFO, "gelf", "hello", null).freeze(instant); + var buffer = encoder.buffer(); + encoder.encode(e, buffer); + ListLogOutput out = new ListLogOutput(); + buffer.drain(out, e); + String message = out.events().get(0).getValue(); + expected = """ + { + "host":"localhost", + "short_message":"hello", + "timestamp":0.001, + "level":6, + "_time":"1970-01-01T00:00:00.001Z", + "_level":"INFO", + "_logger":"gelf", + "_thread_name":"main", + "_thread_id":"1", + _"header1":"1", + "version":"1.1" + } + """; + + assertEquals(expected, message); + } + +}