diff --git a/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java b/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java index 6bf523226e..65ec8d9258 100644 --- a/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java +++ b/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * 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. @@ -16,14 +16,21 @@ package org.springframework.test.util; +import java.lang.reflect.Type; import java.util.List; import java.util.Map; +import java.util.function.Function; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.TypeRef; +import com.jayway.jsonpath.spi.mapper.MappingProvider; import org.hamcrest.CoreMatchers; import org.hamcrest.Matcher; import org.hamcrest.MatcherAssert; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -40,6 +47,7 @@ import org.springframework.util.StringUtils; * @author Juergen Hoeller * @author Craig Andrews * @author Sam Brannen + * @author Stephane Nicoll * @since 3.2 */ public class JsonPathExpectationsHelper { @@ -48,17 +56,42 @@ public class JsonPathExpectationsHelper { private final JsonPath jsonPath; + private final Configuration configuration; + + /** + * Construct a new {@code JsonPathExpectationsHelper}. + * @param expression the {@link JsonPath} expression; never {@code null} or empty + * @param configuration the {@link Configuration} to use or {@code null} to use the + * {@linkplain Configuration#defaultConfiguration() default configuration} + * @since 6.2 + */ + public JsonPathExpectationsHelper(String expression, @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); + this.expression = expression; + this.jsonPath = JsonPath.compile(this.expression); + this.configuration = (configuration != null) ? configuration : Configuration.defaultConfiguration(); + } + + /** + * Construct a new {@code JsonPathExpectationsHelper} using the + * {@linkplain Configuration#defaultConfiguration() default configuration}. + * @param expression the {@link JsonPath} expression; never {@code null} or empty + * @since 6.2 + */ + public JsonPathExpectationsHelper(String expression) { + this(expression, (Configuration) null); + } /** * Construct a new {@code JsonPathExpectationsHelper}. * @param expression the {@link JsonPath} expression; never {@code null} or empty * @param args arguments to parameterize the {@code JsonPath} expression with, * using formatting specifiers defined in {@link String#format(String, Object...)} + * @deprecated in favor of calling {@link String#formatted(Object...)} upfront */ + @Deprecated(since = "6.2", forRemoval = true) public JsonPathExpectationsHelper(String expression, Object... args) { - Assert.hasText(expression, "expression must not be null or empty"); - this.expression = String.format(expression, args); - this.jsonPath = JsonPath.compile(this.expression); + this(expression.formatted(args), (Configuration) null); } @@ -83,9 +116,25 @@ public class JsonPathExpectationsHelper { * @param targetType the expected type of the resulting value * @since 4.3.3 */ - @SuppressWarnings("unchecked") public void assertValue(String content, Matcher matcher, Class targetType) { - T value = (T) evaluateJsonPath(content, targetType); + T value = evaluateJsonPath(content, targetType); + MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher); + } + + /** + * An overloaded variant of {@link #assertValue(String, Matcher)} that also + * accepts a target type for the resulting value that allows generic types + * to be defined. + *

This must be used with a {@link Configuration} that defines a more + * elaborate {@link MappingProvider} as the default one cannot handle + * generic types. + * @param content the JSON content + * @param matcher the matcher with which to assert the result + * @param targetType the expected type of the resulting value + * @since 6.2 + */ + public void assertValue(String content, Matcher matcher, ParameterizedTypeReference targetType) { + T value = evaluateJsonPath(content, targetType); MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher); } @@ -296,7 +345,7 @@ public class JsonPathExpectationsHelper { @Nullable public Object evaluateJsonPath(String content) { try { - return this.jsonPath.read(content); + return this.jsonPath.read(content, this.configuration); } catch (Throwable ex) { throw new AssertionError("No value at JSON path \"" + this.expression + "\"", ex); @@ -306,19 +355,32 @@ public class JsonPathExpectationsHelper { /** * Variant of {@link #evaluateJsonPath(String)} with a target type. *

This can be useful for matching numbers reliably for example coercing an - * integer into a double. + * integer into a double or when the configured {@link MappingProvider} can + * handle more complex object structures. * @param content the content to evaluate against + * @param targetType the requested target type * @return the result of the evaluation * @throws AssertionError if the evaluation fails */ - public Object evaluateJsonPath(String content, Class targetType) { - try { - return JsonPath.parse(content).read(this.expression, targetType); - } - catch (Throwable ex) { - String message = "No value at JSON path \"" + this.expression + "\""; - throw new AssertionError(message, ex); - } + public T evaluateJsonPath(String content, Class targetType) { + return evaluateExpression(content, context -> context.read(this.expression, targetType)); + } + + /** + * Variant of {@link #evaluateJsonPath(String)} with a target type that has + * generics. + *

This must be used with a {@link Configuration} that defines a more + * elaborate {@link MappingProvider} as the default one cannot handle + * generic types. + * @param content the content to evaluate against + * @param targetType the requested target type + * @return the result of the evaluation + * @throws AssertionError if the evaluation fails + * @since 6.2 + */ + public T evaluateJsonPath(String content, ParameterizedTypeReference targetType) { + return evaluateExpression(content, context -> + context.read(this.expression, new TypeRefAdapter<>(targetType))); } @Nullable @@ -336,4 +398,33 @@ public class JsonPathExpectationsHelper { return !this.jsonPath.isDefinite(); } + private T evaluateExpression(String content, Function action) { + try { + DocumentContext context = JsonPath.parse(content, this.configuration); + return action.apply(context); + } + catch (Throwable ex) { + String message = "Failed to evaluate JSON path \"" + this.expression + "\""; + throw new AssertionError(message, ex); + } + } + + + /** + * Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}. + */ + private static final class TypeRefAdapter extends TypeRef { + + private final Type type; + + TypeRefAdapter(ParameterizedTypeReference typeReference) { + this.type = typeReference.getType(); + } + + @Override + public Type getType() { + return this.type; + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java index 43714408aa..39e1d4bd57 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -26,6 +26,7 @@ import org.springframework.http.client.ClientHttpRequest; import org.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.test.util.JsonPathExpectationsHelper; import org.springframework.test.web.client.RequestMatcher; +import org.springframework.util.Assert; /** * Factory for assertions on the request content using @@ -53,7 +54,8 @@ public class JsonPathRequestMatchers { * using formatting specifiers defined in {@link String#format(String, Object...)} */ protected JsonPathRequestMatchers(String expression, Object... args) { - this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args); + Assert.hasText(expression, "expression must not be null or empty"); + this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args)); } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java index 0781f607c0..9bdbe7eefd 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java @@ -22,6 +22,7 @@ import org.hamcrest.Matcher; import org.springframework.lang.Nullable; import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.util.Assert; /** * JsonPath assertions. @@ -41,9 +42,10 @@ public class JsonPathAssertions { JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, Object... args) { + Assert.hasText(expression, "expression must not be null or empty"); this.bodySpec = spec; this.content = content; - this.pathHelper = new JsonPathExpectationsHelper(expression, args); + this.pathHelper = new JsonPathExpectationsHelper(expression.formatted(args)); } @@ -181,10 +183,9 @@ public class JsonPathAssertions { * Consume the result of the JSONPath evaluation and provide a target class. * @since 6.2 */ - @SuppressWarnings("unchecked") public WebTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { - Object value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept((T) value); + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); return this.bodySpec; } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java index a0d882e2d5..e84db5471d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * 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. @@ -28,6 +28,7 @@ import org.springframework.lang.Nullable; import org.springframework.test.util.JsonPathExpectationsHelper; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -60,7 +61,8 @@ public class JsonPathResultMatchers { * using formatting specifiers defined in {@link String#format(String, Object...)} */ protected JsonPathResultMatchers(String expression, Object... args) { - this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args); + Assert.hasText(expression, "expression must not be null or empty"); + this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args)); } /** diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index 73d84825d7..c61424039b 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -16,11 +16,22 @@ package org.springframework.test.util; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.Is.is; /** @@ -28,10 +39,14 @@ import static org.hamcrest.core.Is.is; * * @author Rossen Stoyanchev * @author Sam Brannen + * @author Stephane Nicoll * @since 3.2 */ class JsonPathExpectationsHelperTests { + private static final Configuration JACKSON_MAPPING_CONFIGURATION = Configuration.defaultConfiguration() + .mappingProvider(new JacksonMappingProvider(new ObjectMapper())); + private static final String CONTENT = """ { 'str': 'foo', @@ -324,4 +339,41 @@ class JsonPathExpectationsHelperTests { .withMessageContaining("Expected a map at JSON path \"" + expression + "\" but found: 'foo'"); } + @Test + void assertValueWithComplexTypeFallbacksOnValueType() { + new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .assertValue(SIMPSONS, new Member("Homer")); + } + + @Test + void assertValueWithComplexTypeAndMatcher() { + new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .assertValue(SIMPSONS, CoreMatchers.instanceOf(Member.class), Member.class); + } + + @Test + void assertValueWithComplexGenericTypeAndMatcher() { + JsonPathExpectationsHelper helper = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION); + helper.assertValue(SIMPSONS, hasSize(5), new ParameterizedTypeReference>() {}); + helper.assertValue(SIMPSONS, hasItem(new Member("Lisa")), new ParameterizedTypeReference>() {}); + } + + @Test + void evaluateJsonPathWithClassType() { + Member firstMember = new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .evaluateJsonPath(SIMPSONS, Member.class); + assertThat(firstMember).isEqualTo(new Member("Homer")); + } + + @Test + void evaluateJsonPathWithGenericType() { + List family = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION) + .evaluateJsonPath(SIMPSONS, new ParameterizedTypeReference>() {}); + assertThat(family).containsExactly(new Member("Homer"), new Member("Marge"), + new Member("Bart"), new Member("Lisa"), new Member("Maggie")); + } + + + public record Member(String name) {} + }