Add JsonWriter utility interface

Add `JsonWriter` utility interface that can be used to write JSON
without the need for a third-party library.

Closes gh-41489

Co-authored-by: Moritz Halbritter <moritz.halbritter@broadcom.com>
This commit is contained in:
Phillip Webb
2024-07-09 21:14:35 -07:00
parent bb8241fa8c
commit 20c2af13e3
4 changed files with 2048 additions and 0 deletions

View File

@@ -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> 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 <N> the name type in the pair
* @param <V> 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
*/
<N, V> 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:
* <ul>
* <li>Any {@code null} value</li>
* <li>A {@link WritableJson} instance</li>
* <li>Any {@link Iterable} or Array (written as a JSON array)</li>
* <li>A {@link Map} (written as a JSON object)</li>
* <li>Any {@link Number}</li>
* <li>A {@link Boolean}</li>
* </ul>
* All other values are written as JSON strings.
* @param <V> the value type
* @param value the value to write
* @on IO error
*/
<V> 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 <E> 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)
*/
<E> void writeArray(Consumer<Consumer<E>> 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 <E> the element type
* @param elements a callback that will be used to provide each element. Typically a
* {@code forEach} method reference.
* @see #writeElements(Consumer)
*/
<E> void writeElements(Consumer<Consumer<E>> elements) {
elements.accept(ThrowingConsumer.of(this::writeElement));
}
<E> 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 <N> the name type in the pair
* @param <V> 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)
*/
<N, V> void writeObject(Consumer<BiConsumer<N, V>> 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 <N> the name type in the pair
* @param <V> 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)
*/
<N, V> void writePairs(Consumer<BiConsumer<N, V>> pairs) {
pairs.accept(this::writePair);
}
private <N, V> 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;
}
}
}

View File

@@ -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.
* <p>
* 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:
*
* <pre class="code">
* JsonWriter&lt;Map&lt;String,Object&gt;&gt; writer = JsonWriter.standard();
* writer.write(Map.of("Hello", "World!"), out);
* </pre>
* <p>
* 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:
*
* <pre class="code">
* JsonWriter&lt;Person&gt; writer = JsonWriter.of((members) -&gt; {
* 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);
* </pre>
* <p>
* 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 <T> the type being written
* @author Phillip Webb
* @author Moritz Halbritter
* @since 3.4.0
*/
@FunctionalInterface
public interface JsonWriter<T> {
/**
* 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<T> 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<T> 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 <T> the type to write
* @return a {@link JsonWriter} instance
*/
static <T> JsonWriter<T> 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 <T> the type to write
* @param members a consumer which should configure the members
* @return a {@link JsonWriter} instance
* @see Members
*/
static <T> JsonWriter<T> of(Consumer<Members<T>> members) {
Members<T> 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.
* <p>
* Members can be added without a {@code name} when a {@code Member.using(...)} method
* is used to complete the definition.
* <p>
* Members can filtered using {@code Member.when} methods and adapted to different
* types using {@link Member#as(Function) Member.as(...)}.
*
* @param <T> the type that will be written
*/
final class Members<T> {
private final List<Member<?>> members = new ArrayList<>();
private final boolean contributesPair;
private final Series series;
Members(Consumer<Members<T>> 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<T> addSelf(String name) {
return add(name, (instance) -> instance);
}
/**
* Add a new member with a static value.
* @param <V> the value type
* @param name the member name
* @param value the member value
* @return the added {@link Member} which may be configured further
*/
public <V> Member<V> add(String name, V value) {
return add(name, (instance) -> value);
}
/**
* Add a new member with a supplied value.
* @param <V> 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 <V> Member<V> add(String name, Supplier<V> supplier) {
Assert.notNull(supplier, "'supplier' must not be null");
return add(name, (instance) -> supplier.get());
}
/**
* Add a new member with an extracted value.
* @param <V> 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 <V> Member<V> add(String name, Function<T, V> 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<T> 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 <V> the value type
* @param value the member value
* @return the added {@link Member} which may be configured further
*/
public <V> Member<V> 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 <V> the value type
* @param supplier a supplier of the value
* @return the added {@link Member} which may be configured further
*/
public <V> Member<V> add(Supplier<V> 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 <V> the value type
* @param extractor a function to extract the value
* @return the added {@link Member} which may be configured further
*/
public <V> Member<V> add(Function<T, V> 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 <M> the map type
* @param <K> the key type
* @param <V> the value type
* @param extractor a function to extract the map
* @return the added {@link Member} which may be configured further
*/
public <M extends Map<K, V>, K, V> Member<M> addMapEntries(Function<T, M> extractor) {
return add(extractor).usingPairs(Map::forEach);
}
private <V> Member<V> addMember(String name, Function<T, V> extractor) {
Member<V> member = new Member<>(this.members.size(), name, Extractor.of(extractor));
this.members.add(member);
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.
* <p>
* 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 <T> the member type
*/
final class Member<T> {
private final int index;
private final String name;
private Extractor<T> extractor;
private BiConsumer<T, BiConsumer<?, ?>> pairs;
private Members<T> members;
Member(int index, String name, Extractor<T> 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<T> 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<T> whenNotNull(Function<T, ?> 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<T> 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<T> 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<T> whenNot(Predicate<T> 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<T> when(Predicate<T> 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 <R> the result type
* @param adapter a {@link Function} to adapt the value
* @return a {@link Member} which may be configured further
*/
@SuppressWarnings("unchecked")
public <R> Member<R> as(Function<T, R> adapter) {
Assert.notNull(adapter, "'adapter' must not be null");
Member<R> result = (Member<R>) 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:
*
* <pre class="code">
* members.add(Event::getTags).usingExtractedPairs(Iterable::forEach, pairExtractor);
* </pre>
* <p>
* When used with a named member, the pairs will be added as a new JSON value
* object:
*
* <pre>
* {
* "name": {
* "p1": 1,
* "p2": 2
* }
* }
* </pre>
*
* When used with an unnamed member the pairs will be added to the existing JSON
* object:
*
* <pre>
* {
* "p1": 1,
* "p2": 2
* }
* </pre>
* @param <E> the element type
* @param <N> the name type
* @param <V> 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 <E, N, V> Member<T> usingExtractedPairs(BiConsumer<T, Consumer<E>> elements,
PairExtractor<E> 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:
*
* <pre class="code">
* members.add(Event::getTags).usingExtractedPairs(Iterable::forEach, Tag::getName, Tag::getValue);
* </pre>
* <p>
* When used with a named member, the pairs will be added as a new JSON value
* object:
*
* <pre>
* {
* "name": {
* "p1": 1,
* "p2": 2
* }
* }
* </pre>
*
* When used with an unnamed member the pairs will be added to the existing JSON
* object:
*
* <pre>
* {
* "p1": 1,
* "p2": 2
* }
* </pre>
* @param <E> the element type
* @param <N> the name type
* @param <V> 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 <E, N, V> Member<T> usingExtractedPairs(BiConsumer<T, Consumer<E>> elements,
Function<E, N> nameExtractor, Function<E, V> 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:
*
* <pre class="code">
* members.add(Event::getLabels).usingPairs(Map::forEach);
* </pre>
* <p>
* When used with a named member, the pairs will be added as a new JSON value
* object:
*
* <pre>
* {
* "name": {
* "p1": 1,
* "p2": 2
* }
* }
* </pre>
*
* When used with an unnamed member the pairs will be added to the existing JSON
* object:
*
* <pre>
* {
* "p1": 1,
* "p2": 2
* }
* </pre>
* @param <N> the name type
* @param <V> 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 <N, V> Member<T> usingPairs(BiConsumer<T, BiConsumer<N, V>> 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:
*
* <pre class="code">
* members.add(User::getName).usingMembers((personMembers) -> {
* personMembers.add("first", Name::first);
* personMembers.add("last", Name::last);
* });
* </pre>
*
* <p>
* When used with a named member, the result will be added as a new JSON value
* object:
*
* <pre>
* {
* "name": {
* "first": "Jane",
* "last": "Doe"
* }
* }
* </pre>
*
* When used with an unnamed member the result will be added to the existing JSON
* object:
*
* <pre>
* {
* "first": "John",
* "last": "Doe"
* }
* </pre>
* @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<T> usingMembers(Consumer<Members<T>> 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 <T> the member type
*/
@FunctionalInterface
interface Extractor<T> {
/**
* 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<T> when(Predicate<T> predicate) {
return (instance) -> test(extract(instance), predicate);
}
@SuppressWarnings("unchecked")
private T test(T extracted, Predicate<T> predicate) {
return (!skip(extracted) && predicate.test(extracted)) ? extracted : (T) SKIP;
}
/**
* Adapt the extracted value.
* @param <R> the result type
* @param adapter the adapter to use
* @return a new {@link Extractor}
*/
default <R> Extractor<R> as(Function<T, R> adapter) {
return (instance) -> apply(extract(instance), adapter);
}
@SuppressWarnings("unchecked")
private <R> R apply(T extracted, Function<T, R> 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 <S> the source type
* @param <T> the extracted type
* @param extractor the extractor to use
* @return a new {@link Extractor} instance
*/
@SuppressWarnings("unchecked")
static <S, T> Extractor<T> of(Function<S, T> extractor) {
return (instance) -> !skip(instance) ? extractor.apply((S) instance) : (T) SKIP;
}
/**
* Return if the extracted value should be skipped.
* @param <T> the value type
* @param extracted the value to test
* @return if the value is to be skipped
*/
static <T> boolean skip(T extracted) {
return extracted == SKIP;
}
}
}
/**
* Interface that can be used to extract name/value pairs from an element.
*
* @param <E> the element type
*/
interface PairExtractor<E> {
/**
* Extract the name.
* @param <N> the name type
* @param element the source element
* @return the extracted name
*/
<N> N getName(E element);
/**
* Extract the name.
* @param <V> the value type
* @param element the source element
* @return the extracted value
*/
<V> V getValue(E element);
/**
* Factory method to create a {@link PairExtractor} using distinct name and value
* extraction functions.
* @param <T> the element type
* @param nameExtractor the name extractor
* @param valueExtractor the value extraction
* @return a new {@link PairExtractor} instance
*/
static <T> PairExtractor<T> of(Function<T, ?> nameExtractor, Function<T, ?> 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> N getName(T instance) {
return (N) nameExtractor.apply(instance);
}
@Override
@SuppressWarnings("unchecked")
public <V> V getValue(T instance) {
return (V) valueExtractor.apply(instance);
}
};
}
}
}

View File

@@ -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<String> 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<String, String> map = new LinkedHashMap<>();
map.put("a", "A");
map.put("b", "B");
assertThat(write(map)).isEqualTo("""
{"a":"A","b":"B"}""");
}
@Test
void writeWhenMapWithNumericalKeys() {
Map<Integer, String> map = new LinkedHashMap<>();
map.put(1, "A");
map.put(2, "B");
assertThat(write(map)).isEqualTo("""
{"1":"A","2":"B"}""");
}
@Test
void writeWhenMapWithMixedValueTypes() {
Map<Object, Object> 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<String, String> 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<String> 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 <V> String write(V value) {
return doWrite((valueWriter) -> valueWriter.write(value));
}
private String doWrite(Consumer<JsonValueWriter> action) {
StringBuilder out = new StringBuilder();
action.accept(new JsonValueWriter(out));
return out.toString();
}
private String quoted(String string) {
return "\"" + string + "\"";
}
}

View File

@@ -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<Person> writer = JsonWriter.of((members) -> members.addSelf("test"));
assertThat(writer.writeToString(PERSON)).isEqualTo("""
{"test":"Spring Boot (10)"}""");
}
@Test
void ofAddingNamedValue() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.add("Spring", "Boot"));
assertThat(writer.writeToString(PERSON)).isEqualTo("""
{"Spring":"Boot"}""");
}
@Test
void ofAddingNamedSupplier() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.add("Spring", () -> "Boot"));
assertThat(writer.writeToString(PERSON)).isEqualTo("""
{"Spring":"Boot"}""");
}
@Test
void ofAddingUnamedSelf() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.addSelf());
assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Spring Boot (10)"));
}
@Test
void ofAddingUnamedValue() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.add("Boot"));
assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot"));
}
@Test
void ofAddingUnamedSupplier() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.add(() -> "Boot"));
assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot"));
}
@Test
void ofAddingUnamedExtractor() {
JsonWriter<Person> writer = JsonWriter.of((members) -> members.add(Person::lastName));
assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Boot"));
}
@Test
void ofAddingMapEntries() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("a", "A");
map.put("b", 123);
map.put("c", true);
JsonWriter<List<Map<String, Object>>> 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<Person> 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 <T> JsonWriter<T> 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 <T> String write(T instance) {
return JsonWriter.standard().writeToString(instance);
}
}
@Nested
class MemberTest {
@Test
void whenNotNull() {
JsonWriter<String> 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<Person> 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<String> 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<StringBuilder> 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<Object> 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<List<String>> 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<List<String>> 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<String> banned = Set.of("Spring", "Boot");
JsonWriter<String> 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<String> writer = JsonWriter.of((members) -> members.addSelf().as(Integer::valueOf));
assertThat(writer.writeToString("123")).isEqualTo("123");
}
@Test
void asWhenValueIsNullDoesNotCallAdapter() {
JsonWriter<String> writer = JsonWriter.of((members) -> members.addSelf().as((value) -> {
throw new RuntimeException("bad");
}));
writer.writeToString(null);
}
@Test
void chainedAs() {
Function<Integer, Boolean> booleanAdapter = (integer) -> integer != 0;
JsonWriter<String> 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<Integer, Boolean> booleanAdapter = (integer) -> integer != 0;
JsonWriter<String> 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<String, Object> map = new LinkedHashMap<>();
map.put("a", "A");
map.put("b", "B");
PairExtractor<Map.Entry<String, Object>> extractor = PairExtractor.of(Map.Entry::getKey,
Map.Entry::getValue);
JsonWriter<Map<String, Object>> 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<String, Object> map = new LinkedHashMap<>();
map.put("a", "A");
map.put("b", "B");
Function<Map.Entry<String, Object>, String> nameExtractor = Map.Entry::getKey;
Function<Map.Entry<String, Object>, Object> valueExtractor = Map.Entry::getValue;
JsonWriter<Map<String, Object>> 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<String, Object> map = new LinkedHashMap<>();
map.put("a", "A");
map.put("b", "B");
JsonWriter<Map<String, Object>> 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<Couple> 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<Couple> 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<Couple> 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<Couple> 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) {
}
}