From 6ee1af27c61c015461f79f95f1e4f89db911be0c Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 27 Sep 2017 14:57:56 -0400 Subject: [PATCH] WebFlux supports HTTP HEAD Issue: SPR-15994 --- .../reactive/server/HttpHandlerConnector.java | 10 ++- .../reactive/HttpHeadResponseDecorator.java | 75 +++++++++++++++++++ .../reactive/ReactorHttpHandlerAdapter.java | 9 ++- .../reactive/ServletHttpHandlerAdapter.java | 5 ++ .../reactive/UndertowHttpHandlerAdapter.java | 5 ++ .../reactive/UndertowServerHttpResponse.java | 53 ++++++------- .../reactive/RxNettyHttpHandlerAdapter.java | 9 ++- .../RequestMappingIntegrationTests.java | 9 +++ src/docs/asciidoc/web/webflux.adoc | 8 +- src/docs/asciidoc/web/webmvc.adoc | 5 ++ 10 files changed, 153 insertions(+), 35 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/HttpHeadResponseDecorator.java diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java index 9ed618a2dd..40f356fa0c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java @@ -36,7 +36,9 @@ import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpResponse; 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.ServerHttpResponse; import org.springframework.mock.http.client.reactive.MockClientHttpRequest; import org.springframework.mock.http.client.reactive.MockClientHttpResponse; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; @@ -84,7 +86,8 @@ public class HttpHandlerConnector implements ClientHttpConnector { mockClientRequest.setWriteHandler(requestBody -> { log("Invoking HttpHandler for ", httpMethod, uri); 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(); }); @@ -114,6 +117,11 @@ public class HttpHandlerConnector implements ClientHttpConnector { 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 body) { HttpStatus status = Optional.ofNullable(response.getStatusCode()).orElse(HttpStatus.OK); MockClientHttpResponse clientResponse = new MockClientHttpResponse(status); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/HttpHeadResponseDecorator.java b/spring-web/src/main/java/org/springframework/http/server/reactive/HttpHeadResponseDecorator.java new file mode 100644 index 0000000000..f618687743 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/HttpHeadResponseDecorator.java @@ -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 writeWith(Publisher 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. + * + *

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 writeAndFlushWith(Publisher> body) { + // Not feasible to count bytes on potentially streaming response. + // RFC 7302 allows HEAD without content-length. + return setComplete(); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java index ca8c516411..52f6be609c 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java @@ -27,6 +27,7 @@ import reactor.ipc.netty.http.server.HttpServerResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpMethod; import org.springframework.util.Assert; /** @@ -54,8 +55,8 @@ public class ReactorHttpHandlerAdapter public Mono apply(HttpServerRequest request, HttpServerResponse response) { NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(response.alloc()); - ReactorServerHttpRequest adaptedRequest; - ReactorServerHttpResponse adaptedResponse; + ServerHttpRequest adaptedRequest; + ServerHttpResponse adaptedResponse; try { adaptedRequest = new ReactorServerHttpRequest(request, bufferFactory); adaptedResponse = new ReactorServerHttpResponse(response, bufferFactory); @@ -66,6 +67,10 @@ public class ReactorHttpHandlerAdapter return Mono.empty(); } + if (HttpMethod.HEAD.equals(adaptedRequest.getMethod())) { + adaptedResponse = new HttpHeadResponseDecorator(adaptedResponse); + } + return this.httpHandler.handle(adaptedRequest, adaptedResponse) .onErrorResume(ex -> { logger.error("Could not complete request", ex); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java index e984d5f24b..0d889968c7 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java @@ -36,6 +36,7 @@ import org.reactivestreams.Subscription; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpMethod; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -110,6 +111,10 @@ public class ServletHttpHandlerAdapter implements Servlet { ServerHttpRequest httpRequest = createRequest(((HttpServletRequest) request), asyncContext); ServerHttpResponse httpResponse = createResponse(((HttpServletResponse) response), asyncContext); + if (HttpMethod.HEAD.equals(httpRequest.getMethod())) { + httpResponse = new HttpHeadResponseDecorator(httpResponse); + } + asyncContext.addListener(ERROR_LISTENER); HandlerResultSubscriber subscriber = new HandlerResultSubscriber(asyncContext); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java index 935e873b7c..8e5ce8a9da 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java @@ -25,6 +25,7 @@ import org.reactivestreams.Subscription; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpMethod; import org.springframework.util.Assert; /** @@ -67,6 +68,10 @@ public class UndertowHttpHandlerAdapter implements io.undertow.server.HttpHandle ServerHttpRequest request = new UndertowServerHttpRequest(exchange, getDataBufferFactory()); ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory()); + if (HttpMethod.HEAD.equals(request.getMethod())) { + response = new HttpHeadResponseDecorator(response); + } + HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange); this.httpHandler.handle(request, response).subscribe(resultSubscriber); } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java index becbb59715..854fa4c5a8 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java @@ -80,32 +80,6 @@ public class UndertowServerHttpResponse extends AbstractListenerServerHttpRespon } } - @Override - public Mono 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 protected void applyHeaders() { for (Map.Entry> entry : getHeaders().entrySet()) { @@ -135,6 +109,33 @@ public class UndertowServerHttpResponse extends AbstractListenerServerHttpRespon } } + @Override + public Mono 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 protected Processor, Void> createBodyFlushProcessor() { return new ResponseBodyFlushProcessor(); diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java b/spring-web/src/test/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java index a755ab41c8..feec5d46f1 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java @@ -23,6 +23,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpMethod; import org.springframework.util.Assert; import io.netty.buffer.ByteBuf; @@ -64,8 +65,8 @@ public class RxNettyHttpHandlerAdapter implements RequestHandler result = this.httpHandler.handle(request, response) .onErrorResume(ex -> { logger.error("Could not complete request", ex); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java index 0ce229dd14..4e0a1c40a9 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java @@ -79,6 +79,15 @@ public class RequestMappingIntegrationTests extends AbstractRequestMappingIntegr 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 @EnableWebFlux diff --git a/src/docs/asciidoc/web/webflux.adoc b/src/docs/asciidoc/web/webflux.adoc index f93ce0c36e..bba75eb32f 100644 --- a/src/docs/asciidoc/web/webflux.adoc +++ b/src/docs/asciidoc/web/webflux.adoc @@ -811,10 +811,10 @@ You can also use the same with request header conditions: ==== HTTP HEAD, OPTIONS [.small]#<># -`@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 -but instead of writing the body, the number of bytes are counted and the "Content-Length" -header set. +`@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 the `HttpHandler` server adapter, ensures a `"Content-Length"` +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 methods listed in all `@RequestMapping` methods with matching URL patterns. diff --git a/src/docs/asciidoc/web/webmvc.adoc b/src/docs/asciidoc/web/webmvc.adoc index b6bd381d0d..be2732f3a9 100644 --- a/src/docs/asciidoc/web/webmvc.adoc +++ b/src/docs/asciidoc/web/webmvc.adoc @@ -852,6 +852,11 @@ instead. ==== HTTP HEAD and OPTIONS [.small]#<># +`@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 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"