Update contribution

See gh-34081
This commit is contained in:
rstoyanchev 2025-02-12 11:26:34 +00:00
parent ba74de997a
commit 667004e5fa
8 changed files with 136 additions and 99 deletions

View File

@ -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<ResponseCookie> parse(String header);
/**
* Convenience method to parse a list of headers into a {@link MultiValueMap}.
*/
default MultiValueMap<String, ResponseCookie> parse(List<String> headers) {
MultiValueMap<String, ResponseCookie> 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[] {

View File

@ -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.
* <p>Default is {@code DefaultHttpCookieParser} based on {@code java.net.HttpCookie} capabilities</p>
* @param httpCookieParser
* Customize the parsing of response cookies.
* <p>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<String> headers = response.headers().allValues(HttpHeaders.SET_COOKIE);
return new JdkClientHttpResponse(response, this.bufferFactory, this.cookieParser.parse(headers));
});
}));
}

View File

@ -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<Flow.Publisher<List<ByteBuffer>>> response,
DataBufferFactory bufferFactory, HttpCookieParser httpCookieParser) {
DataBufferFactory bufferFactory, MultiValueMap<String, ResponseCookie> 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<String, ResponseCookie> adaptCookies(HttpResponse<Flow.Publisher<List<ByteBuffer>>> 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<DataBuffer> adaptBody(HttpResponse<Flow.Publisher<List<ByteBuffer>>> response, DataBufferFactory bufferFactory) {
return JdkFlowAdapter.flowPublisherToFlux(response.body())
.flatMapIterable(Function.identity())

View File

@ -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<ResponseCookie> parse(String header) {
Matcher matcher = SAME_SITE_PATTERN.matcher(header);
String sameSite = (matcher.matches() ? matcher.group(1) : null);
List<java.net.HttpCookie> cookies = java.net.HttpCookie.parse(header);
List<ResponseCookie> result = new ArrayList<>(cookies.size());
cookies.forEach(cookie -> result.add(ResponseCookie.from(cookie).sameSite(sameSite).build()));
return result;
}
}

View File

@ -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.
* <p>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<ClientHttpResponse> connect(HttpMethod method, URI uri,
Function<? super ClientHttpRequest, Mono<Void>> requestCallback) {
@ -121,7 +128,8 @@ public class JettyClientHttpConnector implements ClientHttpConnector {
return Mono.fromDirect(request.toReactiveRequest()
.response((reactiveResponse, chunkPublisher) -> {
Flux<DataBuffer> content = Flux.from(chunkPublisher).map(this.bufferFactory::wrap);
return Mono.just(new JettyClientHttpResponse(reactiveResponse, content, this.httpCookieParser));
List<String> headers = reactiveResponse.getHeaders().getValuesList(HttpHeaders.SET_COOKIE);
return Mono.just(new JettyClientHttpResponse(reactiveResponse, content, this.cookieParser.parse(headers)));
}));
}

View File

@ -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<DataBuffer> content, HttpCookieParser httpCookieParser) {
public JettyClientHttpResponse(
ReactiveResponse reactiveResponse, Flux<DataBuffer> content,
MultiValueMap<String, ResponseCookie> 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<String, String> headers = new JettyHeadersAdapter(response.getHeaders());
return HttpHeaders.readOnlyHttpHeaders(headers);
}
private static MultiValueMap<String, ResponseCookie> adaptCookies(ReactiveResponse response, HttpCookieParser httpCookieParser) {
List<HttpField> cookieHeaders = response.getHeaders().getFields(HttpHeaders.SET_COOKIE);
MultiValueMap<String, ResponseCookie> 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);
}
}

View File

@ -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<ResponseCookie> 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();
}
}

View File

@ -1,10 +0,0 @@
package org.springframework.http.support;
import org.springframework.http.ResponseCookie;
import java.util.stream.Stream;
public interface HttpCookieParser {
Stream<ResponseCookie> parse(String header);
}