Skip to content

Commit e73bbd4

Browse files
committed
Configure jsonpath MappingProvider in WebTestClient
This commit improves jsonpath support in WebTestClient by detecting a suitable json encoder/decoder that can be applied to assert more complex data structure. Closes gh-31653
1 parent 9f80389 commit e73bbd4

File tree

10 files changed

+481
-58
lines changed

10 files changed

+481
-58
lines changed

spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 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.
@@ -30,6 +30,8 @@
3030
import java.util.function.Consumer;
3131
import java.util.function.Function;
3232

33+
import com.jayway.jsonpath.Configuration;
34+
import com.jayway.jsonpath.spi.mapper.MappingProvider;
3335
import org.hamcrest.Matcher;
3436
import org.hamcrest.MatcherAssert;
3537
import org.reactivestreams.Publisher;
@@ -57,6 +59,7 @@
5759
import org.springframework.web.reactive.function.client.ClientRequest;
5860
import org.springframework.web.reactive.function.client.ClientResponse;
5961
import org.springframework.web.reactive.function.client.ExchangeFunction;
62+
import org.springframework.web.reactive.function.client.ExchangeStrategies;
6063
import org.springframework.web.util.UriBuilder;
6164
import org.springframework.web.util.UriBuilderFactory;
6265

@@ -72,6 +75,9 @@ class DefaultWebTestClient implements WebTestClient {
7275

7376
private final WiretapConnector wiretapConnector;
7477

78+
@Nullable
79+
private final JsonEncoderDecoder jsonEncoderDecoder;
80+
7581
private final ExchangeFunction exchangeFunction;
7682

7783
private final UriBuilderFactory uriBuilderFactory;
@@ -91,13 +97,15 @@ class DefaultWebTestClient implements WebTestClient {
9197
private final AtomicLong requestIndex = new AtomicLong();
9298

9399

94-
DefaultWebTestClient(ClientHttpConnector connector,
100+
DefaultWebTestClient(ClientHttpConnector connector, ExchangeStrategies exchangeStrategies,
95101
Function<ClientHttpConnector, ExchangeFunction> exchangeFactory, UriBuilderFactory uriBuilderFactory,
96102
@Nullable HttpHeaders headers, @Nullable MultiValueMap<String, String> cookies,
97103
Consumer<EntityExchangeResult<?>> entityResultConsumer,
98104
@Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) {
99105

100106
this.wiretapConnector = new WiretapConnector(connector);
107+
this.jsonEncoderDecoder = JsonEncoderDecoder.from(
108+
exchangeStrategies.messageWriters(), exchangeStrategies.messageReaders());
101109
this.exchangeFunction = exchangeFactory.apply(this.wiretapConnector);
102110
this.uriBuilderFactory = uriBuilderFactory;
103111
this.defaultHeaders = headers;
@@ -362,6 +370,7 @@ public ResponseSpec exchange() {
362370
this.requestId, this.uriTemplate, getResponseTimeout());
363371

364372
return new DefaultResponseSpec(result, response,
373+
DefaultWebTestClient.this.jsonEncoderDecoder,
365374
DefaultWebTestClient.this.entityResultConsumer, getResponseTimeout());
366375
}
367376

@@ -399,18 +408,23 @@ private static class DefaultResponseSpec implements ResponseSpec {
399408

400409
private final ClientResponse response;
401410

411+
@Nullable
412+
private final JsonEncoderDecoder jsonEncoderDecoder;
413+
402414
private final Consumer<EntityExchangeResult<?>> entityResultConsumer;
403415

404416
private final Duration timeout;
405417

406418

407419
DefaultResponseSpec(
408420
ExchangeResult exchangeResult, ClientResponse response,
421+
@Nullable JsonEncoderDecoder jsonEncoderDecoder,
409422
Consumer<EntityExchangeResult<?>> entityResultConsumer,
410423
Duration timeout) {
411424

412425
this.exchangeResult = exchangeResult;
413426
this.response = response;
427+
this.jsonEncoderDecoder = jsonEncoderDecoder;
414428
this.entityResultConsumer = entityResultConsumer;
415429
this.timeout = timeout;
416430
}
@@ -466,7 +480,7 @@ public BodyContentSpec expectBody() {
466480
ByteArrayResource resource = this.response.bodyToMono(ByteArrayResource.class).block(this.timeout);
467481
byte[] body = (resource != null ? resource.getByteArray() : null);
468482
EntityExchangeResult<byte[]> entityResult = initEntityExchangeResult(body);
469-
return new DefaultBodyContentSpec(entityResult);
483+
return new DefaultBodyContentSpec(entityResult, this.jsonEncoderDecoder);
470484
}
471485

472486
private <B> EntityExchangeResult<B> initEntityExchangeResult(@Nullable B body) {
@@ -625,10 +639,14 @@ private static class DefaultBodyContentSpec implements BodyContentSpec {
625639

626640
private final EntityExchangeResult<byte[]> result;
627641

642+
@Nullable
643+
private final JsonEncoderDecoder jsonEncoderDecoder;
644+
628645
private final boolean isEmpty;
629646

630-
DefaultBodyContentSpec(EntityExchangeResult<byte[]> result) {
647+
DefaultBodyContentSpec(EntityExchangeResult<byte[]> result, @Nullable JsonEncoderDecoder jsonEncoderDecoder) {
631648
this.result = result;
649+
this.jsonEncoderDecoder = jsonEncoderDecoder;
632650
this.isEmpty = (result.getResponseBody() == null || result.getResponseBody().length == 0);
633651
}
634652

@@ -666,8 +684,16 @@ public BodyContentSpec xml(String expectedXml) {
666684
}
667685

668686
@Override
687+
public JsonPathAssertions jsonPath(String expression) {
688+
return new JsonPathAssertions(this, getBodyAsString(), expression,
689+
JsonPathConfigurationProvider.getConfiguration(this.jsonEncoderDecoder));
690+
}
691+
692+
@Override
693+
@SuppressWarnings("removal")
669694
public JsonPathAssertions jsonPath(String expression, Object... args) {
670-
return new JsonPathAssertions(this, getBodyAsString(), expression, args);
695+
Assert.hasText(expression, "expression must not be null or empty");
696+
return jsonPath(expression.formatted(args));
671697
}
672698

673699
@Override
@@ -697,4 +723,18 @@ public EntityExchangeResult<byte[]> returnResult() {
697723
}
698724
}
699725

726+
727+
private static class JsonPathConfigurationProvider {
728+
729+
static Configuration getConfiguration(@Nullable JsonEncoderDecoder jsonEncoderDecoder) {
730+
Configuration jsonPathConfiguration = Configuration.defaultConfiguration();
731+
if (jsonEncoderDecoder != null) {
732+
MappingProvider mappingProvider = new EncoderDecoderMappingProvider(
733+
jsonEncoderDecoder.encoder(), jsonEncoderDecoder.decoder());
734+
return jsonPathConfiguration.mappingProvider(mappingProvider);
735+
}
736+
return jsonPathConfiguration;
737+
}
738+
}
739+
700740
}

spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 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.
@@ -294,8 +294,9 @@ public WebTestClient build() {
294294
if (connectorToUse == null) {
295295
connectorToUse = initConnector();
296296
}
297+
ExchangeStrategies exchangeStrategies = initExchangeStrategies();
297298
Function<ClientHttpConnector, ExchangeFunction> exchangeFactory = connector -> {
298-
ExchangeFunction exchange = ExchangeFunctions.create(connector, initExchangeStrategies());
299+
ExchangeFunction exchange = ExchangeFunctions.create(connector, exchangeStrategies);
299300
if (CollectionUtils.isEmpty(this.filters)) {
300301
return exchange;
301302
}
@@ -305,7 +306,7 @@ public WebTestClient build() {
305306
.orElse(exchange);
306307

307308
};
308-
return new DefaultWebTestClient(connectorToUse, exchangeFactory, initUriBuilderFactory(),
309+
return new DefaultWebTestClient(connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(),
309310
this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null,
310311
this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null,
311312
this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.web.reactive.server;
18+
19+
import java.util.Collections;
20+
import java.util.Map;
21+
22+
import com.jayway.jsonpath.Configuration;
23+
import com.jayway.jsonpath.TypeRef;
24+
import com.jayway.jsonpath.spi.mapper.MappingProvider;
25+
26+
import org.springframework.core.ResolvableType;
27+
import org.springframework.core.codec.Decoder;
28+
import org.springframework.core.codec.Encoder;
29+
import org.springframework.core.io.buffer.DataBuffer;
30+
import org.springframework.core.io.buffer.DataBufferFactory;
31+
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
32+
import org.springframework.lang.Nullable;
33+
import org.springframework.util.MimeType;
34+
import org.springframework.util.MimeTypeUtils;
35+
36+
/**
37+
* JSON Path {@link MappingProvider} implementation using {@link Encoder}
38+
* and {@link Decoder}.
39+
*
40+
* @author Rossen Stoyanchev
41+
* @author Stephane Nicoll
42+
* @since 6.2
43+
*/
44+
final class EncoderDecoderMappingProvider implements MappingProvider {
45+
46+
private final Encoder<?> encoder;
47+
48+
private final Decoder<?> decoder;
49+
50+
/**
51+
* Create an instance with the specified writers and readers.
52+
*/
53+
public EncoderDecoderMappingProvider(Encoder<?> encoder, Decoder<?> decoder) {
54+
this.encoder = encoder;
55+
this.decoder = decoder;
56+
}
57+
58+
59+
@Nullable
60+
@Override
61+
public <T> T map(Object source, Class<T> targetType, Configuration configuration) {
62+
return mapToTargetType(source, ResolvableType.forClass(targetType));
63+
}
64+
65+
@Nullable
66+
@Override
67+
public <T> T map(Object source, TypeRef<T> targetType, Configuration configuration) {
68+
return mapToTargetType(source, ResolvableType.forType(targetType.getType()));
69+
}
70+
71+
@SuppressWarnings("unchecked")
72+
@Nullable
73+
private <T> T mapToTargetType(Object source, ResolvableType targetType) {
74+
DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance;
75+
MimeType mimeType = MimeTypeUtils.APPLICATION_JSON;
76+
Map<String, Object> hints = Collections.emptyMap();
77+
78+
DataBuffer buffer = ((Encoder<T>) this.encoder).encodeValue(
79+
(T) source, bufferFactory, ResolvableType.forInstance(source), mimeType, hints);
80+
81+
return ((Decoder<T>) this.decoder).decode(buffer, targetType, mimeType, hints);
82+
}
83+
84+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.web.reactive.server;
18+
19+
import java.util.Collection;
20+
import java.util.Map;
21+
import java.util.stream.Stream;
22+
23+
import org.springframework.core.ResolvableType;
24+
import org.springframework.core.codec.Decoder;
25+
import org.springframework.core.codec.Encoder;
26+
import org.springframework.http.MediaType;
27+
import org.springframework.http.codec.DecoderHttpMessageReader;
28+
import org.springframework.http.codec.EncoderHttpMessageWriter;
29+
import org.springframework.http.codec.HttpMessageReader;
30+
import org.springframework.http.codec.HttpMessageWriter;
31+
import org.springframework.lang.Nullable;
32+
33+
/**
34+
* {@link Encoder} and {@link Decoder} that is able to handle a map to and from
35+
* json. Used to configure the jsonpath infrastructure without having a hard
36+
* dependency on the library.
37+
*
38+
* @param encoder the json encoder
39+
* @param decoder the json decoder
40+
* @author Stephane Nicoll
41+
* @author Rossen Stoyanchev
42+
* @since 6.2
43+
*/
44+
record JsonEncoderDecoder(Encoder<?> encoder, Decoder<?> decoder) {
45+
46+
private static final ResolvableType MAP_TYPE = ResolvableType.forClass(Map.class);
47+
48+
49+
/**
50+
* Create a {@link JsonEncoderDecoder} instance based on the specified
51+
* infrastructure.
52+
* @param messageWriters the HTTP message writers
53+
* @param messageReaders the HTTP message readers
54+
* @return a {@link JsonEncoderDecoder} or {@code null} if a suitable codec
55+
* is not available
56+
*/
57+
@Nullable
58+
static JsonEncoderDecoder from(Collection<HttpMessageWriter<?>> messageWriters,
59+
Collection<HttpMessageReader<?>> messageReaders) {
60+
61+
Encoder<?> jsonEncoder = findJsonEncoder(messageWriters);
62+
Decoder<?> jsonDecoder = findJsonDecoder(messageReaders);
63+
if (jsonEncoder != null && jsonDecoder != null) {
64+
return new JsonEncoderDecoder(jsonEncoder, jsonDecoder);
65+
}
66+
return null;
67+
}
68+
69+
70+
/**
71+
* Find the first suitable {@link Encoder} that can encode a {@link Map}
72+
* to json.
73+
* @param writers the writers to inspect
74+
* @return a suitable json {@link Encoder} or {@code null}
75+
*/
76+
@Nullable
77+
private static Encoder<?> findJsonEncoder(Collection<HttpMessageWriter<?>> writers) {
78+
return findJsonEncoder(writers.stream()
79+
.filter(writer -> writer instanceof EncoderHttpMessageWriter)
80+
.map(writer -> ((EncoderHttpMessageWriter<?>) writer).getEncoder()));
81+
}
82+
83+
@Nullable
84+
private static Encoder<?> findJsonEncoder(Stream<Encoder<?>> stream) {
85+
return stream
86+
.filter(encoder -> encoder.canEncode(MAP_TYPE, MediaType.APPLICATION_JSON))
87+
.findFirst()
88+
.orElse(null);
89+
}
90+
91+
/**
92+
* Find the first suitable {@link Decoder} that can decode a {@link Map} to
93+
* json.
94+
* @param readers the readers to inspect
95+
* @return a suitable json {@link Decoder} or {@code null}
96+
*/
97+
@Nullable
98+
private static Decoder<?> findJsonDecoder(Collection<HttpMessageReader<?>> readers) {
99+
return findJsonDecoder(readers.stream()
100+
.filter(reader -> reader instanceof DecoderHttpMessageReader)
101+
.map(reader -> ((DecoderHttpMessageReader<?>) reader).getDecoder()));
102+
}
103+
104+
@Nullable
105+
private static Decoder<?> findJsonDecoder(Stream<Decoder<?>> decoderStream) {
106+
return decoderStream
107+
.filter(decoder -> decoder.canDecode(MAP_TYPE, MediaType.APPLICATION_JSON))
108+
.findFirst()
109+
.orElse(null);
110+
}
111+
112+
}

0 commit comments

Comments
 (0)