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 new file mode 100644 index 0000000000..96513d38e8 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java @@ -0,0 +1,312 @@ +/* + * 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.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.assertj.core.util.Arrays; + +import org.springframework.boot.json.JsonWriter.WritableJson; +import org.springframework.util.ObjectUtils; +import org.springframework.util.function.ThrowingConsumer; + +/** + * Internal class used by {@link JsonWriter} to handle the lower-level concerns of writing + * JSON. + * + * @author Phillip Webb + * @author Moritz Halbritter + */ +class JsonValueWriter { + + private final Appendable out; + + private final Deque activeSeries = new ArrayDeque<>(); + + /** + * Create a new {@link JsonValueWriter} instance. + * @param out the {@link Appendable} used to receive the JSON output + */ + JsonValueWriter(Appendable out) { + this.out = out; + } + + /** + * Write a name value pair, or just a value if {@code name} is {@code null}. + * @param the name type in the pair + * @param the value type in the pair + * @param name the name of the pair or {@code null} if only the value should be + * written + * @param value the value + * @on IO error + */ + void write(N name, V value) { + if (name != null) { + writePair(name, value); + } + else { + write(value); + } + } + + /** + * Write a value to the JSON output. The following value types are supported: + *
    + *
  • Any {@code null} value
  • + *
  • A {@link WritableJson} instance
  • + *
  • Any {@link Iterable} or Array (written as a JSON array)
  • + *
  • A {@link Map} (written as a JSON object)
  • + *
  • Any {@link Number}
  • + *
  • A {@link Boolean}
  • + *
+ * All other values are written as JSON strings. + * @param the value type + * @param value the value to write + * @on IO error + */ + void write(V value) { + if (value == null) { + append("null"); + } + else if (value instanceof WritableJson writableJson) { + try { + writableJson.to(this.out); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + else if (value instanceof Iterable iterable) { + writeArray(iterable::forEach); + } + else if (ObjectUtils.isArray(value)) { + writeArray(Arrays.asList(ObjectUtils.toObjectArray(value))::forEach); + } + else if (value instanceof Map map) { + writeObject(map::forEach); + } + else if (value instanceof Number) { + append(value.toString()); + } + else if (value instanceof Boolean) { + append(Boolean.TRUE.equals(value) ? "true" : "false"); + } + else { + writeString(value); + } + } + + /** + * Start a new {@link Series} (JSON object or array). + * @param series the series to start + * @on IO error + * @see #end(Series) + * @see #writePairs(Consumer) + * @see #writeElements(Consumer) + */ + void start(Series series) { + if (series != null) { + this.activeSeries.push(new ActiveSeries()); + append(series.openChar); + } + } + + /** + * End an active {@link Series} (JSON object or array). + * @param series the series type being ended (must match {@link #start(Series)}) + * @on IO error + * @see #start(Series) + */ + void end(Series series) { + if (series != null) { + this.activeSeries.pop(); + append(series.closeChar); + } + } + + /** + * Write the specified elements to a newly started {@link Series#ARRAY array series}. + * @param the element type + * @param elements a callback that will be used to provide each element. Typically a + * {@code forEach} method reference. + * @on IO error + * @see #writeElements(Consumer) + */ + void writeArray(Consumer> elements) { + start(Series.ARRAY); + elements.accept(ThrowingConsumer.of(this::writeElement)); + end(Series.ARRAY); + } + + /** + * Write the specified elements to an already started {@link Series#ARRAY array + * series}. + * @param the element type + * @param elements a callback that will be used to provide each element. Typically a + * {@code forEach} method reference. + * @see #writeElements(Consumer) + */ + void writeElements(Consumer> elements) { + elements.accept(ThrowingConsumer.of(this::writeElement)); + } + + void writeElement(E element) { + ActiveSeries activeSeries = this.activeSeries.peek(); + activeSeries.appendCommaIfRequired(); + write(element); + } + + /** + * Write the specified pairs to a newly started {@link Series#OBJECT object series}. + * @param the name type in the pair + * @param the value type in the pair + * @param pairs a callback that will be used to provide each pair. Typically a + * {@code forEach} method reference. + * @on IO error + * @see #writePairs(Consumer) + */ + void writeObject(Consumer> pairs) { + start(Series.OBJECT); + pairs.accept(this::writePair); + end(Series.OBJECT); + } + + /** + * Write the specified pairs to an already started {@link Series#OBJECT object + * series}. + * @param the name type in the pair + * @param the value type in the pair + * @param pairs a callback that will be used to provide each pair. Typically a + * {@code forEach} method reference. + * @see #writePairs(Consumer) + */ + void writePairs(Consumer> pairs) { + pairs.accept(this::writePair); + } + + private void writePair(N name, V value) { + ActiveSeries activeSeries = this.activeSeries.peek(); + activeSeries.appendCommaIfRequired(); + writeString(name); + append(":"); + write(value); + } + + private void writeString(Object value) { + try { + this.out.append('"'); + String string = value.toString(); + for (int i = 0; i < string.length(); i++) { + char ch = string.charAt(i); + switch (ch) { + case '"' -> this.out.append("\\\""); + case '\\' -> this.out.append("\\\\"); + case '/' -> this.out.append("\\/"); + case '\b' -> this.out.append("\\b"); + case '\f' -> this.out.append("\\f"); + case '\n' -> this.out.append("\\n"); + case '\r' -> this.out.append("\\r"); + case '\t' -> this.out.append("\\t"); + default -> { + if (Character.isISOControl(ch)) { + this.out.append("\\u"); + this.out.append(String.format("%04X", (int) ch)); + } + else { + this.out.append(ch); + } + } + } + } + this.out.append('"'); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private void append(String value) { + try { + this.out.append(value); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + + } + + private void append(char ch) { + try { + this.out.append(ch); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * A series of items that can be written to the JSON output. + */ + enum Series { + + /** + * A JSON object series consisting of name/value pairs. + */ + OBJECT('{', '}'), + + /** + * A JSON array series consisting of elements. + */ + ARRAY('[', ']'); + + final char openChar; + + final char closeChar; + + Series(char openChar, char closeChar) { + this.openChar = openChar; + this.closeChar = closeChar; + } + + } + + /** + * Details of the currently active {@link Series}. + */ + private final class ActiveSeries { + + private boolean commaRequired; + + private ActiveSeries() { + } + + void appendCommaIfRequired() { + if (this.commaRequired) { + append(','); + } + this.commaRequired = true; + } + + } + +} 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 new file mode 100644 index 0000000000..0f91c2137b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java @@ -0,0 +1,906 @@ +/* + * 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.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.springframework.boot.json.JsonValueWriter.Series; +import org.springframework.boot.json.JsonWriter.Member.Extractor; +import org.springframework.core.io.WritableResource; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Interface that can be used to write JSON output. Typically used to generate JSON when a + * a dependency on a fully marshalling library (such as Jackson or Gson) cannot be + * assumed. + *

+ * For standard Java types, the {@link #standard()} factory method may be used to obtain + * an instance of this interface. It supports {@link String}, {@link Number} and + * {@link Boolean} as well as {@link Collection}, {@code Array}, {@link Map} and + * {@link WritableJson} types. Typical usage would be: + * + *

+ * JsonWriter<Map<String,Object>> writer = JsonWriter.standard();
+ * writer.write(Map.of("Hello", "World!"), out);
+ * 
+ *

+ * More complex mappings can be created using the {@link #of(Consumer)} method with a + * callback to configure the {@link Members JSON members} that should be written. Typical + * usage would be: + * + *

+ * JsonWriter<Person> writer = JsonWriter.of((members) -> {
+ *     members.add("first", Person::firstName);
+ *     members.add("last", Person::lastName);
+ *     members.add("dob", Person::dateOfBirth)
+ *         .whenNotNull()
+ *         .as(DateTimeFormatter.ISO_DATE::format);
+ * });
+ * writer.write(person, out);
+ * 
+ *

+ * The {@link #writeToString(Object)} method can be used if you want to write the JSON + * directly to a {@link String}. To write to other types of output, the + * {@link #write(Object)} method may be used to obtain a {@link WritableJson} instance. + * + * @param the type being written + * @author Phillip Webb + * @author Moritz Halbritter + * @since 3.4.0 + */ +@FunctionalInterface +public interface JsonWriter { + + /** + * Write the given instance to the provided {@link Appendable}. + * @param instance the instance to write (may be {@code null} + * @param out the output that should receive the JSON + * @throws IOException on IO error + */ + void write(T instance, Appendable out) throws IOException; + + /** + * Write the given instance to a JSON string. + * @param instance the instance to write (may be {@code null}) + * @return the JSON string + */ + default String writeToString(T instance) { + return write(instance).toJsonString(); + } + + /** + * Provide a {@link WritableJson} implementation that may be used to write the given + * instance to various outputs. + * @param instance the instance to write (may be {@code null}) + * @return a {@link WritableJson} instance that may be used to write the JSON + */ + default WritableJson write(T instance) { + return WritableJson.of((out) -> write(instance, out)); + } + + /** + * Return a new {@link JsonWriter} instance that appends a new line after the JSON has + * been written. + * @return a new {@link JsonWriter} instance that appends a new line after the JSON + */ + default JsonWriter withNewLineAtEnd() { + return withSuffix("\n"); + } + + /** + * Return a new {@link JsonWriter} instance that appends the given suffix after the + * JSON has been written. + * @param suffix the suffix to write, if any + * @return a new {@link JsonWriter} instance that appends a suffixafter the JSON + */ + default JsonWriter withSuffix(String suffix) { + if (!StringUtils.hasLength(suffix)) { + return this; + } + return (instance, out) -> { + write(instance, out); + out.append(suffix); + }; + } + + /** + * Factory method to return a {@link JsonWriter} for standard Java types. See + * {@link JsonValueWriter class-level javadoc} for details. + * @param the type to write + * @return a {@link JsonWriter} instance + */ + static JsonWriter standard() { + return of((members) -> members.addSelf()); + } + + /** + * Factory method to return a {@link JsonWriter} with specific {@link Members member + * mapping}. See {@link JsonValueWriter class-level javadoc} and {@link Members} for + * details. + * @param the type to write + * @param members a consumer which should configure the members + * @return a {@link JsonWriter} instance + * @see Members + */ + static JsonWriter of(Consumer> members) { + Members initiaizedMembers = new Members<>(members, false); // Don't inline + return (instance, out) -> initiaizedMembers.write(instance, new JsonValueWriter(out)); + } + + /** + * JSON content that can be written out. + */ + @FunctionalInterface + interface WritableJson { + + /** + * Write the JSON to the provided {@link Appendable}. + * @param out the {@link Appendable} to receive the JSON + * @throws IOException on IO error + */ + void to(Appendable out) throws IOException; + + /** + * Write the JSON to a {@link String}. + * @return the JSON string + */ + default String toJsonString() { + try { + StringBuilder stringBuilder = new StringBuilder(); + to(stringBuilder); + return stringBuilder.toString(); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Write the JSON to the provided {@link WritableResource} using + * {@link StandardCharsets#UTF_8 UTF8} encoding. + * @param out the {@link OutputStream} to receive the JSON + * @throws IOException on IO error + */ + default void toResource(WritableResource out) throws IOException { + Assert.notNull(out, "'out' must not be null"); + try (OutputStream outputStream = out.getOutputStream()) { + toOutputStream(outputStream); + } + } + + /** + * Write the JSON to the provided {@link WritableResource} using the given + * {@link Charset}. + * @param out the {@link OutputStream} to receive the JSON + * @param charset the charset to use + * @throws IOException on IO error + */ + default void toResource(WritableResource out, Charset charset) throws IOException { + Assert.notNull(out, "'out' must not be null"); + Assert.notNull(charset, "'charset' must not be null"); + try (OutputStream outputStream = out.getOutputStream()) { + toOutputStream(outputStream, charset); + } + } + + /** + * Write the JSON to the provided {@link OutputStream} using + * {@link StandardCharsets#UTF_8 UTF8} encoding. The output stream will not be + * closed. + * @param out the {@link OutputStream} to receive the JSON + * @throws IOException on IO error + * @see #toOutputStream(OutputStream, Charset) + */ + default void toOutputStream(OutputStream out) throws IOException { + toOutputStream(out, StandardCharsets.UTF_8); + } + + /** + * Write the JSON to the provided {@link OutputStream} using the given + * {@link Charset}. The output stream will not be closed. + * @param out the {@link OutputStream} to receive the JSON + * @param charset the charset to use + * @throws IOException on IO error + */ + default void toOutputStream(OutputStream out, Charset charset) throws IOException { + Assert.notNull(out, "'out' must not be null"); + Assert.notNull(charset, "'charset' must not be null"); + toWriter(new OutputStreamWriter(out, charset)); + } + + /** + * Write the JSON to the provided {@link Writer}. The writer will be flushed but + * not closed. + * @param out the {@link Writer} to receive the JSON + * @throws IOException on IO error + * @see #toOutputStream(OutputStream, Charset) + */ + default void toWriter(Writer out) throws IOException { + Assert.notNull(out, "'out' must not be null"); + to(out); + out.flush(); + } + + /** + * Factory method used to create a {@link WritableJson} with a sensible + * {@link Object#toString()} that delegate to {@link WritableJson#toJsonString()}. + * @param writableJson the source {@link WritableJson} + * @return a new {@link WritableJson} with a sensible {@link Object#toString()}. + */ + static WritableJson of(WritableJson writableJson) { + return new WritableJson() { + + @Override + public void to(Appendable out) throws IOException { + writableJson.to(out); + } + + @Override + public String toString() { + return toJsonString(); + } + + }; + } + + } + + /** + * Callback used to configure JSON members. Individual members can be declared using + * the various {@code add(...)} methods. Typically members are declared with a + * {@code "name"} and a {@link Function} that will extract the value from the + * instance. Members can also be declared using a static value or a {@link Supplier}. + * The {@link #addSelf(String)} and {@link #addSelf()} methods may be used to access + * the actual instance being written. + *

+ * Members can be added without a {@code name} when a {@code Member.using(...)} method + * is used to complete the definition. + *

+ * Members can filtered using {@code Member.when} methods and adapted to different + * types using {@link Member#as(Function) Member.as(...)}. + * + * @param the type that will be written + */ + final class Members { + + private final List> members = new ArrayList<>(); + + private final boolean contributesPair; + + private final Series series; + + Members(Consumer> members, boolean contributesToExistingSeries) { + Assert.notNull(members, "'members' must not be null"); + members.accept(this); + Assert.state(!this.members.isEmpty(), "No members have been added"); + this.contributesPair = this.members.stream().anyMatch(Member::contributesPair); + this.series = (this.contributesPair && !contributesToExistingSeries) ? Series.OBJECT : null; + if (this.contributesPair || this.members.size() > 1) { + this.members.forEach((member) -> Assert.state(member.contributesPair(), + () -> String.format("%s does not contribute a named pair, ensure that all members have " + + "a name or call an appropriate 'using' method", member))); + } + } + + /** + * Add a new member with access to the instance being written. + * @param name the member name + * @return the added {@link Member} which may be configured further + */ + public Member addSelf(String name) { + return add(name, (instance) -> instance); + } + + /** + * Add a new member with a static value. + * @param the value type + * @param name the member name + * @param value the member value + * @return the added {@link Member} which may be configured further + */ + public Member add(String name, V value) { + return add(name, (instance) -> value); + } + + /** + * Add a new member with a supplied value. + * @param the value type + * @param name the member name + * @param supplier a supplier of the value + * @return the added {@link Member} which may be configured further + */ + public Member add(String name, Supplier supplier) { + Assert.notNull(supplier, "'supplier' must not be null"); + return add(name, (instance) -> supplier.get()); + } + + /** + * Add a new member with an extracted value. + * @param the value type + * @param name the member name + * @param extractor a function to extract the value + * @return the added {@link Member} which may be configured further + */ + public Member add(String name, Function extractor) { + Assert.notNull(name, "'name' must not be null"); + Assert.notNull(extractor, "'extractor' must not be null"); + return addMember(name, extractor); + } + + /** + * Add a new member with access to the instance being written. The member is added + * without a name, so one of the {@code Member.using(...)} methods must be used to + * complete the configuration. + * @return the added {@link Member} which may be configured further + */ + public Member addSelf() { + return add((instance) -> instance); + } + + /** + * Add a new member with a static value. The member is added without a name, so + * one of the {@code Member.using(...)} methods must be used to complete the + * configuration. + * @param the value type + * @param value the member value + * @return the added {@link Member} which may be configured further + */ + public Member add(V value) { + return add((instance) -> value); + } + + /** + * Add a new member with a supplied value.The member is added without a name, so + * one of the {@code Member.using(...)} methods must be used to complete the + * configuration. + * @param the value type + * @param supplier a supplier of the value + * @return the added {@link Member} which may be configured further + */ + public Member add(Supplier supplier) { + Assert.notNull(supplier, "'supplier' must not be null"); + return add((instance) -> supplier.get()); + } + + /** + * Add a new member with an extracted value. The member is added without a name, + * so one of the {@code Member.using(...)} methods must be used to complete the + * configuration. + * @param the value type + * @param extractor a function to extract the value + * @return the added {@link Member} which may be configured further + */ + public Member add(Function extractor) { + Assert.notNull(extractor, "'extractor' must not be null"); + return addMember(null, extractor); + } + + /** + * Add all entries from the given {@link Map} to the JSON. + * @param the map type + * @param the key type + * @param the value type + * @param extractor a function to extract the map + * @return the added {@link Member} which may be configured further + */ + public , K, V> Member addMapEntries(Function extractor) { + return add(extractor).usingPairs(Map::forEach); + } + + private Member addMember(String name, Function extractor) { + Member member = new Member<>(this.members.size(), name, Extractor.of(extractor)); + this.members.add(member); + return member; + } + + /** + * Writes the given instance using the configured {@link Member members}. + * @param instance the instance to write + * @param valueWriter the JSON value writer to use + */ + void write(T instance, JsonValueWriter valueWriter) { + valueWriter.start(this.series); + for (Member member : this.members) { + member.write(instance, valueWriter); + } + valueWriter.end(this.series); + } + + /** + * Return if any of the members contributes a name/value pair to the JSON. + * @return if a name/value pair is contributed + */ + boolean contributesPair() { + return this.contributesPair; + } + + } + + /** + * A member that contributes JSON. Typically a member will contribute a single + * name/value pair based on an extracted value. They may also contribute more complex + * JSON structures when configured with one of the {@code using(...)} methods. + *

+ * The {@code when(...)} methods may be used to filter a member (omit it entirely from + * the JSON). The {@link #as(Function)} method can be used to adapt to a different + * type. + * + * @param the member type + */ + final class Member { + + private final int index; + + private final String name; + + private Extractor extractor; + + private BiConsumer> pairs; + + private Members members; + + Member(int index, String name, Extractor extractor) { + this.index = index; + this.name = name; + this.extractor = extractor; + } + + /** + * Only include this member when its value is not {@code null}. + * @return a {@link Member} which may be configured further + */ + public Member whenNotNull() { + return when(Objects::nonNull); + } + + /** + * Only include this member when an extracted value is not {@code null}. + * @param extractor an function used to extract the value to test + * @return a {@link Member} which may be configured further + */ + public Member whenNotNull(Function extractor) { + Assert.notNull(extractor, "'extractor' must not be null"); + return when((instance) -> Objects.nonNull(extractor.apply(instance))); + } + + /** + * Only include this member when its not {@code null} and has a + * {@link Object#toString() toString()} that is not zero length. + * @return a {@link Member} which may be configured further + * @see StringUtils#hasLength(CharSequence) + */ + public Member whenHasLength() { + return when((instance) -> instance != null && StringUtils.hasLength(instance.toString())); + } + + /** + * Only include this member when its not empty (See + * {@link ObjectUtils#isEmpty(Object)} for details). + * @return a {@link Member} which may be configured further + */ + public Member whenNotEmpty() { + return whenNot(ObjectUtils::isEmpty); + } + + /** + * Only include this member when the given predicate does not match. + * @param predicate the predicate to test + * @return a {@link Member} which may be configured further + */ + public Member whenNot(Predicate predicate) { + Assert.notNull(predicate, "'predicate' must not be null"); + return when(predicate.negate()); + } + + /** + * Only include this member when the given predicate matches. + * @param predicate the predicate to test + * @return a {@link Member} which may be configured further + */ + public Member when(Predicate predicate) { + Assert.notNull(predicate, "'predicate' must not be null"); + this.extractor = this.extractor.when(predicate); + return this; + } + + /** + * Adapt the value by applying the given {@link Function}. + * @param the result type + * @param adapter a {@link Function} to adapt the value + * @return a {@link Member} which may be configured further + */ + @SuppressWarnings("unchecked") + public Member as(Function adapter) { + Assert.notNull(adapter, "'adapter' must not be null"); + Member result = (Member) this; + result.extractor = this.extractor.as(adapter); + return result; + } + + /** + * Add JSON name/value pairs by extracting values from a series of elements. + * Typically used with a {@link Iterable#forEach(Consumer)} call, for example: + * + *

+		 * members.add(Event::getTags).usingExtractedPairs(Iterable::forEach, pairExtractor);
+		 * 
+ *

+ * When used with a named member, the pairs will be added as a new JSON value + * object: + * + *

+		 * {
+		 *   "name": {
+		 *     "p1": 1,
+		 *     "p2": 2
+		 *   }
+		 * }
+		 * 
+ * + * When used with an unnamed member the pairs will be added to the existing JSON + * object: + * + *
+		 * {
+		 *   "p1": 1,
+		 *   "p2": 2
+		 * }
+		 * 
+ * @param the element type + * @param the name type + * @param the value type + * @param elements callback used to provide the elements + * @param extractor a {@link PairExtractor} used to extract the name/value pair + * @return a {@link Member} which may be configured further + * @see #usingExtractedPairs(BiConsumer, Function, Function) + * @see #usingPairs(BiConsumer) + */ + public Member usingExtractedPairs(BiConsumer> elements, + PairExtractor extractor) { + Assert.notNull(elements, "'elements' must not be null"); + Assert.notNull(extractor, "'extractor' must not be null"); + return usingExtractedPairs(elements, extractor::getName, extractor::getValue); + } + + /** + * Add JSON name/value pairs by extracting values from a series of elements. + * Typically used with a {@link Iterable#forEach(Consumer)} call, for example: + * + *
+		 * members.add(Event::getTags).usingExtractedPairs(Iterable::forEach, Tag::getName, Tag::getValue);
+		 * 
+ *

+ * When used with a named member, the pairs will be added as a new JSON value + * object: + * + *

+		 * {
+		 *   "name": {
+		 *     "p1": 1,
+		 *     "p2": 2
+		 *   }
+		 * }
+		 * 
+ * + * When used with an unnamed member the pairs will be added to the existing JSON + * object: + * + *
+		 * {
+		 *   "p1": 1,
+		 *   "p2": 2
+		 * }
+		 * 
+ * @param the element type + * @param the name type + * @param the value type + * @param elements callback used to provide the elements + * @param nameExtractor {@link Function} used to extract the name + * @param valueExtractor {@link Function} used to extract the value + * @return a {@link Member} which may be configured further + * @see #usingExtractedPairs(BiConsumer, PairExtractor) + * @see #usingPairs(BiConsumer) + */ + public Member usingExtractedPairs(BiConsumer> elements, + Function nameExtractor, Function valueExtractor) { + Assert.notNull(elements, "'elements' must not be null"); + Assert.notNull(nameExtractor, "'nameExtractor' must not be null"); + Assert.notNull(valueExtractor, "'valueExtractor' must not be null"); + return usingPairs((instance, pairsConsumer) -> elements.accept(instance, (element) -> { + N name = nameExtractor.apply(element); + V value = valueExtractor.apply(element); + pairsConsumer.accept(name, value); + })); + } + + /** + * Add JSON name/value pairs. Typically used with a + * {@link Map#forEach(BiConsumer)} call, for example: + * + *
+		 * members.add(Event::getLabels).usingPairs(Map::forEach);
+		 * 
+ *

+ * When used with a named member, the pairs will be added as a new JSON value + * object: + * + *

+		 * {
+		 *   "name": {
+		 *     "p1": 1,
+		 *     "p2": 2
+		 *   }
+		 * }
+		 * 
+ * + * When used with an unnamed member the pairs will be added to the existing JSON + * object: + * + *
+		 * {
+		 *   "p1": 1,
+		 *   "p2": 2
+		 * }
+		 * 
+ * @param the name type + * @param the value type + * @param pairs callback used to provide the pairs + * @return a {@link Member} which may be configured further + * @see #usingExtractedPairs(BiConsumer, PairExtractor) + * @see #usingPairs(BiConsumer) + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Member usingPairs(BiConsumer> pairs) { + Assert.notNull(pairs, "'pairs' must not be null"); + Assert.state(this.pairs == null, "Pairs cannot be declared multiple times"); + Assert.state(this.members == null, "Pairs cannot be declared when using members"); + this.pairs = (BiConsumer) pairs; + return this; + } + + /** + * Add JSON based on further {@link Members} configuration. For example: + * + *
+		 * members.add(User::getName).usingMembers((personMembers) -> {
+		 *     personMembers.add("first", Name::first);
+		 *     personMembers.add("last", Name::last);
+		 * });
+		 * 
+ * + *

+ * When used with a named member, the result will be added as a new JSON value + * object: + * + *

+		 * {
+		 *   "name": {
+		 *     "first": "Jane",
+		 *     "last": "Doe"
+		 *   }
+		 * }
+		 * 
+ * + * When used with an unnamed member the result will be added to the existing JSON + * object: + * + *
+		 * {
+		 *   "first": "John",
+		 *   "last": "Doe"
+		 * }
+		 * 
+ * @param members callback to configure the members + * @return a {@link Member} which may be configured further + * @see #usingExtractedPairs(BiConsumer, PairExtractor) + * @see #usingPairs(BiConsumer) + */ + public Member usingMembers(Consumer> members) { + Assert.notNull(members, "'members' must not be null"); + Assert.state(this.members == null, "Members cannot be declared multiple times"); + Assert.state(this.pairs == null, "Members cannot be declared when using pairs"); + this.members = new Members<>(members, this.name == null); + return this; + } + + /** + * Writes the given instance using details configure by this member. + * @param instance the instance to write + * @param valueWriter the JSON value writer to use + */ + void write(Object instance, JsonValueWriter valueWriter) { + T extracted = this.extractor.extract(instance); + if (Extractor.skip(extracted)) { + return; + } + Object value = getValueToWrite(extracted, valueWriter); + valueWriter.write(this.name, value); + } + + private Object getValueToWrite(T extracted, JsonValueWriter valueWriter) { + if (this.pairs != null) { + return WritableJson.of((out) -> valueWriter.writePairs((pairs) -> this.pairs.accept(extracted, pairs))); + } + if (this.members != null) { + return WritableJson.of((out) -> this.members.write(extracted, valueWriter)); + } + return extracted; + } + + /** + * Return if this members contributes one or more name/value pairs to the JSON. + * @return if a name/value pair is contributed + */ + boolean contributesPair() { + return this.name != null || this.pairs != null || (this.members != null && this.members.contributesPair()); + } + + @Override + public String toString() { + return "Member at index " + this.index + ((this.name != null) ? "{%s}".formatted(this.name) : ""); + } + + /** + * Internal class used to manage member value extraction and filtering. + * + * @param the member type + */ + @FunctionalInterface + interface Extractor { + + /** + * Represents a skipped value. + */ + Object SKIP = new Object(); + + /** + * Extract the value from the given instance. + * @param instance the source instance + * @return the extracted value or {@link #SKIP} + */ + T extract(Object instance); + + /** + * Only extract when the given predicate matches. + * @param predicate the predicate to test + * @return a new {@link Extractor} + */ + default Extractor when(Predicate predicate) { + return (instance) -> test(extract(instance), predicate); + } + + @SuppressWarnings("unchecked") + private T test(T extracted, Predicate predicate) { + return (!skip(extracted) && predicate.test(extracted)) ? extracted : (T) SKIP; + } + + /** + * Adapt the extracted value. + * @param the result type + * @param adapter the adapter to use + * @return a new {@link Extractor} + */ + default Extractor as(Function adapter) { + return (instance) -> apply(extract(instance), adapter); + } + + @SuppressWarnings("unchecked") + private R apply(T extracted, Function function) { + if (skip(extracted)) { + return (R) SKIP; + } + return (extracted != null) ? function.apply(extracted) : null; + } + + /** + * Create a new {@link Extractor} based on the given {@link Function}. + * @param the source type + * @param the extracted type + * @param extractor the extractor to use + * @return a new {@link Extractor} instance + */ + @SuppressWarnings("unchecked") + static Extractor of(Function extractor) { + return (instance) -> !skip(instance) ? extractor.apply((S) instance) : (T) SKIP; + } + + /** + * Return if the extracted value should be skipped. + * @param the value type + * @param extracted the value to test + * @return if the value is to be skipped + */ + static boolean skip(T extracted) { + return extracted == SKIP; + } + + } + + } + + /** + * Interface that can be used to extract name/value pairs from an element. + * + * @param the element type + */ + interface PairExtractor { + + /** + * Extract the name. + * @param the name type + * @param element the source element + * @return the extracted name + */ + N getName(E element); + + /** + * Extract the name. + * @param the value type + * @param element the source element + * @return the extracted value + */ + V getValue(E element); + + /** + * Factory method to create a {@link PairExtractor} using distinct name and value + * extraction functions. + * @param the element type + * @param nameExtractor the name extractor + * @param valueExtractor the value extraction + * @return a new {@link PairExtractor} instance + */ + static PairExtractor of(Function nameExtractor, Function valueExtractor) { + Assert.notNull(nameExtractor, "'nameExtractor' must not be null"); + Assert.notNull(valueExtractor, "'valueExtractor' must not be null"); + return new PairExtractor<>() { + + @Override + @SuppressWarnings("unchecked") + public N getName(T instance) { + return (N) nameExtractor.apply(instance); + } + + @Override + @SuppressWarnings("unchecked") + public V getValue(T instance) { + return (V) valueExtractor.apply(instance); + } + + }; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java new file mode 100644 index 0000000000..1f4441a304 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java @@ -0,0 +1,246 @@ +/* + * 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.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.json.JsonValueWriter.Series; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link JsonValueWriter} . + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class JsonValueWriterTests { + + @Test + void writeNameAndValueWhenNameIsNull() { + assertThat(doWrite((writer) -> writer.write(null, "test"))).isEqualTo(quoted("test")); + } + + @Test + void writeNameAndValueWhenNameIsNotNull() { + assertThat(doWrite((writer) -> { + writer.start(Series.OBJECT); + writer.write("name", "value"); + writer.end(Series.OBJECT); + })).isEqualTo(""" + {"name":"value"}"""); + } + + @Test + void writeWhenNull() { + assertThat(write(null)).isEqualTo("null"); + } + + @Test + void writeWhenWritableJson() { + + JsonWriter writer = (instance, out) -> out.append(""" + {"test":"%s"}""".formatted(instance)); + assertThat(write(writer.write("hello"))).isEqualTo(""" + {"test":"hello"}"""); + } + + @Test + void writeWhenStringArray() { + assertThat(write(new String[] { "a", "b", "c" })).isEqualTo(""" + ["a","b","c"]"""); + } + + @Test + void writeWhenNumberArray() { + assertThat(write(new int[] { 1, 2, 3 })).isEqualTo("[1,2,3]"); + assertThat(write(new double[] { 1.0, 2.0, 3.0 })).isEqualTo("[1.0,2.0,3.0]"); + } + + @Test + void writeWhenBooleanArray() { + assertThat(write(new boolean[] { true, false, true })).isEqualTo("[true,false,true]"); + } + + @Test + void writeWhenArrayWithNullElements() { + assertThat(write(new Object[] { null, null })).isEqualTo("[null,null]"); + } + + @Test + void writeWhenArrayWithMixedElementTypes() { + assertThat(write(new Object[] { "a", "b", "c", 1, 2, true, null })).isEqualTo(""" + ["a","b","c",1,2,true,null]"""); + } + + @Test + void writeWhenCollection() { + assertThat(write(List.of("a", "b", "c"))).isEqualTo(""" + ["a","b","c"]"""); + assertThat(write(new LinkedHashSet<>(List.of("a", "b", "c")))).isEqualTo(""" + ["a","b","c"]"""); + } + + @Test + void writeWhenMap() { + Map map = new LinkedHashMap<>(); + map.put("a", "A"); + map.put("b", "B"); + assertThat(write(map)).isEqualTo(""" + {"a":"A","b":"B"}"""); + } + + @Test + void writeWhenMapWithNumericalKeys() { + Map map = new LinkedHashMap<>(); + map.put(1, "A"); + map.put(2, "B"); + assertThat(write(map)).isEqualTo(""" + {"1":"A","2":"B"}"""); + } + + @Test + void writeWhenMapWithMixedValueTypes() { + Map map = new LinkedHashMap<>(); + map.put("a", 1); + map.put("b", 2.0); + map.put("c", true); + map.put("d", "d"); + map.put("e", null); + assertThat(write(map)).isEqualTo(""" + {"a":1,"b":2.0,"c":true,"d":"d","e":null}"""); + } + + @Test + void writeWhenNumber() { + assertThat(write((byte) 123)).isEqualTo("123"); + assertThat(write(123)).isEqualTo("123"); + assertThat(write(123L)).isEqualTo("123"); + assertThat(write(2.0)).isEqualTo("2.0"); + assertThat(write(2.0f)).isEqualTo("2.0"); + assertThat(write(Byte.valueOf((byte) 123))).isEqualTo("123"); + assertThat(write(Integer.valueOf(123))).isEqualTo("123"); + assertThat(write(Long.valueOf(123L))).isEqualTo("123"); + assertThat(write(Double.valueOf(2.0))).isEqualTo("2.0"); + assertThat(write(Float.valueOf(2.0f))).isEqualTo("2.0"); + } + + @Test + void writeWhenBoolean() { + assertThat(write(true)).isEqualTo("true"); + assertThat(write(Boolean.TRUE)).isEqualTo("true"); + assertThat(write(false)).isEqualTo("false"); + assertThat(write(Boolean.FALSE)).isEqualTo("false"); + } + + @Test + void writeWhenString() { + assertThat(write("test")).isEqualTo(quoted("test")); + } + + @Test + void writeWhenStringRequiringEscape() { + assertThat(write("\"")).isEqualTo(quoted("\\\"")); + assertThat(write("\\")).isEqualTo(quoted("\\\\")); + assertThat(write("/")).isEqualTo(quoted("\\/")); + assertThat(write("\b")).isEqualTo(quoted("\\b")); + assertThat(write("\f")).isEqualTo(quoted("\\f")); + assertThat(write("\n")).isEqualTo(quoted("\\n")); + assertThat(write("\r")).isEqualTo(quoted("\\r")); + assertThat(write("\t")).isEqualTo(quoted("\\t")); + assertThat(write("\u0000\u001F")).isEqualTo(quoted("\\u0000\\u001F")); + } + + @Test + void writeObject() { + Map map = Map.of("a", "A"); + String actual = doWrite((valueWriter) -> valueWriter.writeObject(map::forEach)); + assertThat(actual).isEqualTo(""" + {"a":"A"}"""); + } + + @Test + void writePairs() { + String actual = doWrite((valueWriter) -> { + valueWriter.start(Series.OBJECT); + valueWriter.writePairs(Map.of("a", "A")::forEach); + valueWriter.writePairs(Map.of("b", "B")::forEach); + valueWriter.end(Series.OBJECT); + }); + assertThat(actual).isEqualTo(""" + {"a":"A","b":"B"}"""); + } + + @Test + void writeArray() { + List list = List.of("a", "b", "c"); + String actual = doWrite((valueWriter) -> valueWriter.writeArray(list::forEach)); + assertThat(actual).isEqualTo(""" + ["a","b","c"]"""); + } + + @Test + void writeElements() { + String actual = doWrite((valueWriter) -> { + valueWriter.start(Series.ARRAY); + valueWriter.writeElements(List.of("a", "b")::forEach); + valueWriter.writeElements(List.of("c", "d")::forEach); + valueWriter.end(Series.ARRAY); + }); + assertThat(actual).isEqualTo(""" + ["a","b","c","d"]"""); + } + + @Test + void startAndEndWithNull() { + String actual = doWrite((valueWriter) -> { + valueWriter.start(null); + valueWriter.write("test"); + valueWriter.end(null); + }); + assertThat(actual).isEqualTo(quoted("test")); + } + + @Test + void endWhenNotStartedThrowsException() { + doWrite((valueWriter) -> assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> valueWriter.end(Series.ARRAY))); + } + + private String write(V value) { + return doWrite((valueWriter) -> valueWriter.write(value)); + } + + private String doWrite(Consumer action) { + StringBuilder out = new StringBuilder(); + action.accept(new JsonValueWriter(out)); + return out.toString(); + } + + private String quoted(String string) { + return "\"" + string + "\""; + } + +} 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 new file mode 100644 index 0000000000..1288977a94 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java @@ -0,0 +1,584 @@ +/* + * 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.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.json.JsonWriter.PairExtractor; +import org.springframework.boot.json.JsonWriter.WritableJson; +import org.springframework.core.io.FileSystemResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link JsonWriter}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +public class JsonWriterTests { + + private static final Person PERSON = new Person("Spring", "Boot", 10); + + @TempDir + File temp; + + @Test + void writeToStringWritesToString() { + assertThat(ofFormatString("%s").writeToString(123)).isEqualTo("123"); + } + + @Test + void writeReturnsWritableJson() { + assertThat(ofFormatString("%s").write(123)).isInstanceOf(WritableJson.class); + } + + @Test + void withSuffixAddsSuffixToWrittenString() { + assertThat(ofFormatString("%s").withSuffix("000").writeToString(123)).isEqualTo("123000"); + } + + @Test + void withSuffixWhenSuffixIsNullReturnsExistingWriter() { + JsonWriter writer = ofFormatString("%s"); + assertThat(writer.withSuffix(null)).isSameAs(writer); + } + + @Test + void withSuffixWhenSuffixIsEmptyReturnsExistingWriter() { + JsonWriter writer = ofFormatString("%s"); + assertThat(writer.withSuffix("")).isSameAs(writer); + } + + @Test + void withNewLineAtEndAddsNewLineToWrittenString() { + assertThat(ofFormatString("%s").withNewLineAtEnd().writeToString(123)).isEqualTo("123\n"); + } + + @Test + void ofAddingNamedSelf() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf("test")); + assertThat(writer.writeToString(PERSON)).isEqualTo(""" + {"test":"Spring Boot (10)"}"""); + } + + @Test + void ofAddingNamedValue() { + JsonWriter writer = JsonWriter.of((members) -> members.add("Spring", "Boot")); + assertThat(writer.writeToString(PERSON)).isEqualTo(""" + {"Spring":"Boot"}"""); + } + + @Test + void ofAddingNamedSupplier() { + JsonWriter writer = JsonWriter.of((members) -> members.add("Spring", () -> "Boot")); + assertThat(writer.writeToString(PERSON)).isEqualTo(""" + {"Spring":"Boot"}"""); + } + + @Test + void ofAddingUnamedSelf() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf()); + assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Spring Boot (10)")); + } + + @Test + void ofAddingUnamedValue() { + JsonWriter writer = JsonWriter.of((members) -> members.add("Boot")); + assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot")); + } + + @Test + void ofAddingUnamedSupplier() { + JsonWriter writer = JsonWriter.of((members) -> members.add(() -> "Boot")); + assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot")); + } + + @Test + void ofAddingUnamedExtractor() { + JsonWriter writer = JsonWriter.of((members) -> members.add(Person::lastName)); + assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot")); + } + + @Test + void ofAddingMapEntries() { + Map map = new LinkedHashMap<>(); + map.put("a", "A"); + map.put("b", 123); + map.put("c", true); + JsonWriter>> writer = JsonWriter + .of((members) -> members.addMapEntries((list) -> list.get(0))); + assertThat(writer.writeToString(List.of(map))).isEqualTo(""" + {"a":"A","b":123,"c":true}"""); + } + + @Test + void ofAddingNamedExtractor() { + JsonWriter writer = JsonWriter.of((members) -> { + members.add("firstName", Person::firstName); + members.add("lastName", Person::lastName); + members.add("age", Person::age); + }); + assertThat(writer.writeToString(PERSON)).isEqualTo(""" + {"firstName":"Spring","lastName":"Boot","age":10}"""); + } + + @Test + void ofWhenNoMembersAddedThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> JsonWriter.of((members) -> { + })).withMessage("No members have been added"); + } + + @Test + void ofWhenOneContibutesPairByNameAndOneHasNoNameThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> JsonWriter.of((members) -> { + members.add("Spring", "Boot"); + members.add("alone"); + })) + .withMessage("Member at index 1 does not contribute a named pair, " + + "ensure that all members have a name or call an appropriate 'using' method"); + } + + @Test + void ofWhenOneContibutesPairByUsingPairsAndOneHasNoNameThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> JsonWriter.of((members) -> { + members.add(Map.of("Spring", "Boot")).usingPairs(Map::forEach); + members.add("alone"); + })) + .withMessage("Member at index 1 does not contribute a named pair, " + + "ensure that all members have a name or call an appropriate 'using' method"); + } + + @Test + void ofWhenOneContibutesPairByUsingMembersAndOneHasNoNameThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> JsonWriter.of((members) -> { + members.add(PERSON).usingMembers((personMembers) -> { + personMembers.add("first", Person::firstName); + personMembers.add("last", Person::firstName); + }); + members.add("alone"); + })) + .withMessage("Member at index 1 does not contribute a named pair, " + + "ensure that all members have a name or call an appropriate 'using' method"); + } + + private static String quoted(String value) { + return "\"" + value + "\""; + } + + private static JsonWriter ofFormatString(String json) { + return (instance, out) -> out.append(json.formatted(instance)); + } + + @Nested + class StandardWriterTests { + + @Test + void whenPrimitive() { + assertThat(write(null)).isEqualTo("null"); + assertThat(write(123)).isEqualTo("123"); + assertThat(write(true)).isEqualTo("true"); + assertThat(write("test")).isEqualTo(quoted("test")); + } + + @Test + void whenMap() { + assertThat(write(Map.of("spring", "boot"))).isEqualTo(""" + {"spring":"boot"}"""); + } + + @Test + void whenArray() { + assertThat(write(new int[] { 1, 2, 3 })).isEqualTo("[1,2,3]"); + } + + private String write(T instance) { + return JsonWriter.standard().writeToString(instance); + } + + } + + @Nested + class MemberTest { + + @Test + void whenNotNull() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().whenNotNull()); + assertThat(writer.writeToString("test")).isEqualTo(quoted("test")); + assertThat(writer.writeToString(null)).isEmpty(); + } + + @Test + void whenNotNullExtracted() { + Person personWithNull = new Person("Spring", null, 10); + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().whenNotNull(Person::lastName)); + assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Spring Boot (10)")); + assertThat(writer.writeToString(personWithNull)).isEmpty(); + } + + @Test + void whenHasLength() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().whenHasLength()); + assertThat(writer.writeToString("test")).isEqualTo(quoted("test")); + assertThat(writer.writeToString("")).isEmpty(); + assertThat(writer.writeToString(null)).isEmpty(); + } + + @Test + void whenHasLengthOnNonString() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().whenHasLength()); + assertThat(writer.writeToString(new StringBuilder("test"))).isEqualTo(quoted("test")); + assertThat(writer.writeToString(new StringBuilder(""))).isEmpty(); + assertThat(writer.writeToString(null)).isEmpty(); + } + + @Test + void whenNotEmpty() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().whenNotEmpty()); + assertThat(writer.writeToString(List.of("a"))).isEqualTo(""" + ["a"]"""); + assertThat(writer.writeToString(Collections.emptyList())).isEmpty(); + assertThat(writer.writeToString(new Object[] {})).isEmpty(); + assertThat(writer.writeToString(new int[] {})).isEmpty(); + assertThat(writer.writeToString(null)).isEmpty(); + } + + @Test + void whenNot() { + JsonWriter> writer = JsonWriter.of((members) -> members.addSelf().whenNot(List::isEmpty)); + assertThat(writer.writeToString(List.of("a"))).isEqualTo(""" + ["a"]"""); + assertThat(writer.writeToString(Collections.emptyList())).isEmpty(); + } + + @Test + void when() { + JsonWriter> writer = JsonWriter.of((members) -> members.addSelf().when(List::isEmpty)); + assertThat(writer.writeToString(List.of("a"))).isEmpty(); + assertThat(writer.writeToString(Collections.emptyList())).isEqualTo("[]"); + } + + @Test + void chainedPredicates() { + Set banned = Set.of("Spring", "Boot"); + JsonWriter writer = JsonWriter.of((members) -> members.addSelf() + .whenHasLength() + .whenNot(banned::contains) + .whenNot((string) -> string.length() <= 2)); + assertThat(writer.writeToString("")).isEmpty(); + assertThat(writer.writeToString("a")).isEmpty(); + assertThat(writer.writeToString("Boot")).isEmpty(); + assertThat(writer.writeToString("JSON")).isEqualTo(quoted("JSON")); + } + + @Test + void as() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().as(Integer::valueOf)); + assertThat(writer.writeToString("123")).isEqualTo("123"); + } + + @Test + void asWhenValueIsNullDoesNotCallAdapter() { + JsonWriter writer = JsonWriter.of((members) -> members.addSelf().as((value) -> { + throw new RuntimeException("bad"); + })); + writer.writeToString(null); + } + + @Test + void chainedAs() { + Function booleanAdapter = (integer) -> integer != 0; + JsonWriter writer = JsonWriter + .of((members) -> members.addSelf().as(Integer::valueOf).as(booleanAdapter)); + assertThat(writer.writeToString("0")).isEqualTo("false"); + assertThat(writer.writeToString("1")).isEqualTo("true"); + } + + @Test + void chainedAsAndPredicates() { + Function booleanAdapter = (integer) -> integer != 0; + JsonWriter writer = JsonWriter.of((members) -> members.addSelf() + .whenNot(String::isEmpty) + .as(Integer::valueOf) + .when((integer) -> integer < 2) + .as(booleanAdapter)); + assertThat(writer.writeToString("")).isEmpty(); + assertThat(writer.writeToString("0")).isEqualTo("false"); + assertThat(writer.writeToString("1")).isEqualTo("true"); + assertThat(writer.writeToString("2")).isEmpty(); + } + + @Test + void usingExtractedPairsWithExtractor() { + Map map = new LinkedHashMap<>(); + map.put("a", "A"); + map.put("b", "B"); + PairExtractor> extractor = PairExtractor.of(Map.Entry::getKey, + Map.Entry::getValue); + JsonWriter> writer = JsonWriter + .of((members) -> members.addSelf().as(Map::entrySet).usingExtractedPairs(Set::forEach, extractor)); + assertThat(writer.writeToString(map)).isEqualTo(""" + {"a":"A","b":"B"}"""); + } + + @Test + void usingExtractedPairs() { + Map map = new LinkedHashMap<>(); + map.put("a", "A"); + map.put("b", "B"); + Function, String> nameExtractor = Map.Entry::getKey; + Function, Object> valueExtractor = Map.Entry::getValue; + JsonWriter> writer = JsonWriter.of((members) -> members.addSelf() + .as(Map::entrySet) + .usingExtractedPairs(Set::forEach, nameExtractor, valueExtractor)); + assertThat(writer.writeToString(map)).isEqualTo(""" + {"a":"A","b":"B"}"""); + } + + @Test + void usingPairs() { + Map map = new LinkedHashMap<>(); + map.put("a", "A"); + map.put("b", "B"); + JsonWriter> writer = JsonWriter + .of((members) -> members.addSelf().usingPairs(Map::forEach)); + assertThat(writer.writeToString(map)).isEqualTo(""" + {"a":"A","b":"B"}"""); + } + + @Test + void usingPairsWhenAlreadyDeclaredThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> JsonWriter + .of((members) -> members.add(Collections.emptyMap()).usingPairs(Map::forEach).usingPairs(Map::forEach))) + .withMessage("Pairs cannot be declared multiple times"); + } + + @Test + void usingPairsWhenUsingMembersThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> JsonWriter.of((members) -> members.add(Collections.emptyMap()) + .usingMembers((mapMembers) -> mapMembers.addSelf("test")) + .usingPairs(Map::forEach))) + .withMessage("Pairs cannot be declared when using members"); + } + + @Test + void usingMembers() { + Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20)); + JsonWriter writer = JsonWriter.of((member) -> { + member.add("personOne", Couple::person1).usingMembers((personMembers) -> { + personMembers.add("fn", Person::firstName); + personMembers.add("ln", Person::lastName); + }); + member.add("personTwo", Couple::person2).usingMembers((personMembers) -> { + personMembers.add("details", Person::toString); + personMembers.add("eldest", true); + }); + }); + assertThat(writer.writeToString(couple)).isEqualTo(""" + {"personOne":{"fn":"Spring","ln":"Boot"},""" + """ + "personTwo":{"details":"Spring Framework (20)","eldest":true}}"""); + } + + @Test + void usingMembersWithoutName() { + Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20)); + JsonWriter writer = JsonWriter.of((member) -> { + member.add("version", 1); + member.add(Couple::person1).usingMembers((personMembers) -> personMembers.add("one", Person::toString)); + member.add(Couple::person2).usingMembers((personMembers) -> personMembers.add("two", Person::toString)); + }); + assertThat(writer.writeToString(couple)).isEqualTo(""" + {"version":1,"one":"Spring Boot (10)","two":"Spring Framework (20)"}"""); + } + + @Test + void usingMembersWithoutNameInMember() { + Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20)); + JsonWriter writer = JsonWriter.of((member) -> member.add("only", Couple::person2) + .usingMembers((personMembers) -> personMembers.add(Person::toString))); + assertThat(writer.writeToString(couple)).isEqualTo(""" + {"only":"Spring Framework (20)"}"""); + } + + @Test + void usingMemebersWithoutNameAtAll() { + Couple couple = new Couple(PERSON, new Person("Spring", "Framework", 20)); + JsonWriter writer = JsonWriter.of((member) -> member.add(Couple::person2) + .usingMembers((personMembers) -> personMembers.add(Person::toString))); + assertThat(writer.writeToString(couple)).isEqualTo(quoted("Spring Framework (20)")); + } + + @Test + void usingMembersWhenAlreadyDeclaredThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> JsonWriter.of((members) -> members.add(Collections.emptyMap()) + .usingMembers((mapMembers) -> mapMembers.addSelf("test")) + .usingMembers((mapMembers) -> mapMembers.addSelf("test")))) + .withMessage("Members cannot be declared multiple times"); + } + + @Test + void usingMembersWhenUsingPairsThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> JsonWriter.of((members) -> members.add(Collections.emptyMap()) + .usingPairs(Map::forEach) + .usingMembers((mapMembers) -> mapMembers.addSelf("test")))) + .withMessage("Members cannot be declared when using pairs"); + } + + } + + @Nested + class WritableJsonTests { + + @Test + void toJsonStringReturnsString() { + WritableJson writable = (out) -> out.append("{}"); + assertThat(writable.toJsonString()).isEqualTo("{}"); + } + + @Test + void toJsonStringWhenIOExceptionIsThrownThrowsUncheckedIOException() { + WritableJson writable = (out) -> { + throw new IOException("bad"); + }; + assertThatExceptionOfType(UncheckedIOException.class).isThrownBy(() -> writable.toJsonString()) + .havingCause() + .withMessage("bad"); + } + + @Test + void toResourceWritesJson() throws Exception { + File file = new File(JsonWriterTests.this.temp, "out.json"); + WritableJson writable = (out) -> out.append("{}"); + writable.toResource(new FileSystemResource(file)); + assertThat(file).content().isEqualTo("{}"); + } + + @Test + void toResourceWithCharsetWritesJson() throws Exception { + File file = new File(JsonWriterTests.this.temp, "out.json"); + WritableJson writable = (out) -> out.append("{}"); + writable.toResource(new FileSystemResource(file), StandardCharsets.ISO_8859_1); + assertThat(file).content(StandardCharsets.ISO_8859_1).isEqualTo("{}"); + } + + @Test + void toResourceWithCharsetWhenOutIsNullThrowsException() { + WritableJson writable = (out) -> out.append("{}"); + assertThatIllegalArgumentException().isThrownBy(() -> writable.toResource(null, StandardCharsets.UTF_8)) + .withMessage("'out' must not be null"); + } + + @Test + void toResourceWithCharsetWhenCharsetIsNullThrowsException() { + File file = new File(JsonWriterTests.this.temp, "out.json"); + WritableJson writable = (out) -> out.append("{}"); + assertThatIllegalArgumentException() + .isThrownBy(() -> writable.toResource(new FileSystemResource(file), null)) + .withMessage("'charset' must not be null"); + } + + @Test + void toOutputStreamWritesJson() throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + WritableJson writable = (out) -> out.append("{}"); + writable.toOutputStream(outputStream); + assertThat(outputStream.toString(StandardCharsets.UTF_8)).isEqualTo("{}"); + } + + @Test + void toOutputStreamWithCharsetWritesJson() throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + WritableJson writable = (out) -> out.append("{}"); + writable.toOutputStream(outputStream, StandardCharsets.ISO_8859_1); + assertThat(outputStream.toString(StandardCharsets.ISO_8859_1)).isEqualTo("{}"); + } + + @Test + void toOutputStreamWithCharsetWhenOutIsNullThrowsException() { + WritableJson writable = (out) -> out.append("{}"); + assertThatIllegalArgumentException().isThrownBy(() -> writable.toOutputStream(null, StandardCharsets.UTF_8)) + .withMessage("'out' must not be null"); + } + + @Test + void toOutputStreamWithCharsetWhenCharsetIsNullThrowsException() { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + WritableJson writable = (out) -> out.append("{}"); + assertThatIllegalArgumentException().isThrownBy(() -> writable.toOutputStream(outputStream, null)) + .withMessage("'charset' must not be null"); + } + + // + + @Test + void toWriterWritesJson() throws Exception { + StringWriter writer = new StringWriter(); + WritableJson writable = (out) -> out.append("{}"); + writable.toWriter(writer); + assertThat(writer).hasToString("{}"); + } + + @Test + void toWriterWhenWriterIsNullThrowsException() { + WritableJson writable = (out) -> out.append("{}"); + assertThatIllegalArgumentException().isThrownBy(() -> writable.toWriter(null)) + .withMessage("'out' must not be null"); + } + + @Test + void ofReturnsInstanceWithSensibleToString() { + WritableJson writable = WritableJson.of((out) -> out.append("{}")); + assertThat(writable).hasToString("{}"); + } + + } + + record Person(String firstName, String lastName, int age) { + + @Override + public String toString() { + return "%s %s (%s)".formatted(this.firstName, this.lastName, this.age); + } + + } + + record Couple(Person person1, Person person2) { + + } + +}