Add filter, name processor and value processor support to `JsonWriter`

Update `JsonWriter` to support filtering and processing of names/values.
This update will allow us to offer better customization options with
structured logging.

See gh-42486
This commit is contained in:
Phillip Webb 2024-10-09 19:12:54 -07:00
parent 763266f20d
commit 27c59b8cb5
4 changed files with 893 additions and 14 deletions

View File

@ -24,9 +24,15 @@ import java.util.Deque;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.springframework.boot.json.JsonWriter.MemberPath;
import org.springframework.boot.json.JsonWriter.NameProcessor;
import org.springframework.boot.json.JsonWriter.ValueProcessor;
import org.springframework.boot.util.LambdaSafe;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.function.ThrowingConsumer;
/**
@ -40,6 +46,10 @@ class JsonValueWriter {
private final Appendable out;
private MemberPath path = MemberPath.ROOT;
private final Deque<JsonWriterFiltersAndProcessors> filtersAndProcessors = new ArrayDeque<>();
private final Deque<ActiveSeries> activeSeries = new ArrayDeque<>();
/**
@ -50,6 +60,14 @@ class JsonValueWriter {
this.out = out;
}
void pushProcessors(JsonWriterFiltersAndProcessors jsonProcessors) {
this.filtersAndProcessors.addLast(jsonProcessors);
}
void popProcessors() {
this.filtersAndProcessors.removeLast();
}
/**
* Write a name value pair, or just a value if {@code name} is {@code null}.
* @param <N> the name type in the pair
@ -82,6 +100,7 @@ class JsonValueWriter {
* @param value the value to write
*/
<V> void write(V value) {
value = processValue(value);
if (value == null) {
append("null");
}
@ -119,7 +138,7 @@ class JsonValueWriter {
*/
void start(Series series) {
if (series != null) {
this.activeSeries.push(new ActiveSeries());
this.activeSeries.push(new ActiveSeries(series));
append(series.openChar);
}
}
@ -164,8 +183,10 @@ class JsonValueWriter {
<E> void writeElement(E element) {
ActiveSeries activeSeries = this.activeSeries.peek();
Assert.notNull(activeSeries, "No series has been started");
activeSeries.appendCommaIfRequired();
this.path = activeSeries.updatePath(this.path);
activeSeries.incrementIndexAndAddCommaIfRequired();
write(element);
this.path = activeSeries.restorePath(this.path);
}
/**
@ -196,12 +217,17 @@ class JsonValueWriter {
}
private <N, V> void writePair(N name, V value) {
ActiveSeries activeSeries = this.activeSeries.peek();
Assert.notNull(activeSeries, "No series has been started");
activeSeries.appendCommaIfRequired();
writeString(name);
append(":");
write(value);
this.path = this.path.child(name.toString());
if (!isFilteredPath()) {
String processedName = processName(name.toString());
ActiveSeries activeSeries = this.activeSeries.peek();
Assert.notNull(activeSeries, "No series has been started");
activeSeries.incrementIndexAndAddCommaIfRequired();
writeString(processedName);
append(":");
write(value);
}
this.path = this.path.parent();
}
private void writeString(Object value) {
@ -256,6 +282,48 @@ class JsonValueWriter {
}
}
private boolean isFilteredPath() {
for (JsonWriterFiltersAndProcessors filtersAndProcessors : this.filtersAndProcessors) {
for (Predicate<MemberPath> pathFilter : filtersAndProcessors.pathFilters()) {
if (pathFilter.test(this.path)) {
return true;
}
}
}
return false;
}
private String processName(String name) {
for (JsonWriterFiltersAndProcessors filtersAndProcessors : this.filtersAndProcessors) {
for (NameProcessor nameProcessor : filtersAndProcessors.nameProcessors()) {
name = processName(name, nameProcessor);
}
}
return name;
}
private String processName(String name, NameProcessor nameProcessor) {
name = nameProcessor.processName(this.path, name);
Assert.state(StringUtils.hasLength(name), "NameProcessor " + nameProcessor + " returned an empty result");
return name;
}
private <V> V processValue(V value) {
for (JsonWriterFiltersAndProcessors filtersAndProcessors : this.filtersAndProcessors) {
for (ValueProcessor<?> valueProcessor : filtersAndProcessors.valueProcessors()) {
value = processValue(value, valueProcessor);
}
}
return value;
}
@SuppressWarnings({ "unchecked", "unchecked" })
private <V> V processValue(V value, ValueProcessor<?> valueProcessor) {
return (V) LambdaSafe.callback(ValueProcessor.class, valueProcessor, this.path, value)
.invokeAnd((call) -> call.processValue(this.path, value))
.get(value);
}
/**
* A series of items that can be written to the JSON output.
*/
@ -287,16 +355,27 @@ class JsonValueWriter {
*/
private final class ActiveSeries {
private boolean commaRequired;
private final Series series;
private ActiveSeries() {
private int index;
private ActiveSeries(Series series) {
this.series = series;
}
void appendCommaIfRequired() {
if (this.commaRequired) {
MemberPath updatePath(MemberPath path) {
return (this.series != Series.ARRAY) ? path : path.child(this.index);
}
MemberPath restorePath(MemberPath path) {
return (this.series != Series.ARRAY) ? path : path.parent();
}
void incrementIndexAndAddCommaIfRequired() {
if (this.index > 0) {
append(',');
}
this.commaRequired = true;
this.index++;
}
}

View File

@ -27,6 +27,7 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import org.springframework.boot.json.JsonValueWriter.Series;
import org.springframework.boot.json.JsonWriter.Member.Extractor;
@ -147,7 +148,8 @@ public interface JsonWriter<T> {
* @see Members
*/
static <T> JsonWriter<T> of(Consumer<Members<T>> members) {
Members<T> initializedMembers = new Members<>(members, false); // Don't inline
// Don't inline 'new Members' (must be outside of lambda)
Members<T> initializedMembers = new Members<>(members, false);
return (instance, out) -> initializedMembers.write(instance, new JsonValueWriter(out));
}
@ -175,6 +177,8 @@ public interface JsonWriter<T> {
private final Series series;
private final JsonWriterFiltersAndProcessors jsonProcessors = new JsonWriterFiltersAndProcessors();
Members(Consumer<Members<T>> members, boolean contributesToExistingSeries) {
Assert.notNull(members, "'members' must not be null");
members.accept(this);
@ -290,6 +294,33 @@ public interface JsonWriter<T> {
return addMember(null, extractor);
}
/**
* Add a filter that will be used to restrict the members written to the JSON.
* @param predicate the predicate used to filter members
*/
public void applyingPathFilter(Predicate<MemberPath> predicate) {
Assert.notNull(predicate, "'predicate' must not be null");
this.jsonProcessors.pathFilters().add(predicate);
}
/**
* Add the a {@link NameProcessor} to be applied when the JSON is written.
* @param nameProcessor the name processor to add
*/
public void applyingNameProcessor(NameProcessor nameProcessor) {
Assert.notNull(nameProcessor, "'nameProcessor' must not be null");
this.jsonProcessors.nameProcessors().add(nameProcessor);
}
/**
* Add the a {@link ValueProcessor} to be applied when the JSON is written.
* @param valueProcessor the value processor to add
*/
public void applyingValueProcessor(ValueProcessor<?> valueProcessor) {
Assert.notNull(valueProcessor, "'valueProcessor' must not be null");
this.jsonProcessors.valueProcessors().add(valueProcessor);
}
private <V> Member<V> addMember(String name, Function<T, V> extractor) {
Member<V> member = new Member<>(this.members.size(), name, Extractor.of(extractor));
this.members.add(member);
@ -302,11 +333,13 @@ public interface JsonWriter<T> {
* @param valueWriter the JSON value writer to use
*/
void write(T instance, JsonValueWriter valueWriter) {
valueWriter.pushProcessors(this.jsonProcessors);
valueWriter.start(this.series);
for (Member<?> member : this.members) {
member.write(instance, valueWriter);
}
valueWriter.end(this.series);
valueWriter.popProcessors();
}
/**
@ -718,6 +751,117 @@ public interface JsonWriter<T> {
}
/**
* A path used to identify a specific JSON member. Paths can be represented as strings
* in form {@code "my.json[1].item"} where elements are separated by {@code '.' } or
* {@code [<index>]}. Reserved characters are escaped using {@code '\'}.
*
* @param parent the parent of this path
* @param name the name of the member or {@code null} if the member is indexed. Path
* names are provided as they were defined when the member was added and do not
* include any {@link NameProcessor name processing}.
* @param index the index of the member or {@link MemberPath#UNINDEXED}
*/
record MemberPath(MemberPath parent, String name, int index) {
private static final String[] ESCAPED = { "\\", ".", "[", "]" };
public MemberPath {
Assert.isTrue((name != null && index < 0) || (name == null && index >= 0),
"'name' and 'index' cannot be mixed");
}
/**
* Indicates that the member has no index.
*/
public static final int UNINDEXED = -1;
/**
* The root of all member paths.
*/
static final MemberPath ROOT = new MemberPath(null, "", UNINDEXED);
/**
* Create a new child from this path with the specified index.
* @param index the index of the child
* @return a new {@link MemberPath} instance
*/
public MemberPath child(int index) {
return new MemberPath(this, null, index);
}
/**
* Create a new child from this path with the specified name.
* @param name the name of the child
* @return a new {@link MemberPath} instance
*/
public MemberPath child(String name) {
return (!StringUtils.hasLength(name)) ? this : new MemberPath(this, name, UNINDEXED);
}
@Override
public final String toString() {
return toString(true);
}
/**
* Return a string representation of the path without any escaping.
* @return the unescaped string representation
*/
public final String toUnescapedString() {
return toString(false);
}
private String toString(boolean escape) {
StringBuilder string = new StringBuilder((this.parent != null) ? this.parent.toString(escape) : "");
if (this.index >= 0) {
string.append("[").append(this.index).append("]");
}
else {
string.append((!string.isEmpty()) ? "." : "").append((!escape) ? this.name : escape(this.name));
}
return string.toString();
}
private String escape(String name) {
for (String escape : ESCAPED) {
name = name.replace(escape, "\\" + escape);
}
return name;
}
/**
* Create a new {@link MemberPath} instance from the given string.
* @param value the path value
* @return a new {@link MemberPath} instance
*/
public static MemberPath of(String value) {
MemberPath path = MemberPath.ROOT;
StringBuilder buffer = new StringBuilder();
boolean escape = false;
for (char ch : value.toCharArray()) {
if (!escape && ch == '\\') {
escape = true;
}
else if (!escape && (ch == '.' || ch == '[')) {
path = path.child(buffer.toString());
buffer.setLength(0);
}
else if (!escape && ch == ']') {
path = path.child(Integer.parseUnsignedInt(buffer.toString()));
buffer.setLength(0);
}
else {
buffer.append(ch);
escape = false;
}
}
path = path.child(buffer.toString());
return path;
}
}
/**
* Interface that can be used to extract name/value pairs from an element.
*
@ -771,4 +915,130 @@ public interface JsonWriter<T> {
}
/**
* Callback interface that can be {@link Members#applyingNameProcessor(NameProcessor)
* applied} to {@link Members} to change names or filter members.
*/
@FunctionalInterface
interface NameProcessor {
/**
* Return a new name for the JSON member or {@code null} if the member should be
* filtered entirely.
* @param path the path of the member
* @param existingName the existing and possibly already processed name.
* @return the new name
*/
String processName(MemberPath path, String existingName);
/**
* Factory method to create a new {@link NameProcessor} for the given operation.
* @param operation the operation to apply
* @return a new {@link NameProcessor} instance
*/
static NameProcessor of(UnaryOperator<String> operation) {
Assert.notNull(operation, "'operation' must not be null");
return (path, existingName) -> operation.apply(existingName);
}
}
/**
* Callback interface that can be
* {@link Members#applyingValueProcessor(ValueProcessor) applied} to {@link Members}
* to process values before they are written. Typically used to filter values, for
* example to reduce superfluous information or sanitize sensitive data.
*
* @param <T> the value type
*/
@FunctionalInterface
interface ValueProcessor<T> {
/**
* Process the value at the given path.
* @param path the path of the member containing the value
* @param value the value being written (may be {@code null})
* @return the processed value
*/
T processValue(MemberPath path, T value);
/**
* Return a new processor from this one that only applied to members with the
* given path (ignoring escape characters).
* @param path the patch to match
* @return a new {@link ValueProcessor} that only applies when the path matches
*/
default ValueProcessor<T> whenHasUnescapedPath(String path) {
return whenHasPath((candidate) -> candidate.toString(false).equals(path));
}
/**
* Return a new processor from this one that only applied to members with the
* given path.
* @param path the patch to match
* @return a new {@link ValueProcessor} that only applies when the path matches
*/
default ValueProcessor<T> whenHasPath(String path) {
return whenHasPath(MemberPath.of(path)::equals);
}
/**
* Return a new processor from this one that only applied to members that match
* the given path predicate.
* @param predicate the predicate that must match
* @return a new {@link ValueProcessor} that only applies when the predicate
* matches
*/
default ValueProcessor<T> whenHasPath(Predicate<MemberPath> predicate) {
return (path, value) -> (predicate.test(path)) ? processValue(path, value) : value;
}
/**
* Return a new processor from this one that only applies to member with values of
* the given type.
* @param type the type that must match
* @return a new {@link ValueProcessor} that only applies when value is the given
* type.
*/
default ValueProcessor<T> whenInstanceOf(Class<?> type) {
return when(type::isInstance);
}
/**
* Return a new processor from this one that only applies to member with values
* that match the given predicate.
* @param predicate the predicate that must match
* @return a new {@link ValueProcessor} that only applies when the predicate
* matches
*/
default ValueProcessor<T> when(Predicate<T> predicate) {
return (name, value) -> (predicate.test(value)) ? processValue(name, value) : value;
}
/**
* Factory method to crate a new {@link ValueProcessor} that applies the given
* action.
* @param <T> the value type
* @param type the value type
* @param action the action to apply
* @return a new {@link ValueProcessor} instance
*/
static <T> ValueProcessor<T> of(Class<? extends T> type, UnaryOperator<T> action) {
return of(action).whenInstanceOf(type);
}
/**
* Factory method to crate a new {@link ValueProcessor} that applies the given
* action.
* @param <T> the value type
* @param action the action to apply
* @return a new {@link ValueProcessor} instance
*/
static <T> ValueProcessor<T> of(UnaryOperator<T> action) {
Assert.notNull(action, "'action' must not be null");
return (name, value) -> action.apply(value);
}
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.json;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import org.springframework.boot.json.JsonWriter.MemberPath;
import org.springframework.boot.json.JsonWriter.NameProcessor;
import org.springframework.boot.json.JsonWriter.ValueProcessor;
/**
* Internal record used to hold {@link NameProcessor} and {@link ValueProcessor}
* instances.
*
* @author Phillip Webb
* @param pathFilters the path filters
* @param nameProcessors the name processors
* @param valueProcessors the value processors
*/
record JsonWriterFiltersAndProcessors(List<Predicate<MemberPath>> pathFilters, List<NameProcessor> nameProcessors,
List<ValueProcessor<?>> valueProcessors) {
JsonWriterFiltersAndProcessors() {
this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
}

View File

@ -16,6 +16,7 @@
package org.springframework.boot.json;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@ -26,9 +27,16 @@ import java.util.function.Function;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.json.JsonWriter.Member;
import org.springframework.boot.json.JsonWriter.MemberPath;
import org.springframework.boot.json.JsonWriter.Members;
import org.springframework.boot.json.JsonWriter.NameProcessor;
import org.springframework.boot.json.JsonWriter.PairExtractor;
import org.springframework.boot.json.JsonWriter.ValueProcessor;
import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
@ -454,6 +462,485 @@ class JsonWriterTests {
}
/**
* Tests for {@link MemberPath}.
*/
@Nested
class MemberPathTests {
@Test
void createWhenIndexAndNamedThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new MemberPath(null, "boot", 0))
.withMessage("'name' and 'index' cannot be mixed");
assertThatIllegalArgumentException().isThrownBy(() -> new MemberPath(null, null, -1))
.withMessage("'name' and 'index' cannot be mixed");
}
@Test
void toStringReturnsUsefulString() {
assertThat(MemberPath.ROOT).hasToString("");
MemberPath spring = new MemberPath(MemberPath.ROOT, "spring", MemberPath.UNINDEXED);
MemberPath springDotBoot = new MemberPath(spring, "boot", MemberPath.UNINDEXED);
MemberPath springZero = new MemberPath(spring, null, 0);
MemberPath springZeroDotBoot = new MemberPath(springZero, "boot", MemberPath.UNINDEXED);
assertThat(spring).hasToString("spring");
assertThat(springDotBoot).hasToString("spring.boot");
assertThat(springZero).hasToString("spring[0]");
assertThat(springZeroDotBoot).hasToString("spring[0].boot");
}
@Test
void childWithNameCreatesChild() {
assertThat(MemberPath.ROOT.child("spring").child("boot")).hasToString("spring.boot");
}
@Test
void childWithNameWhenNameSpecialChars() {
assertThat(MemberPath.ROOT.child("spring.io").child("boot")).hasToString("spring\\.io.boot");
assertThat(MemberPath.ROOT.child("spring[io]").child("boot")).hasToString("spring\\[io\\].boot");
assertThat(MemberPath.ROOT.child("spring.[io]").child("boot")).hasToString("spring\\.\\[io\\].boot");
assertThat(MemberPath.ROOT.child("spring\\io").child("boot")).hasToString("spring\\\\io.boot");
assertThat(MemberPath.ROOT.child("spring.\\io").child("boot")).hasToString("spring\\.\\\\io.boot");
assertThat(MemberPath.ROOT.child("spring[\\io]").child("boot")).hasToString("spring\\[\\\\io\\].boot");
assertThat(MemberPath.ROOT.child("123").child("boot")).hasToString("123.boot");
assertThat(MemberPath.ROOT.child("1.2.3").child("boot")).hasToString("1\\.2\\.3.boot");
}
@Test
void childWithIndexCreatesChild() {
assertThat(MemberPath.ROOT.child("spring").child(0)).hasToString("spring[0]");
}
@Test
void ofParsesPaths() {
assertOfFromToString(MemberPath.ROOT.child("spring").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring").child(0));
assertOfFromToString(MemberPath.ROOT.child("spring.io").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring[io]").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring.[io]").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring\\io").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring.\\io").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("spring[\\io]").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("123").child("boot"));
assertOfFromToString(MemberPath.ROOT.child("1.2.3").child("boot"));
}
private void assertOfFromToString(MemberPath path) {
assertThat(MemberPath.of(path.toString())).isEqualTo(path);
}
}
/**
* Tests for {@link Members#applyingPathFilter(java.util.function.Predicate)}.
*/
@Nested
class PathFilterTests {
@Test
void filteringMember() {
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members.applyingPathFilter((path) -> path.name().equals("first"));
});
assertThat(writer.writeToString(new Person("spring", "boot", 10))).isEqualTo("""
{"last":"boot"}""");
}
@Test
void filteringInMap() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingPathFilter((path) -> path.name().equals("spring"));
});
assertThat(writer.writeToString(Map.of("spring", "boot", "test", "test"))).isEqualTo("""
{"test":"test"}""");
}
}
/**
* Tests for {@link NameProcessor}.
*/
@Nested
class NameProcessorTests {
@Test
void processNameWhenSimpleValue() {
JsonWriter<String> writer = JsonWriter.of((members) -> {
members.add();
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString("test")).isEqualTo("\"test\"");
}
@Test
void processNameWhenMember() {
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString(new Person("spring", "boot", 10))).isEqualTo("""
{"FIRST":"spring","LAST":"boot"}""");
}
@Test
void processNameWhenInMap() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"SPRING":"boot"}""");
}
@Test
void processNameWhenInNestedMap() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString(Map.of("test", Map.of("spring", "boot")))).isEqualTo("""
{"TEST":{"SPRING":"boot"}}""");
}
@Test
void processNameWhenInPairs() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add().usingPairs(Map::forEach);
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"SPRING":"boot"}""");
}
@Test
void processNameWhenHasNestedMembers() {
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.from(Couple::person1)
.usingMembers((personMembers) -> personMembers.add("one", Person::toString));
members.from(Couple::person2)
.usingMembers((personMembers) -> personMembers.add("two", Person::toString));
members.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
assertThat(writer.writeToString(couple)).isEqualTo("""
{"ONE":"Spring Boot (10)","TWO":"Spring Framework (20)"}""");
}
@Test
void processNameWhenHasNestedMembersWithAdditionalValueProcessor() {
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.from(Couple::person1)
.usingMembers((personMembers) -> personMembers.add("one", Person::toString));
members.from(Couple::person2).usingMembers((personMembers) -> {
personMembers.add("two", Person::toString);
personMembers.applyingNameProcessor(NameProcessor.of(String::toUpperCase));
});
members.applyingNameProcessor(NameProcessor.of((name) -> name + "!"));
});
assertThat(writer.writeToString(couple)).isEqualTo("""
{"one!":"Spring Boot (10)","TWO!":"Spring Framework (20)"}""");
}
@Test
void processNameWhenDeeplyNestedUsesCompoundPaths() {
List<String> paths = new ArrayList<>();
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.add("one", Couple::person1).usingMembers((personMembers) -> {
personMembers.add("first", Person::firstName);
personMembers.add("last", Person::lastName);
});
members.add("two", Couple::person2).usingMembers((personMembers) -> {
personMembers.add("first", Person::firstName);
personMembers.add("last", Person::lastName);
});
members.applyingNameProcessor((path, existingName) -> {
paths.add(path.toString());
return existingName;
});
});
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
writer.writeToString(couple);
assertThat(paths).containsExactly("one", "one.first", "one.last", "two", "two.first", "two.last");
}
@Test
void processNameWhenReturnsNullThrowsException() {
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members
.applyingNameProcessor((path, existingName) -> !"first".equals(existingName) ? existingName : null);
});
assertThatIllegalStateException().isThrownBy(() -> writer.writeToString(new Person("spring", "boot", 10)))
.withMessageContaining("NameProcessor")
.withMessageContaining("returned an empty result");
}
@Test
void processNameWhenReturnsEmptyStringThrowsException() {
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members
.applyingNameProcessor((path, existingName) -> !"first".equals(existingName) ? existingName : "");
});
assertThatIllegalStateException().isThrownBy(() -> writer.writeToString(new Person("spring", "boot", 10)))
.withMessageContaining("NameProcessor")
.withMessageContaining("returned an empty result");
}
}
/**
* Tests for {@link ValueProcessor}.
*/
@Nested
class ValueProcessorTests {
@Test
void of() {
ValueProcessor<String> processor = ValueProcessor.of(String::toUpperCase);
assertThat(processor.processValue(null, "test")).isEqualTo("TEST");
}
@Test
void ofWhenNull() {
assertThatIllegalArgumentException().isThrownBy(() -> ValueProcessor.of(null))
.withMessage("'action' must not be null");
}
@Test
void whenHasPathWithStringWhenPathMatches() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase).whenHasPath("foo");
assertThat(processor.processValue(MemberPath.ROOT.child("foo"), "test")).isEqualTo("TEST");
}
@Test
void whenHasPathWithStringWhenPathDoesNotMatch() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase).whenHasPath("foo");
assertThat(processor.processValue(MemberPath.ROOT.child("bar"), "test")).isEqualTo("test");
}
@Test
void whenHasPathWithPredicateWhenPathMatches() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase)
.whenHasPath((path) -> path.toString().startsWith("f"));
assertThat(processor.processValue(MemberPath.ROOT.child("foo"), "test")).isEqualTo("TEST");
}
@Test
void whenHasPathWithPredicateWhenPathDoesNotMatch() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase)
.whenHasPath((path) -> path.toString().startsWith("f"));
assertThat(processor.processValue(MemberPath.ROOT.child("bar"), "test")).isEqualTo("test");
}
@Test
void whenInstanceOfWhenInstanceMatches() {
ValueProcessor<Object> processor = ValueProcessor.of((value) -> value.toString().toUpperCase())
.whenInstanceOf(String.class);
assertThat(processor.processValue(null, "test")).hasToString("TEST");
}
@Test
void whenInstanceOfWhenInstanceDoesNotMatch() {
ValueProcessor<Object> processor = ValueProcessor.of((value) -> value.toString().toUpperCase())
.whenInstanceOf(String.class);
assertThat(processor.processValue(null, new StringBuilder("test"))).hasToString("test");
}
@Test
void whenWhenPredicateMatches() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase).when("test"::equals);
assertThat(processor.processValue(null, "test")).isEqualTo("TEST");
}
@Test
void whenWhenPredicateDoesNotMatch() {
ValueProcessor<String> processor = ValueProcessor.<String>of(String::toUpperCase).when("test"::equals);
assertThat(processor.processValue(null, "other")).isEqualTo("other");
}
@Test
void processValueWhenSimpleValue() {
JsonWriter<String> writer = simpleWriterWithUppercaseProcessor();
assertThat(writer.writeToString("test")).isEqualTo("\"TEST\"");
}
@Test
void processValueWhenMemberValue() {
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize));
});
assertThat(writer.writeToString(new Person("spring", "boot", 10))).isEqualTo("""
{"first":"Spring","last":"Boot"}""");
}
@Test
void processValueWhenInMap() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"spring":"Boot"}""");
}
@Test
void processValueWhenInNestedMap() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize));
});
assertThat(writer.writeToString(Map.of("test", Map.of("spring", "boot")))).isEqualTo("""
{"test":{"spring":"Boot"}}""");
}
@Test
void processValueWhenInPairs() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add().usingPairs(Map::forEach);
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"spring":"Boot"}""");
}
@Test
void processValueWhenCalledWithMultipleTypesIgnoresLambdaErrors() {
JsonWriter<Object> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize));
});
assertThat(writer.writeToString("spring")).isEqualTo("\"Spring\"");
assertThat(writer.writeToString(123)).isEqualTo("123");
assertThat(writer.writeToString(true)).isEqualTo("true");
}
@Test
void processValueWhenLimitedToPath() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(ValueProcessor.of(StringUtils::capitalize).whenHasPath("spring"));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"spring":"Boot"}""");
assertThat(writer.writeToString(Map.of("boot", "spring"))).isEqualTo("""
{"boot":"spring"}""");
}
@Test
void processValueWhen() {
JsonWriter<Map<?, ?>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(
ValueProcessor.of(StringUtils::capitalize).when((candidate) -> candidate.startsWith("b")));
});
assertThat(writer.writeToString(Map.of("spring", "boot"))).isEqualTo("""
{"spring":"Boot"}""");
assertThat(writer.writeToString(Map.of("boot", "spring"))).isEqualTo("""
{"boot":"spring"}""");
}
@Test
void processValueWhenHasNestedMembers() {
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.from(Couple::person1)
.usingMembers((personMembers) -> personMembers.add("one", Person::toString));
members.from(Couple::person2)
.usingMembers((personMembers) -> personMembers.add("two", Person::toString));
members.applyingValueProcessor(ValueProcessor.of(String.class, String::toUpperCase));
});
assertThat(writer.writeToString(couple)).isEqualTo("""
{"one":"SPRING BOOT (10)","two":"SPRING FRAMEWORK (20)"}""");
}
@Test
void processValueWhenHasNestedMembersWithAdditionalValueProcessor() {
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.from(Couple::person1)
.usingMembers((personMembers) -> personMembers.add("one", Person::toString));
members.from(Couple::person2).usingMembers((personMembers) -> {
personMembers.add("two", Person::toString);
personMembers.applyingValueProcessor(ValueProcessor.of(String.class, (item) -> item + "!"));
});
members.applyingValueProcessor(ValueProcessor.of(String.class, String::toUpperCase));
});
assertThat(writer.writeToString(couple)).isEqualTo("""
{"one":"SPRING BOOT (10)","two":"SPRING FRAMEWORK (20)!"}""");
}
@Test
void processValueWhenDeeplyNestedUsesCompoundPaths() {
List<String> paths = new ArrayList<>();
JsonWriter<Couple> writer = JsonWriter.of((members) -> {
members.add("one", Couple::person1).usingMembers((personMembers) -> {
personMembers.add("first", Person::firstName);
personMembers.add("last", Person::lastName);
});
members.add("two", Couple::person2).usingMembers((personMembers) -> {
personMembers.add("first", Person::firstName);
personMembers.add("last", Person::lastName);
});
members.applyingValueProcessor((path, value) -> {
paths.add(path.toString());
return value;
});
});
Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20));
writer.writeToString(couple);
assertThat(paths).containsExactly("one", "one.first", "one.last", "two", "two.first", "two.last");
}
@Test
void processValueWhenUsingListUsesIndexedPaths() {
List<String> paths = new ArrayList<>();
JsonWriter<List<String>> writer = JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor((path, value) -> {
paths.add(path.toString());
return value;
});
});
writer.writeToString(List.of("a", "b", "c"));
assertThat(paths).containsExactly("", "[0]", "[1]", "[2]");
}
@Test
void processValueUsesUnprocessedNameInPath() {
List<String> paths = new ArrayList<>();
JsonWriter<Person> writer = JsonWriter.of((members) -> {
members.add("first", Person::firstName);
members.add("last", Person::lastName);
members.applyingValueProcessor((path, value) -> {
paths.add(path.toString());
return value;
});
members.applyingNameProcessor((path, existingName) -> "the-" + existingName);
});
writer.writeToString(PERSON);
assertThat(paths).containsExactly("first", "last");
}
private JsonWriter<String> simpleWriterWithUppercaseProcessor() {
return JsonWriter.of((members) -> {
members.add();
members.applyingValueProcessor(ValueProcessor.of(String.class, String::toUpperCase));
});
}
}
record Person(String firstName, String lastName, int age) {
@Override