diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java index 6c7017b2538..e8a277efd03 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java @@ -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 filtersAndProcessors = new ArrayDeque<>(); + private final Deque 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 the name type in the pair @@ -82,6 +100,7 @@ class JsonValueWriter { * @param value the value to write */ 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 { 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 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 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 processValue(V value) { + for (JsonWriterFiltersAndProcessors filtersAndProcessors : this.filtersAndProcessors) { + for (ValueProcessor valueProcessor : filtersAndProcessors.valueProcessors()) { + value = processValue(value, valueProcessor); + } + } + return value; + } + + @SuppressWarnings({ "unchecked", "unchecked" }) + private 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++; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java index 03ae21ce463..2125acf506e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java @@ -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 { * @see Members */ static JsonWriter of(Consumer> members) { - Members initializedMembers = new Members<>(members, false); // Don't inline + // Don't inline 'new Members' (must be outside of lambda) + Members initializedMembers = new Members<>(members, false); return (instance, out) -> initializedMembers.write(instance, new JsonValueWriter(out)); } @@ -175,6 +177,8 @@ public interface JsonWriter { private final Series series; + private final JsonWriterFiltersAndProcessors jsonProcessors = new JsonWriterFiltersAndProcessors(); + Members(Consumer> members, boolean contributesToExistingSeries) { Assert.notNull(members, "'members' must not be null"); members.accept(this); @@ -290,6 +294,33 @@ public interface JsonWriter { 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 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 Member addMember(String name, Function extractor) { Member member = new Member<>(this.members.size(), name, Extractor.of(extractor)); this.members.add(member); @@ -302,11 +333,13 @@ public interface JsonWriter { * @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 { } + /** + * 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 []}. 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 { } + /** + * 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 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 the value type + */ + @FunctionalInterface + interface ValueProcessor { + + /** + * 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 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 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 whenHasPath(Predicate 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 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 when(Predicate 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 the value type + * @param type the value type + * @param action the action to apply + * @return a new {@link ValueProcessor} instance + */ + static ValueProcessor of(Class type, UnaryOperator action) { + return of(action).whenInstanceOf(type); + } + + /** + * Factory method to crate a new {@link ValueProcessor} that applies the given + * action. + * @param the value type + * @param action the action to apply + * @return a new {@link ValueProcessor} instance + */ + static ValueProcessor of(UnaryOperator action) { + Assert.notNull(action, "'action' must not be null"); + return (name, value) -> action.apply(value); + } + + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriterFiltersAndProcessors.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriterFiltersAndProcessors.java new file mode 100644 index 00000000000..f23ee2a3ab4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriterFiltersAndProcessors.java @@ -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> pathFilters, List nameProcessors, + List> valueProcessors) { + + JsonWriterFiltersAndProcessors() { + this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java index 2f803a8aac5..27c74c23fc3 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java @@ -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 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> 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 writer = JsonWriter.of((members) -> { + members.add(); + members.applyingNameProcessor(NameProcessor.of(String::toUpperCase)); + }); + assertThat(writer.writeToString("test")).isEqualTo("\"test\""); + } + + @Test + void processNameWhenMember() { + JsonWriter 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> 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> 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> 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 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 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 paths = new ArrayList<>(); + JsonWriter 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 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 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 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 processor = ValueProcessor.of(String::toUpperCase).whenHasPath("foo"); + assertThat(processor.processValue(MemberPath.ROOT.child("foo"), "test")).isEqualTo("TEST"); + } + + @Test + void whenHasPathWithStringWhenPathDoesNotMatch() { + ValueProcessor processor = ValueProcessor.of(String::toUpperCase).whenHasPath("foo"); + assertThat(processor.processValue(MemberPath.ROOT.child("bar"), "test")).isEqualTo("test"); + } + + @Test + void whenHasPathWithPredicateWhenPathMatches() { + ValueProcessor processor = ValueProcessor.of(String::toUpperCase) + .whenHasPath((path) -> path.toString().startsWith("f")); + assertThat(processor.processValue(MemberPath.ROOT.child("foo"), "test")).isEqualTo("TEST"); + } + + @Test + void whenHasPathWithPredicateWhenPathDoesNotMatch() { + ValueProcessor processor = ValueProcessor.of(String::toUpperCase) + .whenHasPath((path) -> path.toString().startsWith("f")); + assertThat(processor.processValue(MemberPath.ROOT.child("bar"), "test")).isEqualTo("test"); + } + + @Test + void whenInstanceOfWhenInstanceMatches() { + ValueProcessor processor = ValueProcessor.of((value) -> value.toString().toUpperCase()) + .whenInstanceOf(String.class); + assertThat(processor.processValue(null, "test")).hasToString("TEST"); + } + + @Test + void whenInstanceOfWhenInstanceDoesNotMatch() { + ValueProcessor processor = ValueProcessor.of((value) -> value.toString().toUpperCase()) + .whenInstanceOf(String.class); + assertThat(processor.processValue(null, new StringBuilder("test"))).hasToString("test"); + } + + @Test + void whenWhenPredicateMatches() { + ValueProcessor processor = ValueProcessor.of(String::toUpperCase).when("test"::equals); + assertThat(processor.processValue(null, "test")).isEqualTo("TEST"); + } + + @Test + void whenWhenPredicateDoesNotMatch() { + ValueProcessor processor = ValueProcessor.of(String::toUpperCase).when("test"::equals); + assertThat(processor.processValue(null, "other")).isEqualTo("other"); + } + + @Test + void processValueWhenSimpleValue() { + JsonWriter writer = simpleWriterWithUppercaseProcessor(); + assertThat(writer.writeToString("test")).isEqualTo("\"TEST\""); + } + + @Test + void processValueWhenMemberValue() { + JsonWriter 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> 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> 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> 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 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> 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> 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 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 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 paths = new ArrayList<>(); + JsonWriter 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 paths = new ArrayList<>(); + JsonWriter> 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 paths = new ArrayList<>(); + JsonWriter 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 simpleWriterWithUppercaseProcessor() { + return JsonWriter.of((members) -> { + members.add(); + members.applyingValueProcessor(ValueProcessor.of(String.class, String::toUpperCase)); + }); + } + + } + record Person(String firstName, String lastName, int age) { @Override