diff --git a/spring-test/src/main/java/org/springframework/mock/web/server/MockServerWebExchange.java b/spring-test/src/main/java/org/springframework/mock/web/server/MockServerWebExchange.java index 8be9bfb93f..ca8a064888 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/server/MockServerWebExchange.java +++ b/spring-test/src/main/java/org/springframework/mock/web/server/MockServerWebExchange.java @@ -16,6 +16,8 @@ package org.springframework.mock.web.server; +import java.security.Principal; + import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; @@ -40,15 +42,19 @@ import org.springframework.web.server.session.WebSessionManager; */ public final class MockServerWebExchange extends DefaultServerWebExchange { + private final Mono principalMono; + private MockServerWebExchange( MockServerHttpRequest request, @Nullable WebSessionManager sessionManager, - @Nullable ApplicationContext applicationContext) { + @Nullable ApplicationContext applicationContext, @Nullable Principal principal) { super(request, new MockServerHttpResponse(), sessionManager != null ? sessionManager : new DefaultWebSessionManager(), ServerCodecConfigurer.create(), new AcceptHeaderLocaleContextResolver(), applicationContext); + + this.principalMono = (principal != null) ? Mono.just(principal) : Mono.empty(); } @@ -57,6 +63,16 @@ public final class MockServerWebExchange extends DefaultServerWebExchange { return (MockServerHttpResponse) super.getResponse(); } + /** + * Return the user set via {@link Builder#principal(Principal)}. + * @since 6.2.7 + */ + @SuppressWarnings("unchecked") + @Override + public Mono getPrincipal() { + return (Mono) this.principalMono; + } + /** * Create a {@link MockServerWebExchange} from the given mock request. @@ -107,8 +123,9 @@ public final class MockServerWebExchange extends DefaultServerWebExchange { private @Nullable WebSessionManager sessionManager; - @Nullable - private ApplicationContext applicationContext; + private @Nullable ApplicationContext applicationContext; + + private @Nullable Principal principal; public Builder(MockServerHttpRequest request) { this.request = request; @@ -146,11 +163,22 @@ public final class MockServerWebExchange extends DefaultServerWebExchange { return this; } + /** + * Provide a user to associate with the exchange. + * @param principal the principal to use + * @since 6.2.7 + */ + public Builder principal(@Nullable Principal principal) { + this.principal = principal; + return this; + } + /** * Build the {@code MockServerWebExchange} instance. */ public MockServerWebExchange build() { - return new MockServerWebExchange(this.request, this.sessionManager, this.applicationContext); + return new MockServerWebExchange( + this.request, this.sessionManager, this.applicationContext, this.principal); } } diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index eed353283a..bdf74a3053 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -85,7 +85,8 @@ public final class RestClientAdapter implements HttpExchangeAdapter { return newRequest(values).retrieve().toEntity(bodyType); } - private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { + @SuppressWarnings("unchecked") + private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -127,8 +128,14 @@ public final class RestClientAdapter implements HttpExchangeAdapter { bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); - if (values.getBodyValue() != null) { - bodySpec.body(values.getBodyValue()); + B body = (B) values.getBodyValue(); + if (body != null) { + if (values.getBodyValueType() != null) { + bodySpec.body(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.body(body); + } } return bodySpec; diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java index b431575b39..47c664fe51 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -86,7 +86,7 @@ public final class RestTemplateAdapter implements HttpExchangeAdapter { return this.restTemplate.exchange(newRequest(values), bodyType); } - private RequestEntity newRequest(HttpRequestValues values) { + private RequestEntity newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -120,11 +120,16 @@ public final class RestTemplateAdapter implements HttpExchangeAdapter { builder.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } - if (values.getBodyValue() != null) { - return builder.body(values.getBodyValue()); + Object body = values.getBodyValue(); + if (body == null) { + return builder.build(); } - return builder.build(); + if (values.getBodyValueType() != null) { + return builder.body(body, values.getBodyValueType().getType()); + } + + return builder.body(body); } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index ff93da6663..cb3b54d00a 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -26,6 +26,7 @@ import java.util.Map; import org.jspecify.annotations.Nullable; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -74,6 +75,8 @@ public class HttpRequestValues { private final @Nullable Object bodyValue; + private @Nullable ParameterizedTypeReference bodyValueType; + /** * Construct {@link HttpRequestValues}. @@ -176,6 +179,14 @@ public class HttpRequestValues { return this.bodyValue; } + /** + * Return the type for the {@linkplain #getBodyValue() body value}. + * @since 6.2.7 + */ + public @Nullable ParameterizedTypeReference getBodyValueType() { + return this.bodyValueType; + } + /** * Return a builder for {@link HttpRequestValues}. @@ -264,6 +275,8 @@ public class HttpRequestValues { private @Nullable Object bodyValue; + private @Nullable ParameterizedTypeReference bodyValueType; + protected Builder() { } @@ -417,6 +430,15 @@ public class HttpRequestValues { this.bodyValue = bodyValue; } + /** + * Variant of {@link #setBodyValue(Object)} with the body type. + * @since 6.2.7 + */ + public void setBodyValue(@Nullable Object bodyValue, @Nullable ParameterizedTypeReference valueType) { + setBodyValue(bodyValue); + this.bodyValueType = valueType; + } + // Implementation of {@link Metadata} methods @@ -489,9 +511,14 @@ public class HttpRequestValues { Map attributes = (this.attributes != null ? new HashMap<>(this.attributes) : Collections.emptyMap()); - return createRequestValues( + HttpRequestValues requestValues = createRequestValues( this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, headers, cookies, this.version, attributes, bodyValue); + + // In 6.2.x only, temporarily work around protected methods + requestValues.bodyValueType = this.bodyValueType; + + return requestValues; } protected boolean hasParts() { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java index 311a44aec9..18d383db05 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -83,15 +83,16 @@ public class RequestBodyArgumentResolver implements HttpServiceArgumentResolver if (this.reactiveAdapterRegistry != null) { ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); if (adapter != null) { - MethodParameter nestedParameter = parameter.nested(); + MethodParameter nestedParam = parameter.nested(); String message = "Async type for @RequestBody should produce value(s)"; Assert.isTrue(!adapter.isNoValue(), message); - Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); + Assert.isTrue(nestedParam.getNestedParameterType() != Void.class, message); - if (requestValues instanceof ReactiveHttpRequestValues.Builder reactiveRequestValues) { - reactiveRequestValues.setBodyPublisher( - adapter.toPublisher(argument), asParameterizedTypeRef(nestedParameter)); + if (requestValues instanceof ReactiveHttpRequestValues.Builder rrv) { + rrv.setBodyPublisher( + adapter.toPublisher(argument), + ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType())); } else { throw new IllegalStateException( @@ -103,12 +104,8 @@ public class RequestBodyArgumentResolver implements HttpServiceArgumentResolver } // Not a reactive type - requestValues.setBodyValue(argument); + requestValues.setBodyValue(argument, ParameterizedTypeReference.forType(parameter.getGenericParameterType())); return true; } - private static ParameterizedTypeReference asParameterizedTypeRef(MethodParameter nestedParam) { - return ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType()); - } - } diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index 5a7e51af3f..8704796894 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -22,7 +22,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.net.URI; +import java.util.LinkedHashSet; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -284,6 +286,19 @@ class RestClientAdapterTests { assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"); } + @ParameterizedAdapterTest // gh-34793 + void postSet(MockWebServer server, Service service) throws InterruptedException { + Set persons = new LinkedHashSet<>(); + persons.add(new Person("John")); + persons.add(new Person("Richard")); + service.postPersonSet(persons); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/persons"); + assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + } + private static MockWebServer anotherServer() { MockWebServer server = new MockWebServer(); @@ -317,6 +332,9 @@ class RestClientAdapterTests { @PostExchange void postMultipart(MultipartFile file, @RequestPart String anotherPart); + @PostExchange(url = "/persons", contentType = MediaType.APPLICATION_JSON_VALUE) + void postPersonSet(@RequestBody Set set); + @PutExchange void putWithCookies(@CookieValue String firstCookie, @CookieValue String secondCookie); @@ -335,4 +353,19 @@ class RestClientAdapterTests { ResponseEntity getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); } + + static final class Person { + + private final String name; + + Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java index 7c3822eb22..126e095e3b 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -54,6 +54,7 @@ class RequestBodyArgumentResolverTests { this.service.execute(body); assertThat(getBodyValue()).isEqualTo(body); + assertThat(getBodyValueType()).isEqualTo(new ParameterizedTypeReference() {}); assertThat(getPublisherBody()).isNull(); } @@ -172,6 +173,10 @@ class RequestBodyArgumentResolverTests { return getReactiveRequestValues().getBodyValue(); } + private @Nullable ParameterizedTypeReference getBodyValueType() { + return getReactiveRequestValues().getBodyValueType(); + } + private @Nullable Publisher getPublisherBody() { return getReactiveRequestValues().getBodyPublisher(); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java index 7e648dbd08..0961dd9706 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -98,8 +98,8 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { return newRequest(requestValues).retrieve().toEntityFlux(bodyType); } - @SuppressWarnings("ReactiveStreamsUnusedPublisher") - private WebClient.RequestBodySpec newRequest(HttpRequestValues values) { + @SuppressWarnings({"ReactiveStreamsUnusedPublisher", "unchecked"}) + private WebClient.RequestBodySpec newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -135,12 +135,18 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); if (values.getBodyValue() != null) { - bodySpec.bodyValue(values.getBodyValue()); + if (values.getBodyValueType() != null) { + B body = (B) values.getBodyValue(); + bodySpec.bodyValue(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.bodyValue(values.getBodyValue()); + } } - else if (values instanceof ReactiveHttpRequestValues reactiveRequestValues) { - Publisher body = reactiveRequestValues.getBodyPublisher(); + else if (values instanceof ReactiveHttpRequestValues rhrv) { + Publisher body = rhrv.getBodyPublisher(); if (body != null) { - ParameterizedTypeReference elementType = reactiveRequestValues.getBodyPublisherElementType(); + ParameterizedTypeReference elementType = rhrv.getBodyPublisherElementType(); Assert.notNull(elementType, "Publisher body element type is required"); bodySpec.body(body, elementType); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index b5661a49b8..c316e77908 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -21,7 +21,9 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import okhttp3.mockwebserver.MockResponse; @@ -39,6 +41,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -166,6 +169,22 @@ class WebClientAdapterTests { "Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2"); } + @Test // gh-34793 + void postSet() throws InterruptedException { + prepareResponse(response -> response.setResponseCode(201)); + + Set persons = new LinkedHashSet<>(); + persons.add(new Person("John")); + persons.add(new Person("Richard")); + + initService().postPersonSet(persons); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/persons"); + assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + } + @Test void uriBuilderFactory() throws Exception { String ignoredResponseBody = "hello"; @@ -249,6 +268,9 @@ class WebClientAdapterTests { @PostExchange void postMultipart(MultipartFile file, @RequestPart String anotherPart); + @PostExchange("/persons") + void postPersonSet(@RequestBody Set set); + @GetExchange("/greeting") String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory); @@ -260,4 +282,19 @@ class WebClientAdapterTests { String getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); } + + static final class Person { + + private final String name; + + Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + }