WebFlux supports HTTP HEAD

Issue: SPR-15994
This commit is contained in:
Rossen Stoyanchev 2017-09-27 14:57:56 -04:00
parent d8d74faab8
commit 6ee1af27c6
10 changed files with 153 additions and 35 deletions

View File

@ -36,7 +36,9 @@ import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpResponse; import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.HttpHeadResponseDecorator;
import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest; import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse; import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
@ -84,7 +86,8 @@ public class HttpHandlerConnector implements ClientHttpConnector {
mockClientRequest.setWriteHandler(requestBody -> { mockClientRequest.setWriteHandler(requestBody -> {
log("Invoking HttpHandler for ", httpMethod, uri); log("Invoking HttpHandler for ", httpMethod, uri);
ServerHttpRequest mockServerRequest = adaptRequest(mockClientRequest, requestBody); ServerHttpRequest mockServerRequest = adaptRequest(mockClientRequest, requestBody);
this.handler.handle(mockServerRequest, mockServerResponse).subscribe(aVoid -> {}, result::onError); ServerHttpResponse responseToUse = prepareResponse(mockServerResponse, mockServerRequest);
this.handler.handle(mockServerRequest, responseToUse).subscribe(aVoid -> {}, result::onError);
return Mono.empty(); return Mono.empty();
}); });
@ -114,6 +117,11 @@ public class HttpHandlerConnector implements ClientHttpConnector {
return MockServerHttpRequest.method(method, uri).headers(headers).cookies(cookies).body(body); return MockServerHttpRequest.method(method, uri).headers(headers).cookies(cookies).body(body);
} }
private ServerHttpResponse prepareResponse(ServerHttpResponse response, ServerHttpRequest request) {
return HttpMethod.HEAD.equals(request.getMethod()) ?
new HttpHeadResponseDecorator(response) : response;
}
private ClientHttpResponse adaptResponse(MockServerHttpResponse response, Flux<DataBuffer> body) { private ClientHttpResponse adaptResponse(MockServerHttpResponse response, Flux<DataBuffer> body) {
HttpStatus status = Optional.ofNullable(response.getStatusCode()).orElse(HttpStatus.OK); HttpStatus status = Optional.ofNullable(response.getStatusCode()).orElse(HttpStatus.OK);
MockClientHttpResponse clientResponse = new MockClientHttpResponse(status); MockClientHttpResponse clientResponse = new MockClientHttpResponse(status);

View File

@ -0,0 +1,75 @@
/*
* Copyright 2002-2017 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
*
* http://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.server.reactive;
import java.util.function.BiFunction;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
/**
* {@link ServerHttpResponse} decorator for HTTP HEAD requests.
*
* @author Rossen Stoyanchev
* @since 5.0
*/
public class HttpHeadResponseDecorator extends ServerHttpResponseDecorator {
public HttpHeadResponseDecorator(ServerHttpResponse delegate) {
super(delegate);
}
/**
* Apply {@link Flux#reduce(Object, BiFunction) reduce} on the body, count
* the number of bytes produced, release data buffers without writing, and
* set the {@literal Content-Length} header.
*/
@Override
public final Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
// After Reactor Netty #171 is fixed we can return without delegating
return getDelegate().writeWith(
Flux.from(body)
.reduce(0, (current, buffer) -> {
int next = current + buffer.readableByteCount();
DataBufferUtils.release(buffer);
return next;
})
.doOnNext(count -> getHeaders().setContentLength(count))
.then(Mono.empty()));
}
/**
* Invoke {@link #setComplete()} without writing.
*
* <p>RFC 7302 allows HTTP HEAD response without content-length and it's not
* something that can be computed on a streaming response.
*/
@Override
public final Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
// Not feasible to count bytes on potentially streaming response.
// RFC 7302 allows HEAD without content-length.
return setComplete();
}
}

View File

@ -27,6 +27,7 @@ import reactor.ipc.netty.http.server.HttpServerResponse;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.springframework.core.io.buffer.NettyDataBufferFactory; import org.springframework.core.io.buffer.NettyDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.util.Assert; import org.springframework.util.Assert;
/** /**
@ -54,8 +55,8 @@ public class ReactorHttpHandlerAdapter
public Mono<Void> apply(HttpServerRequest request, HttpServerResponse response) { public Mono<Void> apply(HttpServerRequest request, HttpServerResponse response) {
NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(response.alloc()); NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(response.alloc());
ReactorServerHttpRequest adaptedRequest; ServerHttpRequest adaptedRequest;
ReactorServerHttpResponse adaptedResponse; ServerHttpResponse adaptedResponse;
try { try {
adaptedRequest = new ReactorServerHttpRequest(request, bufferFactory); adaptedRequest = new ReactorServerHttpRequest(request, bufferFactory);
adaptedResponse = new ReactorServerHttpResponse(response, bufferFactory); adaptedResponse = new ReactorServerHttpResponse(response, bufferFactory);
@ -66,6 +67,10 @@ public class ReactorHttpHandlerAdapter
return Mono.empty(); return Mono.empty();
} }
if (HttpMethod.HEAD.equals(adaptedRequest.getMethod())) {
adaptedResponse = new HttpHeadResponseDecorator(adaptedResponse);
}
return this.httpHandler.handle(adaptedRequest, adaptedResponse) return this.httpHandler.handle(adaptedRequest, adaptedResponse)
.onErrorResume(ex -> { .onErrorResume(ex -> {
logger.error("Could not complete request", ex); logger.error("Could not complete request", ex);

View File

@ -36,6 +36,7 @@ import org.reactivestreams.Subscription;
import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
@ -110,6 +111,10 @@ public class ServletHttpHandlerAdapter implements Servlet {
ServerHttpRequest httpRequest = createRequest(((HttpServletRequest) request), asyncContext); ServerHttpRequest httpRequest = createRequest(((HttpServletRequest) request), asyncContext);
ServerHttpResponse httpResponse = createResponse(((HttpServletResponse) response), asyncContext); ServerHttpResponse httpResponse = createResponse(((HttpServletResponse) response), asyncContext);
if (HttpMethod.HEAD.equals(httpRequest.getMethod())) {
httpResponse = new HttpHeadResponseDecorator(httpResponse);
}
asyncContext.addListener(ERROR_LISTENER); asyncContext.addListener(ERROR_LISTENER);
HandlerResultSubscriber subscriber = new HandlerResultSubscriber(asyncContext); HandlerResultSubscriber subscriber = new HandlerResultSubscriber(asyncContext);

View File

@ -25,6 +25,7 @@ import org.reactivestreams.Subscription;
import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.util.Assert; import org.springframework.util.Assert;
/** /**
@ -67,6 +68,10 @@ public class UndertowHttpHandlerAdapter implements io.undertow.server.HttpHandle
ServerHttpRequest request = new UndertowServerHttpRequest(exchange, getDataBufferFactory()); ServerHttpRequest request = new UndertowServerHttpRequest(exchange, getDataBufferFactory());
ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory()); ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory());
if (HttpMethod.HEAD.equals(request.getMethod())) {
response = new HttpHeadResponseDecorator(response);
}
HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange); HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange);
this.httpHandler.handle(request, response).subscribe(resultSubscriber); this.httpHandler.handle(request, response).subscribe(resultSubscriber);
} }

View File

@ -80,32 +80,6 @@ public class UndertowServerHttpResponse extends AbstractListenerServerHttpRespon
} }
} }
@Override
public Mono<Void> writeWith(File file, long position, long count) {
return doCommit(() -> {
FileChannel source = null;
try {
source = FileChannel.open(file.toPath(), StandardOpenOption.READ);
StreamSinkChannel destination = getUndertowExchange().getResponseChannel();
Channels.transferBlocking(destination, source, position, count);
return Mono.empty();
}
catch (IOException ex) {
return Mono.error(ex);
}
finally {
if (source != null) {
try {
source.close();
}
catch (IOException ex) {
// ignore
}
}
}
});
}
@Override @Override
protected void applyHeaders() { protected void applyHeaders() {
for (Map.Entry<String, List<String>> entry : getHeaders().entrySet()) { for (Map.Entry<String, List<String>> entry : getHeaders().entrySet()) {
@ -135,6 +109,33 @@ public class UndertowServerHttpResponse extends AbstractListenerServerHttpRespon
} }
} }
@Override
public Mono<Void> writeWith(File file, long position, long count) {
return doCommit(() -> {
FileChannel source = null;
try {
source = FileChannel.open(file.toPath(), StandardOpenOption.READ);
StreamSinkChannel destination = getUndertowExchange().getResponseChannel();
Channels.transferBlocking(destination, source, position, count);
return Mono.empty();
}
catch (IOException ex) {
return Mono.error(ex);
}
finally {
if (source != null) {
try {
source.close();
}
catch (IOException ex) {
// ignore
}
}
}
});
}
@Override @Override
protected Processor<? super Publisher<? extends DataBuffer>, Void> createBodyFlushProcessor() { protected Processor<? super Publisher<? extends DataBuffer>, Void> createBodyFlushProcessor() {
return new ResponseBodyFlushProcessor(); return new ResponseBodyFlushProcessor();

View File

@ -23,6 +23,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.reactivestreams.Publisher; import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.NettyDataBufferFactory; import org.springframework.core.io.buffer.NettyDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
@ -64,8 +65,8 @@ public class RxNettyHttpHandlerAdapter implements RequestHandler<ByteBuf, ByteBu
NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(channel.alloc()); NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(channel.alloc());
InetSocketAddress remoteAddress = (InetSocketAddress) channel.remoteAddress(); InetSocketAddress remoteAddress = (InetSocketAddress) channel.remoteAddress();
RxNettyServerHttpRequest request; ServerHttpRequest request;
RxNettyServerHttpResponse response; ServerHttpResponse response;
try { try {
request = new RxNettyServerHttpRequest(nativeRequest, bufferFactory, remoteAddress); request = new RxNettyServerHttpRequest(nativeRequest, bufferFactory, remoteAddress);
response = new RxNettyServerHttpResponse(nativeResponse, bufferFactory); response = new RxNettyServerHttpResponse(nativeResponse, bufferFactory);
@ -76,6 +77,10 @@ public class RxNettyHttpHandlerAdapter implements RequestHandler<ByteBuf, ByteBu
return Observable.empty(); return Observable.empty();
} }
if (HttpMethod.HEAD.equals(request.getMethod())) {
response = new HttpHeadResponseDecorator(response);
}
Publisher<Void> result = this.httpHandler.handle(request, response) Publisher<Void> result = this.httpHandler.handle(request, response)
.onErrorResume(ex -> { .onErrorResume(ex -> {
logger.error("Could not complete request", ex); logger.error("Could not complete request", ex);

View File

@ -79,6 +79,15 @@ public class RequestMappingIntegrationTests extends AbstractRequestMappingIntegr
assertEquals(expected, performGet("/object-stream-result", MediaType.ALL, String.class).getBody()); assertEquals(expected, performGet("/object-stream-result", MediaType.ALL, String.class).getBody());
} }
@Test
public void httpHead() throws Exception {
String url = "http://localhost:" + this.port + "/param?name=George";
HttpHeaders headers = getRestTemplate().headForHeaders(url);
String contentType = headers.getFirst("Content-Type");
assertNotNull(contentType);
assertEquals("text/html;charset=utf-8", contentType.toLowerCase());
assertEquals(13, headers.getContentLength());
}
@Configuration @Configuration
@EnableWebFlux @EnableWebFlux

View File

@ -811,10 +811,10 @@ You can also use the same with request header conditions:
==== HTTP HEAD, OPTIONS ==== HTTP HEAD, OPTIONS
[.small]#<<web.adoc#mvc-ann-requestmapping-head-options,Same in Spring MVC>># [.small]#<<web.adoc#mvc-ann-requestmapping-head-options,Same in Spring MVC>>#
`@GetMapping` -- and also `@RequestMapping(method=HttpMethod.GET)`, are implicitly mapped to `@GetMapping` -- and also `@RequestMapping(method=HttpMethod.GET)`, support HTTP HEAD
and also support HTTP HEAD. An HTTP HEAD request is processed as if it were HTTP GET except transparently for request mapping purposes. Controller methods don't need to change.
but instead of writing the body, the number of bytes are counted and the "Content-Length" A response wrapper, applied in the `HttpHandler` server adapter, ensures a `"Content-Length"`
header set. header is set to the number of bytes written and without actually writing to the response.
By default HTTP OPTIONS is handled by setting the "Allow" response header to the list of HTTP By default HTTP OPTIONS is handled by setting the "Allow" response header to the list of HTTP
methods listed in all `@RequestMapping` methods with matching URL patterns. methods listed in all `@RequestMapping` methods with matching URL patterns.

View File

@ -852,6 +852,11 @@ instead.
==== HTTP HEAD and OPTIONS ==== HTTP HEAD and OPTIONS
[.small]#<<web-reactive.adoc#webflux-ann-requestmapping-head-options,Same in Spring WebFlux>># [.small]#<<web-reactive.adoc#webflux-ann-requestmapping-head-options,Same in Spring WebFlux>>#
`@GetMapping` -- and also `@RequestMapping(method=HttpMethod.GET)`, support HTTP HEAD
transparently for request mapping purposes. Controller methods don't need to change.
A response wrapper, applied in `javax.servlet.http.HttpServlet`, ensures a `"Content-Length"`
header is set to the number of bytes written and without actually writing to the response.
`@GetMapping` -- and also `@RequestMapping(method=HttpMethod.GET)`, are implicitly mapped to `@GetMapping` -- and also `@RequestMapping(method=HttpMethod.GET)`, are implicitly mapped to
and also support HTTP HEAD. An HTTP HEAD request is processed as if it were HTTP GET except and also support HTTP HEAD. An HTTP HEAD request is processed as if it were HTTP GET except
but instead of writing the body, the number of bytes are counted and the "Content-Length" but instead of writing the body, the number of bytes are counted and the "Content-Length"