diff --git a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java index 5539fc60cc6..0fe9b466816 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseCookie.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. @@ -17,10 +17,13 @@ package org.springframework.http; import java.time.Duration; +import java.util.List; import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -235,6 +238,22 @@ public final class ResponseCookie extends HttpCookie { return new DefaultResponseCookieBuilder(name, value, true); } + /** + * Factory method to obtain a builder that copies from {@link java.net.HttpCookie}. + * @param cookie the source cookie to copy from + * @return a builder to create the cookie with + * @since 7.0 + */ + public static ResponseCookieBuilder from(java.net.HttpCookie cookie) { + return ResponseCookie.from(cookie.getName(), cookie.getValue()) + .domain(cookie.getDomain()) + .httpOnly(cookie.isHttpOnly()) + .maxAge(cookie.getMaxAge()) + .path(cookie.getPath()) + .secure(cookie.getSecure()); + } + + /** * A builder for a server-defined HttpCookie with attributes. @@ -307,6 +326,32 @@ public final class ResponseCookie extends HttpCookie { } + /** + * Contract to parse {@code "Set-Cookie"} headers. + * @since 7.0 + */ + public interface Parser { + + /** + * Parse the given header. + */ + List parse(String header); + + /** + * Convenience method to parse a list of headers into a {@link MultiValueMap}. + */ + default MultiValueMap parse(List headers) { + MultiValueMap result = new LinkedMultiValueMap<>(); + for (String header : headers) { + for (ResponseCookie cookie : parse(header)) { + result.add(cookie.getName(), cookie); + } + } + return result; + } + } + + private static class Rfc6265Utils { private static final String SEPARATOR_CHARS = new String(new char[] { diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java index 194d572fd6f..72f846f6814 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 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. @@ -33,9 +33,9 @@ import reactor.core.publisher.Mono; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.support.DefaultHttpCookieParser; -import org.springframework.http.support.HttpCookieParser; +import org.springframework.http.ResponseCookie; import org.springframework.util.Assert; /** @@ -52,10 +52,10 @@ public class JdkClientHttpConnector implements ClientHttpConnector { private DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; - private HttpCookieParser httpCookieParser = new DefaultHttpCookieParser(); - private @Nullable Duration readTimeout; + private ResponseCookie.Parser cookieParser = new JdkResponseCookieParser(); + /** * Default constructor that uses {@link HttpClient#newHttpClient()}. @@ -110,13 +110,15 @@ public class JdkClientHttpConnector implements ClientHttpConnector { } /** - * Set the {@code HttpCookieParser} to be used in response parsing. - *

Default is {@code DefaultHttpCookieParser} based on {@code java.net.HttpCookie} capabilities

- * @param httpCookieParser + * Customize the parsing of response cookies. + *

By default, {@link java.net.HttpCookie#parse(String)} is used, and + * additionally the sameSite attribute is parsed and set. + * @param parser the parser to use + * @since 7.0 */ - public void setHttpCookieParser(HttpCookieParser httpCookieParser) { - Assert.notNull(readTimeout, "httpCookieParser is required"); - this.httpCookieParser = httpCookieParser; + public void setCookieParser(ResponseCookie.Parser parser) { + Assert.notNull(parser, "ResponseCookie parser is required"); + this.cookieParser = parser; } @@ -134,7 +136,10 @@ public class JdkClientHttpConnector implements ClientHttpConnector { this.httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofPublisher()); return Mono.fromCompletionStage(future) - .map(response -> new JdkClientHttpResponse(response, this.bufferFactory, this.httpCookieParser)); + .map(response -> { + List headers = response.headers().allValues(HttpHeaders.SET_COOKIE); + return new JdkClientHttpResponse(response, this.bufferFactory, this.cookieParser.parse(headers)); + }); })); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpResponse.java index ab60012f2da..b32d132c6c7 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpResponse.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. @@ -34,10 +34,8 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; -import org.springframework.http.support.HttpCookieParser; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedCaseInsensitiveMap; -import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; /** @@ -50,12 +48,10 @@ import org.springframework.util.MultiValueMap; class JdkClientHttpResponse extends AbstractClientHttpResponse { public JdkClientHttpResponse(HttpResponse>> response, - DataBufferFactory bufferFactory, HttpCookieParser httpCookieParser) { + DataBufferFactory bufferFactory, MultiValueMap cookies) { super(HttpStatusCode.valueOf(response.statusCode()), - adaptHeaders(response), - adaptCookies(response, httpCookieParser), - adaptBody(response, bufferFactory) + adaptHeaders(response), cookies, adaptBody(response, bufferFactory) ); } @@ -67,15 +63,6 @@ class JdkClientHttpResponse extends AbstractClientHttpResponse { return HttpHeaders.readOnlyHttpHeaders(multiValueMap); } - private static MultiValueMap adaptCookies(HttpResponse>> response, - HttpCookieParser httpCookieParser) { - return response.headers().allValues(HttpHeaders.SET_COOKIE).stream() - .flatMap(httpCookieParser::parse) - .collect(LinkedMultiValueMap::new, - (cookies, cookie) -> cookies.add(cookie.getName(), cookie), - LinkedMultiValueMap::addAll); - } - private static Flux adaptBody(HttpResponse>> response, DataBufferFactory bufferFactory) { return JdkFlowAdapter.flowPublisherToFlux(response.body()) .flatMapIterable(Function.identity()) diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java new file mode 100644 index 00000000000..dca1b0528c5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java @@ -0,0 +1,51 @@ +/* + * 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. + * 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.http.client.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.http.ResponseCookie; + + +/** + * Parser that delegates to {@link java.net.HttpCookie#parse(String)} for parsing, + * but also extracts and sets {@code sameSite}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +final class JdkResponseCookieParser implements ResponseCookie.Parser { + + private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*"); + + + /** + * Parse the given headers. + */ + public List parse(String header) { + Matcher matcher = SAME_SITE_PATTERN.matcher(header); + String sameSite = (matcher.matches() ? matcher.group(1) : null); + List cookies = java.net.HttpCookie.parse(header); + List result = new ArrayList<>(cookies.size()); + cookies.forEach(cookie -> result.add(ResponseCookie.from(cookie).sameSite(sameSite).build())); + return result; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java index c341a87cf1a..dc1c6a7e34d 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java @@ -17,6 +17,7 @@ package org.springframework.http.client.reactive; import java.net.URI; +import java.util.List; import java.util.function.Function; import org.eclipse.jetty.client.HttpClient; @@ -27,9 +28,9 @@ import reactor.core.publisher.Mono; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.JettyDataBufferFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.support.DefaultHttpCookieParser; -import org.springframework.http.support.HttpCookieParser; +import org.springframework.http.ResponseCookie; import org.springframework.util.Assert; /** @@ -45,7 +46,7 @@ public class JettyClientHttpConnector implements ClientHttpConnector { private JettyDataBufferFactory bufferFactory = new JettyDataBufferFactory(); - private HttpCookieParser httpCookieParser = new DefaultHttpCookieParser(); + private ResponseCookie.Parser cookieParser = new JdkResponseCookieParser(); /** @@ -88,12 +89,18 @@ public class JettyClientHttpConnector implements ClientHttpConnector { } /** - * Set the cookie parser to use. + * Customize the parsing of response cookies. + *

By default, {@link java.net.HttpCookie#parse(String)} is used, and + * additionally the sameSite attribute is parsed and set. + * @param parser the parser to use + * @since 7.0 */ - public void setHttpCookieParser(HttpCookieParser httpCookieParser) { - this.httpCookieParser = httpCookieParser; + public void setCookieParser(ResponseCookie.Parser parser) { + Assert.notNull(parser, "ResponseCookie parser is required"); + this.cookieParser = parser; } + @Override public Mono connect(HttpMethod method, URI uri, Function> requestCallback) { @@ -121,7 +128,8 @@ public class JettyClientHttpConnector implements ClientHttpConnector { return Mono.fromDirect(request.toReactiveRequest() .response((reactiveResponse, chunkPublisher) -> { Flux content = Flux.from(chunkPublisher).map(this.bufferFactory::wrap); - return Mono.just(new JettyClientHttpResponse(reactiveResponse, content, this.httpCookieParser)); + List headers = reactiveResponse.getHeaders().getValuesList(HttpHeaders.SET_COOKIE); + return Mono.just(new JettyClientHttpResponse(reactiveResponse, content, this.cookieParser.parse(headers))); })); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpResponse.java index b69e959a622..f4be0618e00 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpResponse.java @@ -16,9 +16,6 @@ package org.springframework.http.client.reactive; -import java.util.List; - -import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.reactive.client.ReactiveResponse; import reactor.core.publisher.Flux; @@ -26,10 +23,7 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; -import org.springframework.http.support.HttpCookieParser; import org.springframework.http.support.JettyHeadersAdapter; -import org.springframework.util.CollectionUtils; -import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; /** @@ -42,26 +36,16 @@ import org.springframework.util.MultiValueMap; */ class JettyClientHttpResponse extends AbstractClientHttpResponse { - public JettyClientHttpResponse(ReactiveResponse reactiveResponse, Flux content, HttpCookieParser httpCookieParser) { + public JettyClientHttpResponse( + ReactiveResponse reactiveResponse, Flux content, + MultiValueMap cookies) { - super(HttpStatusCode.valueOf(reactiveResponse.getStatus()), - adaptHeaders(reactiveResponse), - adaptCookies(reactiveResponse, httpCookieParser), - content); + super(HttpStatusCode.valueOf(reactiveResponse.getStatus()), adaptHeaders(reactiveResponse), cookies, content); } private static HttpHeaders adaptHeaders(ReactiveResponse response) { MultiValueMap headers = new JettyHeadersAdapter(response.getHeaders()); return HttpHeaders.readOnlyHttpHeaders(headers); } - private static MultiValueMap adaptCookies(ReactiveResponse response, HttpCookieParser httpCookieParser) { - List cookieHeaders = response.getHeaders().getFields(HttpHeaders.SET_COOKIE); - MultiValueMap result = cookieHeaders.stream() - .flatMap(header -> httpCookieParser.parse(header.getValue())) - .collect(LinkedMultiValueMap::new, - (cookies, cookie) -> cookies.add(cookie.getName(), cookie), - LinkedMultiValueMap::addAll); - return CollectionUtils.unmodifiableMultiValueMap(result); - } } diff --git a/spring-web/src/main/java/org/springframework/http/support/DefaultHttpCookieParser.java b/spring-web/src/main/java/org/springframework/http/support/DefaultHttpCookieParser.java deleted file mode 100644 index 7fcea7402db..00000000000 --- a/spring-web/src/main/java/org/springframework/http/support/DefaultHttpCookieParser.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.springframework.http.support; - -import org.springframework.http.ResponseCookie; - -import java.net.HttpCookie; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -import org.jspecify.annotations.Nullable; - -public final class DefaultHttpCookieParser implements HttpCookieParser { - - private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*"); - - @Override - public Stream parse(String header) { - Matcher matcher = SAME_SITE_PATTERN.matcher(header); - String sameSite = (matcher.matches() ? matcher.group(1) : null); - return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite)); - } - - private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite) { - return ResponseCookie.from(cookie.getName(), cookie.getValue()) - .domain(cookie.getDomain()) - .httpOnly(cookie.isHttpOnly()) - .maxAge(cookie.getMaxAge()) - .path(cookie.getPath()) - .secure(cookie.getSecure()) - .sameSite(sameSite) - .build(); - } -} diff --git a/spring-web/src/main/java/org/springframework/http/support/HttpCookieParser.java b/spring-web/src/main/java/org/springframework/http/support/HttpCookieParser.java deleted file mode 100644 index e9e0703eab9..00000000000 --- a/spring-web/src/main/java/org/springframework/http/support/HttpCookieParser.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.springframework.http.support; - -import org.springframework.http.ResponseCookie; - -import java.util.stream.Stream; - -public interface HttpCookieParser { - - Stream parse(String header); -}