Add support for JSON assertions using JSON path

This commit moves JSON path AssertJ support from Spring Boot.

See gh-21178

Co-authored-by: Brian Clozel <brian.clozel@broadcom.com>
This commit is contained in:
Stéphane Nicoll
2024-03-15 13:16:51 +01:00
parent 97ebc43ea9
commit 76f45c4289
5 changed files with 1103 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
/*
* Copyright 2002-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.test.json;
import java.lang.reflect.Array;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Map;
import org.assertj.core.api.AbstractBooleanAssert;
import org.assertj.core.api.AbstractMapAssert;
import org.assertj.core.api.AbstractObjectAssert;
import org.assertj.core.api.AbstractStringAssert;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.ObjectArrayAssert;
import org.assertj.core.error.BasicErrorMessageFactory;
import org.assertj.core.internal.Failures;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.MockHttpInputMessage;
import org.springframework.mock.http.MockHttpOutputMessage;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Base AssertJ {@link org.assertj.core.api.Assert assertions} that can be
* applied to a JSON value. In JSON, values must be one of the following data
* types:
* <ul>
* <li>a {@linkplain #asString() string}</li>
* <li>a {@linkplain #asNumber() number}</li>
* <li>a {@linkplain #asBoolean() boolean}</li>
* <li>an {@linkplain #asArray() array}</li>
* <li>an {@linkplain #asMap() object} (JSON object)</li>
* <li>{@linkplain #isNull() null}</li>
* </ul>
* This base class offers direct access for each of those types as well as a
* conversion methods based on an optional {@link GenericHttpMessageConverter}.
*
* @author Stephane Nicoll
* @since 6.2
* @param <SELF> the type of assertions
*/
public abstract class AbstractJsonValueAssert<SELF extends AbstractJsonValueAssert<SELF>>
extends AbstractObjectAssert<SELF, Object> {
private final Failures failures = Failures.instance();
@Nullable
private final GenericHttpMessageConverter<Object> httpMessageConverter;
protected AbstractJsonValueAssert(@Nullable Object actual, Class<?> selfType,
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
super(actual, selfType);
this.httpMessageConverter = httpMessageConverter;
}
/**
* Verify that the actual value is a non-{@code null} {@link String}
* and return a new {@linkplain AbstractStringAssert assertion} object that
* provides dedicated {@code String} assertions for it.
*/
@Override
public AbstractStringAssert<?> asString() {
return Assertions.assertThat(castTo(String.class, "a string"));
}
/**
* Verify that the actual value is a non-{@code null} {@link Number},
* usually an {@link Integer} or {@link Double} and return a new
* {@linkplain AbstractObjectAssert assertion} object for it.
*/
public AbstractObjectAssert<?, Number> asNumber() {
return Assertions.assertThat(castTo(Number.class, "a number"));
}
/**
* Verify that the actual value is a non-{@code null} {@link Boolean}
* and return a new {@linkplain AbstractBooleanAssert assertion} object
* that provides dedicated {@code Boolean} assertions for it.
*/
public AbstractBooleanAssert<?> asBoolean() {
return Assertions.assertThat(castTo(Boolean.class, "a boolean"));
}
/**
* Verify that the actual value is a non-{@code null} {@link Array}
* and return a new {@linkplain ObjectArrayAssert assertion} object
* that provides dedicated {@code Array} assertions for it.
*/
public ObjectArrayAssert<Object> asArray() {
List<?> list = castTo(List.class, "an array");
Object[] array = list.toArray(new Object[0]);
return Assertions.assertThat(array);
}
/**
* Verify that the actual value is a non-{@code null} JSON object and
* return a new {@linkplain AbstractMapAssert assertion} object that
* provides dedicated assertions on individual elements of the
* object. The returned map assertion object uses the attribute name as the
* key, and the value can itself be any of the valid JSON values.
*/
@SuppressWarnings("unchecked")
public AbstractMapAssert<?, Map<String, Object>, String, Object> asMap() {
return Assertions.assertThat(castTo(Map.class, "a map"));
}
private <T> T castTo(Class<T> expectedType, String description) {
if (this.actual == null) {
throw valueProcessingFailed("To be %s%n".formatted(description));
}
if (!expectedType.isInstance(this.actual)) {
throw valueProcessingFailed("To be %s%nBut was:%n %s%n".formatted(description, this.actual.getClass().getName()));
}
return expectedType.cast(this.actual);
}
/**
* Verify that the actual value can be converted to an instance of the
* given {@code target} and produce a new {@linkplain AbstractObjectAssert
* assertion} object narrowed to that type.
* @param target the {@linkplain Class type} to convert the actual value to
*/
public <T> AbstractObjectAssert<?, T> convertTo(Class<T> target) {
isNotNull();
T value = convertToTargetType(target);
return Assertions.assertThat(value);
}
/**
* Verify that the actual value can be converted to an instance of the
* given {@code target} and produce a new {@linkplain AbstractObjectAssert
* assertion} object narrowed to that type.
* @param target the {@linkplain ParameterizedTypeReference parameterized
* type} to convert the actual value to
*/
public <T> AbstractObjectAssert<?, T> convertTo(ParameterizedTypeReference<T> target) {
isNotNull();
T value = convertToTargetType(target.getType());
return Assertions.assertThat(value);
}
/**
* Verify that the actual value is empty, that is a {@code null} scalar
* value or an empty list or map. Can also be used when the path is using a
* filter operator to validate that it dit not match.
*/
public SELF isEmpty() {
if (!ObjectUtils.isEmpty(this.actual)) {
throw valueProcessingFailed("To be empty");
}
return this.myself;
}
/**
* Verify that the actual value is not empty, that is a non-{@code null}
* scalar value or a non-empty list or map. Can also be used when the path is
* using a filter operator to validate that it dit match at least one
* element.
*/
public SELF isNotEmpty() {
if (ObjectUtils.isEmpty(this.actual)) {
throw valueProcessingFailed("To not be empty");
}
return this.myself;
}
@SuppressWarnings("unchecked")
private <T> T convertToTargetType(Type targetType) {
if (this.httpMessageConverter == null) {
throw new IllegalStateException(
"No JSON message converter available to convert %s".formatted(actualToString()));
}
try {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
this.httpMessageConverter.write(this.actual, ResolvableType.forInstance(this.actual).getType(),
MediaType.APPLICATION_JSON, outputMessage);
return (T) this.httpMessageConverter.read(targetType, getClass(),
fromHttpOutputMessage(outputMessage));
}
catch (Exception ex) {
throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n"
.formatted(targetType.getTypeName(), ex.getMessage()));
}
}
private HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
inputMessage.getHeaders().addAll(message.getHeaders());
return inputMessage;
}
protected String getExpectedErrorMessagePrefix() {
return "Expected:";
}
private AssertionError valueProcessingFailed(String errorMessage) {
throw this.failures.failure(this.info, new ValueProcessingFailed(
getExpectedErrorMessagePrefix(), actualToString(), errorMessage));
}
private String actualToString() {
return ObjectUtils.nullSafeToString(StringUtils.quoteIfString(this.actual));
}
private static final class ValueProcessingFailed extends BasicErrorMessageFactory {
private ValueProcessingFailed(String prefix, String actualToString, String errorMessage) {
super("%n%s%n %s%n%s".formatted(prefix, actualToString, errorMessage));
}
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2002-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.test.json;
import java.util.function.Consumer;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.PathNotFoundException;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.AssertProvider;
import org.assertj.core.error.BasicErrorMessageFactory;
import org.assertj.core.internal.Failures;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied
* to a {@link CharSequence} representation of a json document using
* {@linkplain JsonPath JSON path}.
*
* @author Stephane Nicoll
* @since 6.2
*/
public class JsonPathAssert extends AbstractAssert<JsonPathAssert, CharSequence> {
private static final Failures failures = Failures.instance();
@Nullable
private final GenericHttpMessageConverter<Object> jsonMessageConverter;
public JsonPathAssert(CharSequence json,
@Nullable GenericHttpMessageConverter<Object> jsonMessageConverter) {
super(json, JsonPathAssert.class);
this.jsonMessageConverter = jsonMessageConverter;
}
/**
* Verify that the given JSON {@code path} is present and extract the JSON
* value for further {@linkplain JsonPathValueAssert assertions}.
* @param path the {@link JsonPath} expression
* @see #hasPathSatisfying(String, Consumer)
*/
public JsonPathValueAssert extractingPath(String path) {
Object value = new JsonPathValue(path).getValue();
return new JsonPathValueAssert(value, path, this.jsonMessageConverter);
}
/**
* Verify that the given JSON {@code path} is present with a JSON value
* satisfying the given {@code valueRequirements}.
* @param path the {@link JsonPath} expression
* @param valueRequirements a {@link Consumer} of the assertion object
*/
public JsonPathAssert hasPathSatisfying(String path, Consumer<AssertProvider<JsonPathValueAssert>> valueRequirements) {
Object value = new JsonPathValue(path).assertHasPath();
JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.jsonMessageConverter);
valueRequirements.accept(() -> valueAssert);
return this;
}
/**
* Verify that the given JSON {@code path} matches. For paths with an
* operator, this validates that the path expression is valid, but does not
* validate that it yield any results.
* @param path the {@link JsonPath} expression
*/
public JsonPathAssert hasPath(String path) {
new JsonPathValue(path).assertHasPath();
return this;
}
/**
* Verify that the given JSON {@code path} does not match.
* @param path the {@link JsonPath} expression
*/
public JsonPathAssert doesNotHavePath(String path) {
new JsonPathValue(path).assertDoesNotHavePath();
return this;
}
private AssertionError failure(BasicErrorMessageFactory errorMessageFactory) {
throw failures.failure(this.info, errorMessageFactory);
}
/**
* A {@link JsonPath} value.
*/
private class JsonPathValue {
private final String path;
private final JsonPath jsonPath;
private final String json;
JsonPathValue(String path) {
Assert.hasText(path, "'path' must not be null or empty");
this.path = path;
this.jsonPath = JsonPath.compile(this.path);
this.json = JsonPathAssert.this.actual.toString();
}
@Nullable
Object assertHasPath() {
return getValue();
}
void assertDoesNotHavePath() {
try {
read();
throw failure(new JsonPathNotExpected(this.json, this.path));
}
catch (PathNotFoundException ignore) {
}
}
@Nullable
Object getValue() {
try {
return read();
}
catch (PathNotFoundException ex) {
throw failure(new JsonPathNotFound(this.json, this.path));
}
}
@Nullable
private Object read() {
return this.jsonPath.read(this.json);
}
static final class JsonPathNotFound extends BasicErrorMessageFactory {
private JsonPathNotFound(String actual, String path) {
super("%nExpecting:%n %s%nTo match JSON path:%n %s%n", actual, path);
}
}
static final class JsonPathNotExpected extends BasicErrorMessageFactory {
private JsonPathNotExpected(String actual, String path) {
super("%nExpecting:%n %s%nTo not match JSON path:%n %s%n", actual, path);
}
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2002-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.test.json;
import com.jayway.jsonpath.JsonPath;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
/**
* AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied
* to a JSON value produced by evaluating a {@linkplain JsonPath JSON path}
* expression.
*
* @author Stephane Nicoll
* @since 6.2
*/
public class JsonPathValueAssert
extends AbstractJsonValueAssert<JsonPathValueAssert> {
private final String expression;
JsonPathValueAssert(@Nullable Object actual, String expression,
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
super(actual, JsonPathValueAssert.class, httpMessageConverter);
this.expression = expression;
}
@Override
protected String getExpectedErrorMessagePrefix() {
return "Expected value at JSON path \"%s\":".formatted(this.expression);
}
}