Skip to content

Commit

Permalink
Configure jsonpath MappingProvider in WebTestClient
Browse files Browse the repository at this point in the history
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
  • Loading branch information
snicoll committed Feb 15, 2024
1 parent 9f80389 commit e73bbd4
Show file tree
Hide file tree
Showing 10 changed files with 481 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 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.
Expand Down Expand Up @@ -30,6 +30,8 @@
import java.util.function.Consumer;
import java.util.function.Function;

import com.jayway.jsonpath.Configuration;
import com.jayway.jsonpath.spi.mapper.MappingProvider;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.reactivestreams.Publisher;
Expand Down Expand Up @@ -57,6 +59,7 @@
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriBuilderFactory;

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

private final WiretapConnector wiretapConnector;

@Nullable
private final JsonEncoderDecoder jsonEncoderDecoder;

private final ExchangeFunction exchangeFunction;

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


DefaultWebTestClient(ClientHttpConnector connector,
DefaultWebTestClient(ClientHttpConnector connector, ExchangeStrategies exchangeStrategies,
Function<ClientHttpConnector, ExchangeFunction> exchangeFactory, UriBuilderFactory uriBuilderFactory,
@Nullable HttpHeaders headers, @Nullable MultiValueMap<String, String> cookies,
Consumer<EntityExchangeResult<?>> entityResultConsumer,
@Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) {

this.wiretapConnector = new WiretapConnector(connector);
this.jsonEncoderDecoder = JsonEncoderDecoder.from(
exchangeStrategies.messageWriters(), exchangeStrategies.messageReaders());
this.exchangeFunction = exchangeFactory.apply(this.wiretapConnector);
this.uriBuilderFactory = uriBuilderFactory;
this.defaultHeaders = headers;
Expand Down Expand Up @@ -362,6 +370,7 @@ public ResponseSpec exchange() {
this.requestId, this.uriTemplate, getResponseTimeout());

return new DefaultResponseSpec(result, response,
DefaultWebTestClient.this.jsonEncoderDecoder,
DefaultWebTestClient.this.entityResultConsumer, getResponseTimeout());
}

Expand Down Expand Up @@ -399,18 +408,23 @@ private static class DefaultResponseSpec implements ResponseSpec {

private final ClientResponse response;

@Nullable
private final JsonEncoderDecoder jsonEncoderDecoder;

private final Consumer<EntityExchangeResult<?>> entityResultConsumer;

private final Duration timeout;


DefaultResponseSpec(
ExchangeResult exchangeResult, ClientResponse response,
@Nullable JsonEncoderDecoder jsonEncoderDecoder,
Consumer<EntityExchangeResult<?>> entityResultConsumer,
Duration timeout) {

this.exchangeResult = exchangeResult;
this.response = response;
this.jsonEncoderDecoder = jsonEncoderDecoder;
this.entityResultConsumer = entityResultConsumer;
this.timeout = timeout;
}
Expand Down Expand Up @@ -466,7 +480,7 @@ public BodyContentSpec expectBody() {
ByteArrayResource resource = this.response.bodyToMono(ByteArrayResource.class).block(this.timeout);
byte[] body = (resource != null ? resource.getByteArray() : null);
EntityExchangeResult<byte[]> entityResult = initEntityExchangeResult(body);
return new DefaultBodyContentSpec(entityResult);
return new DefaultBodyContentSpec(entityResult, this.jsonEncoderDecoder);
}

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

private final EntityExchangeResult<byte[]> result;

@Nullable
private final JsonEncoderDecoder jsonEncoderDecoder;

private final boolean isEmpty;

DefaultBodyContentSpec(EntityExchangeResult<byte[]> result) {
DefaultBodyContentSpec(EntityExchangeResult<byte[]> result, @Nullable JsonEncoderDecoder jsonEncoderDecoder) {
this.result = result;
this.jsonEncoderDecoder = jsonEncoderDecoder;
this.isEmpty = (result.getResponseBody() == null || result.getResponseBody().length == 0);
}

Expand Down Expand Up @@ -666,8 +684,16 @@ public BodyContentSpec xml(String expectedXml) {
}

@Override
public JsonPathAssertions jsonPath(String expression) {
return new JsonPathAssertions(this, getBodyAsString(), expression,
JsonPathConfigurationProvider.getConfiguration(this.jsonEncoderDecoder));
}

@Override
@SuppressWarnings("removal")
public JsonPathAssertions jsonPath(String expression, Object... args) {
return new JsonPathAssertions(this, getBodyAsString(), expression, args);
Assert.hasText(expression, "expression must not be null or empty");
return jsonPath(expression.formatted(args));
}

@Override
Expand Down Expand Up @@ -697,4 +723,18 @@ public EntityExchangeResult<byte[]> returnResult() {
}
}


private static class JsonPathConfigurationProvider {

static Configuration getConfiguration(@Nullable JsonEncoderDecoder jsonEncoderDecoder) {
Configuration jsonPathConfiguration = Configuration.defaultConfiguration();
if (jsonEncoderDecoder != null) {
MappingProvider mappingProvider = new EncoderDecoderMappingProvider(
jsonEncoderDecoder.encoder(), jsonEncoderDecoder.decoder());
return jsonPathConfiguration.mappingProvider(mappingProvider);
}
return jsonPathConfiguration;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 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.
Expand Down Expand Up @@ -294,8 +294,9 @@ public WebTestClient build() {
if (connectorToUse == null) {
connectorToUse = initConnector();
}
ExchangeStrategies exchangeStrategies = initExchangeStrategies();
Function<ClientHttpConnector, ExchangeFunction> exchangeFactory = connector -> {
ExchangeFunction exchange = ExchangeFunctions.create(connector, initExchangeStrategies());
ExchangeFunction exchange = ExchangeFunctions.create(connector, exchangeStrategies);
if (CollectionUtils.isEmpty(this.filters)) {
return exchange;
}
Expand All @@ -305,7 +306,7 @@ public WebTestClient build() {
.orElse(exchange);

};
return new DefaultWebTestClient(connectorToUse, exchangeFactory, initUriBuilderFactory(),
return new DefaultWebTestClient(connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(),
this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null,
this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null,
this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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.web.reactive.server;

import java.util.Collections;
import java.util.Map;

import com.jayway.jsonpath.Configuration;
import com.jayway.jsonpath.TypeRef;
import com.jayway.jsonpath.spi.mapper.MappingProvider;

import org.springframework.core.ResolvableType;
import org.springframework.core.codec.Decoder;
import org.springframework.core.codec.Encoder;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.lang.Nullable;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;

/**
* JSON Path {@link MappingProvider} implementation using {@link Encoder}
* and {@link Decoder}.
*
* @author Rossen Stoyanchev
* @author Stephane Nicoll
* @since 6.2
*/
final class EncoderDecoderMappingProvider implements MappingProvider {

private final Encoder<?> encoder;

private final Decoder<?> decoder;

/**
* Create an instance with the specified writers and readers.
*/
public EncoderDecoderMappingProvider(Encoder<?> encoder, Decoder<?> decoder) {
this.encoder = encoder;
this.decoder = decoder;
}


@Nullable
@Override
public <T> T map(Object source, Class<T> targetType, Configuration configuration) {
return mapToTargetType(source, ResolvableType.forClass(targetType));
}

@Nullable
@Override
public <T> T map(Object source, TypeRef<T> targetType, Configuration configuration) {
return mapToTargetType(source, ResolvableType.forType(targetType.getType()));
}

@SuppressWarnings("unchecked")
@Nullable
private <T> T mapToTargetType(Object source, ResolvableType targetType) {
DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance;
MimeType mimeType = MimeTypeUtils.APPLICATION_JSON;
Map<String, Object> hints = Collections.emptyMap();

DataBuffer buffer = ((Encoder<T>) this.encoder).encodeValue(
(T) source, bufferFactory, ResolvableType.forInstance(source), mimeType, hints);

return ((Decoder<T>) this.decoder).decode(buffer, targetType, mimeType, hints);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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.web.reactive.server;

import java.util.Collection;
import java.util.Map;
import java.util.stream.Stream;

import org.springframework.core.ResolvableType;
import org.springframework.core.codec.Decoder;
import org.springframework.core.codec.Encoder;
import org.springframework.http.MediaType;
import org.springframework.http.codec.DecoderHttpMessageReader;
import org.springframework.http.codec.EncoderHttpMessageWriter;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.lang.Nullable;

/**
* {@link Encoder} and {@link Decoder} that is able to handle a map to and from
* json. Used to configure the jsonpath infrastructure without having a hard
* dependency on the library.
*
* @param encoder the json encoder
* @param decoder the json decoder
* @author Stephane Nicoll
* @author Rossen Stoyanchev
* @since 6.2
*/
record JsonEncoderDecoder(Encoder<?> encoder, Decoder<?> decoder) {

private static final ResolvableType MAP_TYPE = ResolvableType.forClass(Map.class);


/**
* Create a {@link JsonEncoderDecoder} instance based on the specified
* infrastructure.
* @param messageWriters the HTTP message writers
* @param messageReaders the HTTP message readers
* @return a {@link JsonEncoderDecoder} or {@code null} if a suitable codec
* is not available
*/
@Nullable
static JsonEncoderDecoder from(Collection<HttpMessageWriter<?>> messageWriters,
Collection<HttpMessageReader<?>> messageReaders) {

Encoder<?> jsonEncoder = findJsonEncoder(messageWriters);
Decoder<?> jsonDecoder = findJsonDecoder(messageReaders);
if (jsonEncoder != null && jsonDecoder != null) {
return new JsonEncoderDecoder(jsonEncoder, jsonDecoder);
}
return null;
}


/**
* Find the first suitable {@link Encoder} that can encode a {@link Map}
* to json.
* @param writers the writers to inspect
* @return a suitable json {@link Encoder} or {@code null}
*/
@Nullable
private static Encoder<?> findJsonEncoder(Collection<HttpMessageWriter<?>> writers) {
return findJsonEncoder(writers.stream()
.filter(writer -> writer instanceof EncoderHttpMessageWriter)
.map(writer -> ((EncoderHttpMessageWriter<?>) writer).getEncoder()));
}

@Nullable
private static Encoder<?> findJsonEncoder(Stream<Encoder<?>> stream) {
return stream
.filter(encoder -> encoder.canEncode(MAP_TYPE, MediaType.APPLICATION_JSON))
.findFirst()
.orElse(null);
}

/**
* Find the first suitable {@link Decoder} that can decode a {@link Map} to
* json.
* @param readers the readers to inspect
* @return a suitable json {@link Decoder} or {@code null}
*/
@Nullable
private static Decoder<?> findJsonDecoder(Collection<HttpMessageReader<?>> readers) {
return findJsonDecoder(readers.stream()
.filter(reader -> reader instanceof DecoderHttpMessageReader)
.map(reader -> ((DecoderHttpMessageReader<?>) reader).getDecoder()));
}

@Nullable
private static Decoder<?> findJsonDecoder(Stream<Decoder<?>> decoderStream) {
return decoderStream
.filter(decoder -> decoder.canDecode(MAP_TYPE, MediaType.APPLICATION_JSON))
.findFirst()
.orElse(null);
}

}
Loading

0 comments on commit e73bbd4

Please sign in to comment.