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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Map<String,Object>> 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<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);
|
||||
* </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);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 + "\"";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user