Skip to content

Commit 52fb2c3

Browse files
committed
Allow JsonPathExpectationsHelper to use a custom configuration
This commit improves JsonPathExpectationsHelper to allow a custom json path configuration to be defined. The primary objective of a custom configuration is to specify a custom mapper that can deserialize complex object structure. As part of this commit, it is now possible to invoke a Matcher against a type that holds generic information, using a regular ParameterizedTypeReference. Given that the existing constructor takes a vararg of Object, this commit also deprecates this constructor in favor of formatting the expression String upfront. Closes spring-projectsgh-31651
1 parent e0c5068 commit 52fb2c3

File tree

5 files changed

+175
-25
lines changed

5 files changed

+175
-25
lines changed

Diff for: spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java

+109-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,14 +16,21 @@
1616

1717
package org.springframework.test.util;
1818

19+
import java.lang.reflect.Type;
1920
import java.util.List;
2021
import java.util.Map;
22+
import java.util.function.Function;
2123

24+
import com.jayway.jsonpath.Configuration;
25+
import com.jayway.jsonpath.DocumentContext;
2226
import com.jayway.jsonpath.JsonPath;
27+
import com.jayway.jsonpath.TypeRef;
28+
import com.jayway.jsonpath.spi.mapper.MappingProvider;
2329
import org.hamcrest.CoreMatchers;
2430
import org.hamcrest.Matcher;
2531
import org.hamcrest.MatcherAssert;
2632

33+
import org.springframework.core.ParameterizedTypeReference;
2734
import org.springframework.lang.Nullable;
2835
import org.springframework.util.Assert;
2936
import org.springframework.util.ClassUtils;
@@ -40,6 +47,7 @@
4047
* @author Juergen Hoeller
4148
* @author Craig Andrews
4249
* @author Sam Brannen
50+
* @author Stephane Nicoll
4351
* @since 3.2
4452
*/
4553
public class JsonPathExpectationsHelper {
@@ -48,17 +56,42 @@ public class JsonPathExpectationsHelper {
4856

4957
private final JsonPath jsonPath;
5058

59+
private final Configuration configuration;
60+
61+
/**
62+
* Construct a new {@code JsonPathExpectationsHelper}.
63+
* @param expression the {@link JsonPath} expression; never {@code null} or empty
64+
* @param configuration the {@link Configuration} to use or {@code null} to use the
65+
* {@linkplain Configuration#defaultConfiguration() default configuration}
66+
* @since 6.2
67+
*/
68+
public JsonPathExpectationsHelper(String expression, @Nullable Configuration configuration) {
69+
Assert.hasText(expression, "expression must not be null or empty");
70+
this.expression = expression;
71+
this.jsonPath = JsonPath.compile(this.expression);
72+
this.configuration = (configuration != null) ? configuration : Configuration.defaultConfiguration();
73+
}
74+
75+
/**
76+
* Construct a new {@code JsonPathExpectationsHelper} using the
77+
* {@linkplain Configuration#defaultConfiguration() default configuration}.
78+
* @param expression the {@link JsonPath} expression; never {@code null} or empty
79+
* @since 6.2
80+
*/
81+
public JsonPathExpectationsHelper(String expression) {
82+
this(expression, (Configuration) null);
83+
}
5184

5285
/**
5386
* Construct a new {@code JsonPathExpectationsHelper}.
5487
* @param expression the {@link JsonPath} expression; never {@code null} or empty
5588
* @param args arguments to parameterize the {@code JsonPath} expression with,
5689
* using formatting specifiers defined in {@link String#format(String, Object...)}
90+
* @deprecated in favor of calling {@link String#formatted(Object...)} upfront
5791
*/
92+
@Deprecated(since = "6.2", forRemoval = true)
5893
public JsonPathExpectationsHelper(String expression, Object... args) {
59-
Assert.hasText(expression, "expression must not be null or empty");
60-
this.expression = String.format(expression, args);
61-
this.jsonPath = JsonPath.compile(this.expression);
94+
this(expression.formatted(args), (Configuration) null);
6295
}
6396

6497

@@ -83,9 +116,25 @@ public <T> void assertValue(String content, Matcher<? super T> matcher) {
83116
* @param targetType the expected type of the resulting value
84117
* @since 4.3.3
85118
*/
86-
@SuppressWarnings("unchecked")
87119
public <T> void assertValue(String content, Matcher<? super T> matcher, Class<T> targetType) {
88-
T value = (T) evaluateJsonPath(content, targetType);
120+
T value = evaluateJsonPath(content, targetType);
121+
MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher);
122+
}
123+
124+
/**
125+
* An overloaded variant of {@link #assertValue(String, Matcher)} that also
126+
* accepts a target type for the resulting value that allows generic types
127+
* to be defined.
128+
* <p>This must be used with a {@link Configuration} that defines a more
129+
* elaborate {@link MappingProvider} as the default one cannot handle
130+
* generic types.
131+
* @param content the JSON content
132+
* @param matcher the matcher with which to assert the result
133+
* @param targetType the expected type of the resulting value
134+
* @since 6.2
135+
*/
136+
public <T> void assertValue(String content, Matcher<? super T> matcher, ParameterizedTypeReference<T> targetType) {
137+
T value = evaluateJsonPath(content, targetType);
89138
MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher);
90139
}
91140

@@ -296,7 +345,7 @@ private String failureReason(String expectedDescription, @Nullable Object value)
296345
@Nullable
297346
public Object evaluateJsonPath(String content) {
298347
try {
299-
return this.jsonPath.read(content);
348+
return this.jsonPath.read(content, this.configuration);
300349
}
301350
catch (Throwable ex) {
302351
throw new AssertionError("No value at JSON path \"" + this.expression + "\"", ex);
@@ -306,19 +355,32 @@ public Object evaluateJsonPath(String content) {
306355
/**
307356
* Variant of {@link #evaluateJsonPath(String)} with a target type.
308357
* <p>This can be useful for matching numbers reliably for example coercing an
309-
* integer into a double.
358+
* integer into a double or when the configured {@link MappingProvider} can
359+
* handle more complex object structures.
310360
* @param content the content to evaluate against
361+
* @param targetType the requested target type
311362
* @return the result of the evaluation
312363
* @throws AssertionError if the evaluation fails
313364
*/
314-
public Object evaluateJsonPath(String content, Class<?> targetType) {
315-
try {
316-
return JsonPath.parse(content).read(this.expression, targetType);
317-
}
318-
catch (Throwable ex) {
319-
String message = "No value at JSON path \"" + this.expression + "\"";
320-
throw new AssertionError(message, ex);
321-
}
365+
public <T> T evaluateJsonPath(String content, Class<T> targetType) {
366+
return evaluateExpression(content, context -> context.read(this.expression, targetType));
367+
}
368+
369+
/**
370+
* Variant of {@link #evaluateJsonPath(String)} with a target type that has
371+
* generics.
372+
* <p>This must be used with a {@link Configuration} that defines a more
373+
* elaborate {@link MappingProvider} as the default one cannot handle
374+
* generic types.
375+
* @param content the content to evaluate against
376+
* @param targetType the requested target type
377+
* @return the result of the evaluation
378+
* @throws AssertionError if the evaluation fails
379+
* @since 6.2
380+
*/
381+
public <T> T evaluateJsonPath(String content, ParameterizedTypeReference<T> targetType) {
382+
return evaluateExpression(content, context ->
383+
context.read(this.expression, new TypeRefAdapter<>(targetType)));
322384
}
323385

324386
@Nullable
@@ -336,4 +398,35 @@ private boolean pathIsIndefinite() {
336398
return !this.jsonPath.isDefinite();
337399
}
338400

401+
402+
private <T> T evaluateExpression(String content, Function<DocumentContext, T> action) {
403+
try {
404+
DocumentContext context = JsonPath.parse(content, this.configuration);
405+
return action.apply(context);
406+
}
407+
catch (Throwable ex) {
408+
String message = "Failed to evaluate JSON path \"" + this.expression + "\"";
409+
throw new AssertionError(message, ex);
410+
}
411+
}
412+
413+
414+
/**
415+
* Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}.
416+
*/
417+
private static final class TypeRefAdapter<T> extends TypeRef<T> {
418+
419+
private final Type type;
420+
421+
TypeRefAdapter(ParameterizedTypeReference<T> typeReference) {
422+
this.type = typeReference.getType();
423+
}
424+
425+
@Override
426+
public Type getType() {
427+
return this.type;
428+
}
429+
430+
}
431+
339432
}

Diff for: spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
2626
import org.springframework.mock.http.client.MockClientHttpRequest;
2727
import org.springframework.test.util.JsonPathExpectationsHelper;
2828
import org.springframework.test.web.client.RequestMatcher;
29+
import org.springframework.util.Assert;
2930

3031
/**
3132
* Factory for assertions on the request content using
@@ -53,7 +54,8 @@ public class JsonPathRequestMatchers {
5354
* using formatting specifiers defined in {@link String#format(String, Object...)}
5455
*/
5556
protected JsonPathRequestMatchers(String expression, Object... args) {
56-
this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args);
57+
Assert.hasText(expression, "expression must not be null or empty");
58+
this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args));
5759
}
5860

5961

Diff for: spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@
2222

2323
import org.springframework.lang.Nullable;
2424
import org.springframework.test.util.JsonPathExpectationsHelper;
25+
import org.springframework.util.Assert;
2526

2627
/**
2728
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> assertions.
@@ -41,9 +42,10 @@ public class JsonPathAssertions {
4142

4243

4344
JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, Object... args) {
45+
Assert.hasText(expression, "expression must not be null or empty");
4446
this.bodySpec = spec;
4547
this.content = content;
46-
this.pathHelper = new JsonPathExpectationsHelper(expression, args);
48+
this.pathHelper = new JsonPathExpectationsHelper(expression.formatted(args));
4749
}
4850

4951

@@ -170,10 +172,9 @@ public <T> WebTestClient.BodyContentSpec value(Consumer<T> consumer) {
170172
* Consume the result of the JSONPath evaluation and provide a target class.
171173
* @since 5.1
172174
*/
173-
@SuppressWarnings("unchecked")
174175
public <T> WebTestClient.BodyContentSpec value(Consumer<T> consumer, Class<T> targetType) {
175-
Object value = this.pathHelper.evaluateJsonPath(this.content, targetType);
176-
consumer.accept((T) value);
176+
T value = this.pathHelper.evaluateJsonPath(this.content, targetType);
177+
consumer.accept(value);
177178
return this.bodySpec;
178179
}
179180

Diff for: spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@
2828
import org.springframework.test.util.JsonPathExpectationsHelper;
2929
import org.springframework.test.web.servlet.MvcResult;
3030
import org.springframework.test.web.servlet.ResultMatcher;
31+
import org.springframework.util.Assert;
3132
import org.springframework.util.StringUtils;
3233

3334
/**
@@ -60,7 +61,8 @@ public class JsonPathResultMatchers {
6061
* using formatting specifiers defined in {@link String#format(String, Object...)}
6162
*/
6263
protected JsonPathResultMatchers(String expression, Object... args) {
63-
this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args);
64+
Assert.hasText(expression, "expression must not be null or empty");
65+
this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args));
6466
}
6567

6668
/**

Diff for: spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java

+52
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,37 @@
1616

1717
package org.springframework.test.util;
1818

19+
import java.util.List;
20+
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.jayway.jsonpath.Configuration;
23+
import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
24+
import org.hamcrest.CoreMatchers;
1925
import org.junit.jupiter.api.Test;
2026
import org.junit.jupiter.params.ParameterizedTest;
2127
import org.junit.jupiter.params.provider.ValueSource;
2228

29+
import org.springframework.core.ParameterizedTypeReference;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
2332
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
33+
import static org.hamcrest.Matchers.hasItem;
34+
import static org.hamcrest.Matchers.hasSize;
2435
import static org.hamcrest.core.Is.is;
2536

2637
/**
2738
* Tests for {@link JsonPathExpectationsHelper}.
2839
*
2940
* @author Rossen Stoyanchev
3041
* @author Sam Brannen
42+
* @author Stephane Nicoll
3143
* @since 3.2
3244
*/
3345
class JsonPathExpectationsHelperTests {
3446

47+
private static final Configuration JACKSON_MAPPING_CONFIGURATION = Configuration.defaultConfiguration()
48+
.mappingProvider(new JacksonMappingProvider(new ObjectMapper()));
49+
3550
private static final String CONTENT = """
3651
{
3752
'str': 'foo',
@@ -324,4 +339,41 @@ void assertValueIsMapForNonMap() {
324339
.withMessageContaining("Expected a map at JSON path \"" + expression + "\" but found: 'foo'");
325340
}
326341

342+
@Test
343+
void assertValueWithComplexTypeFallbacksOnValueType() {
344+
new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION)
345+
.assertValue(SIMPSONS, new Member("Homer"));
346+
}
347+
348+
@Test
349+
void assertValueWithComplexTypeAndMatcher() {
350+
new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION)
351+
.assertValue(SIMPSONS, CoreMatchers.instanceOf(Member.class), Member.class);
352+
}
353+
354+
@Test
355+
void assertValueWithComplexGenericTypeAndMatcher() {
356+
JsonPathExpectationsHelper helper = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION);
357+
helper.assertValue(SIMPSONS, hasSize(5), new ParameterizedTypeReference<List<Member>>() {});
358+
helper.assertValue(SIMPSONS, hasItem(new Member("Lisa")), new ParameterizedTypeReference<List<Member>>() {});
359+
}
360+
361+
@Test
362+
void evaluateJsonPathWithClassType() {
363+
Member firstMember = new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION)
364+
.evaluateJsonPath(SIMPSONS, Member.class);
365+
assertThat(firstMember).isEqualTo(new Member("Homer"));
366+
}
367+
368+
@Test
369+
void evaluateJsonPathWithGenericType() {
370+
List<Member> family = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION)
371+
.evaluateJsonPath(SIMPSONS, new ParameterizedTypeReference<List<Member>>() {});
372+
assertThat(family).containsExactly(new Member("Homer"), new Member("Marge"),
373+
new Member("Bart"), new Member("Lisa"), new Member("Maggie"));
374+
}
375+
376+
377+
public record Member(String name) {}
378+
327379
}

0 commit comments

Comments
 (0)