diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ServerDowngrader.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ServerDowngrader.java new file mode 100644 index 0000000000..189e2cd52c --- /dev/null +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ServerDowngrader.java @@ -0,0 +1,126 @@ +/* + * Copyright 2016 The Netty Project + * + * The Netty Project licenses this file to you 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 io.netty.handler.codec.http2; + +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToMessageCodec; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultLastHttpContent; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpObject; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.util.ReferenceCountUtil; + +import java.util.List; + +/** + * This is a server-side adapter so that an http2 codec can be downgraded to + * appear as if it's speaking http/1.1. + * + * In particular, this handler converts from {@link Http2StreamFrame} to {@link + * HttpObject}, and back. For simplicity, it converts to chunked encoding + * unless the entire stream is a single header. + */ +public class Http2ServerDowngrader extends MessageToMessageCodec { + + private final boolean validateHeaders; + + public Http2ServerDowngrader(boolean validateHeaders) { + this.validateHeaders = validateHeaders; + } + + public Http2ServerDowngrader() { + this(true); + } + + @Override + public boolean acceptInboundMessage(Object msg) throws Exception { + return (msg instanceof Http2HeadersFrame) || (msg instanceof Http2DataFrame); + } + + @Override + protected void decode(ChannelHandlerContext ctx, Http2StreamFrame frame, List out) throws Exception { + if (frame instanceof Http2HeadersFrame) { + int id = 0; // not really the id + Http2HeadersFrame headersFrame = (Http2HeadersFrame) frame; + Http2Headers headers = headersFrame.headers(); + + if (headersFrame.isEndStream()) { + if (headers.method() == null) { + LastHttpContent last = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER, validateHeaders); + HttpConversionUtil.addHttp2ToHttpHeaders(id, headers, last.trailingHeaders(), + HttpVersion.HTTP_1_1, true, true); + out.add(last); + } else { + FullHttpRequest full = HttpConversionUtil.toFullHttpRequest(id, headers, ctx.alloc(), + validateHeaders); + out.add(full); + } + } else { + out.add(HttpConversionUtil.toHttpRequest(id, headersFrame.headers(), validateHeaders)); + } + + } else if (frame instanceof Http2DataFrame) { + Http2DataFrame dataFrame = (Http2DataFrame) frame; + if (dataFrame.isEndStream()) { + out.add(new DefaultLastHttpContent(dataFrame.content(), validateHeaders)); + } else { + out.add(new DefaultHttpContent(dataFrame.content())); + } + } + ReferenceCountUtil.retain(frame); + } + + private void encodeLastContent(LastHttpContent last, List out) { + boolean needFiller = !(last instanceof FullHttpResponse) && last.trailingHeaders().isEmpty(); + if (last.content().isReadable() || needFiller) { + out.add(new DefaultHttp2DataFrame(last.content(), last.trailingHeaders().isEmpty())); + } + if (!last.trailingHeaders().isEmpty()) { + Http2Headers headers = HttpConversionUtil.toHttp2Headers(last.trailingHeaders(), validateHeaders); + out.add(new DefaultHttp2HeadersFrame(headers, true)); + } + } + + @Override + protected void encode(ChannelHandlerContext ctx, HttpObject obj, List out) throws Exception { + if (obj instanceof HttpResponse) { + Http2Headers headers = HttpConversionUtil.toHttp2Headers((HttpResponse) obj, validateHeaders); + boolean noMoreFrames = false; + if (obj instanceof FullHttpResponse) { + FullHttpResponse full = (FullHttpResponse) obj; + noMoreFrames = !full.content().isReadable() && full.trailingHeaders().isEmpty(); + } + + out.add(new DefaultHttp2HeadersFrame(headers, noMoreFrames)); + } + + if (obj instanceof LastHttpContent) { + LastHttpContent last = (LastHttpContent) obj; + encodeLastContent(last, out); + } else if (obj instanceof HttpContent) { + HttpContent cont = (HttpContent) obj; + out.add(new DefaultHttp2DataFrame(cont.content(), false)); + } + ReferenceCountUtil.retain(obj); + } +} diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java index 1d24bc069f..056aafc92e 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java @@ -17,6 +17,7 @@ package io.netty.handler.codec.http2; import io.netty.buffer.ByteBufAllocator; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.DefaultHttpRequest; import io.netty.handler.codec.http.FullHttpMessage; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; @@ -230,7 +231,7 @@ public final class HttpConversionUtil { * @return A new request object which represents headers/data * @throws Http2Exception see {@link #addHttp2ToHttpHeaders(int, Http2Headers, FullHttpMessage, boolean)} */ - public static FullHttpRequest toHttpRequest(int streamId, Http2Headers http2Headers, ByteBufAllocator alloc, + public static FullHttpRequest toFullHttpRequest(int streamId, Http2Headers http2Headers, ByteBufAllocator alloc, boolean validateHttpHeaders) throws Http2Exception { // HTTP/2 does not define a way to carry the version identifier that is included in the HTTP/1.1 request line. @@ -252,6 +253,37 @@ public final class HttpConversionUtil { return msg; } + /** + * Create a new object to contain the request data. + * + * @param streamId The stream associated with the request + * @param http2Headers The initial set of HTTP/2 headers to create the request with + * @param validateHttpHeaders
    + *
  • {@code true} to validate HTTP headers in the http-codec
  • + *
  • {@code false} not to validate HTTP headers in the http-codec
  • + *
+ * @return A new request object which represents headers for a chunked request + * @throws Http2Exception see {@link #addHttp2ToHttpHeaders(int, Http2Headers, FullHttpMessage, boolean)} + */ + public static HttpRequest toHttpRequest(int streamId, Http2Headers http2Headers, boolean validateHttpHeaders) + throws Http2Exception { + // HTTP/2 does not define a way to carry the version identifier that is included in the HTTP/1.1 request line. + final CharSequence method = checkNotNull(http2Headers.method(), + "method header cannot be null in conversion to HTTP/1.x"); + final CharSequence path = checkNotNull(http2Headers.path(), + "path header cannot be null in conversion to HTTP/1.x"); + HttpRequest msg = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.valueOf(method.toString()), + path.toString(), validateHttpHeaders); + try { + addHttp2ToHttpHeaders(streamId, http2Headers, msg.headers(), msg.protocolVersion(), false, true); + } catch (Http2Exception e) { + throw e; + } catch (Throwable t) { + throw streamError(streamId, PROTOCOL_ERROR, t, "HTTP/2 to HTTP/1.x headers conversion error"); + } + return msg; + } + /** * Translate and add HTTP/2 headers to HTTP/1.x headers. * diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapter.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapter.java index c67ffaecb0..2fec4a5c05 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapter.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapter.java @@ -152,7 +152,7 @@ public class InboundHttp2ToHttpAdapter extends Http2EventAdapter { protected FullHttpMessage newMessage(Http2Stream stream, Http2Headers headers, boolean validateHttpHeaders, ByteBufAllocator alloc) throws Http2Exception { - return connection.isServer() ? HttpConversionUtil.toHttpRequest(stream.id(), headers, alloc, + return connection.isServer() ? HttpConversionUtil.toFullHttpRequest(stream.id(), headers, alloc, validateHttpHeaders) : HttpConversionUtil.toHttpResponse(stream.id(), headers, alloc, validateHttpHeaders); } diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2ServerDowngraderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2ServerDowngraderTest.java new file mode 100644 index 0000000000..e7ca6702e7 --- /dev/null +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2ServerDowngraderTest.java @@ -0,0 +1,317 @@ +/* + * Copyright 2016 The Netty Project + * + * The Netty Project licenses this file to you 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 io.netty.handler.codec.http2; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.DefaultLastHttpContent; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.util.CharsetUtil; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +public class Http2ServerDowngraderTest { + + @Test + public void testUpgradeEmptyFullResponse() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + assertTrue(ch.writeOutbound(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK))); + + Http2HeadersFrame headersFrame = ch.readOutbound(); + assertThat(headersFrame.headers().status().toString(), is("200")); + assertTrue(headersFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeNonEmptyFullResponse() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + assertTrue(ch.writeOutbound(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, hello))); + + Http2HeadersFrame headersFrame = ch.readOutbound(); + assertThat(headersFrame.headers().status().toString(), is("200")); + assertFalse(headersFrame.isEndStream()); + + Http2DataFrame dataFrame = ch.readOutbound(); + assertThat(dataFrame.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertTrue(dataFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeEmptyFullResponseWithTrailers() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + HttpHeaders trailers = response.trailingHeaders(); + trailers.set("key", "value"); + assertTrue(ch.writeOutbound(response)); + + Http2HeadersFrame headersFrame = ch.readOutbound(); + assertThat(headersFrame.headers().status().toString(), is("200")); + assertFalse(headersFrame.isEndStream()); + + Http2HeadersFrame trailersFrame = ch.readOutbound(); + assertThat(trailersFrame.headers().get("key").toString(), is("value")); + assertTrue(trailersFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeNonEmptyFullResponseWithTrailers() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, hello); + HttpHeaders trailers = response.trailingHeaders(); + trailers.set("key", "value"); + assertTrue(ch.writeOutbound(response)); + + Http2HeadersFrame headersFrame = ch.readOutbound(); + assertThat(headersFrame.headers().status().toString(), is("200")); + assertFalse(headersFrame.isEndStream()); + + Http2DataFrame dataFrame = ch.readOutbound(); + assertThat(dataFrame.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertFalse(dataFrame.isEndStream()); + + Http2HeadersFrame trailersFrame = ch.readOutbound(); + assertThat(trailersFrame.headers().get("key").toString(), is("value")); + assertTrue(trailersFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeHeaders() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + assertTrue(ch.writeOutbound(response)); + + Http2HeadersFrame headersFrame = ch.readOutbound(); + assertThat(headersFrame.headers().status().toString(), is("200")); + assertFalse(headersFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeChunk() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + HttpContent content = new DefaultHttpContent(hello); + assertTrue(ch.writeOutbound(content)); + + Http2DataFrame dataFrame = ch.readOutbound(); + assertThat(dataFrame.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertFalse(dataFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeEmptyEnd() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + LastHttpContent end = LastHttpContent.EMPTY_LAST_CONTENT; + assertTrue(ch.writeOutbound(end)); + + Http2DataFrame emptyFrame = ch.readOutbound(); + assertThat(emptyFrame.content().readableBytes(), is(0)); + assertTrue(emptyFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeDataEnd() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + LastHttpContent end = new DefaultLastHttpContent(hello, true); + assertTrue(ch.writeOutbound(end)); + + Http2DataFrame dataFrame = ch.readOutbound(); + assertThat(dataFrame.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertTrue(dataFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeTrailers() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + LastHttpContent trailers = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER, true); + HttpHeaders headers = trailers.trailingHeaders(); + headers.set("key", "value"); + assertTrue(ch.writeOutbound(trailers)); + + Http2HeadersFrame headerFrame = ch.readOutbound(); + assertThat(headerFrame.headers().get("key").toString(), is("value")); + assertTrue(headerFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testUpgradeDataEndWithTrailers() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + LastHttpContent trailers = new DefaultLastHttpContent(hello, true); + HttpHeaders headers = trailers.trailingHeaders(); + headers.set("key", "value"); + assertTrue(ch.writeOutbound(trailers)); + + Http2DataFrame dataFrame = ch.readOutbound(); + assertThat(dataFrame.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertFalse(dataFrame.isEndStream()); + + Http2HeadersFrame headerFrame = ch.readOutbound(); + assertThat(headerFrame.headers().get("key").toString(), is("value")); + assertTrue(headerFrame.isEndStream()); + + assertThat(ch.readOutbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testDowngradeHeaders() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + Http2Headers headers = new DefaultHttp2Headers(); + headers.path("/"); + headers.method("GET"); + + assertTrue(ch.writeInbound(new DefaultHttp2HeadersFrame(headers))); + + HttpRequest request = ch.readInbound(); + assertThat(request.uri(), is("/")); + assertThat(request.method(), is(HttpMethod.GET)); + assertThat(request.protocolVersion(), is(HttpVersion.HTTP_1_1)); + assertFalse(request instanceof FullHttpRequest); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testDowngradeFullHeaders() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + Http2Headers headers = new DefaultHttp2Headers(); + headers.path("/"); + headers.method("GET"); + + assertTrue(ch.writeInbound(new DefaultHttp2HeadersFrame(headers, true))); + + FullHttpRequest request = ch.readInbound(); + assertThat(request.uri(), is("/")); + assertThat(request.method(), is(HttpMethod.GET)); + assertThat(request.protocolVersion(), is(HttpVersion.HTTP_1_1)); + assertThat(request.content().readableBytes(), is(0)); + assertTrue(request.trailingHeaders().isEmpty()); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testDowngradeTrailers() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + Http2Headers headers = new DefaultHttp2Headers(); + headers.set("key", "value"); + assertTrue(ch.writeInbound(new DefaultHttp2HeadersFrame(headers, true))); + + LastHttpContent trailers = ch.readInbound(); + assertThat(trailers.content().readableBytes(), is(0)); + assertThat(trailers.trailingHeaders().get("key").toString(), is("value")); + assertFalse(trailers instanceof FullHttpRequest); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testDowngradeData() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + assertTrue(ch.writeInbound(new DefaultHttp2DataFrame(hello))); + + HttpContent content = ch.readInbound(); + assertThat(content.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertFalse(content instanceof LastHttpContent); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testDowngradeEndData() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + ByteBuf hello = Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8); + assertTrue(ch.writeInbound(new DefaultHttp2DataFrame(hello, true))); + + LastHttpContent content = ch.readInbound(); + assertThat(content.content().toString(CharsetUtil.UTF_8), is("hello world")); + assertTrue(content.trailingHeaders().isEmpty()); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } + + @Test + public void testPassThroughOther() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(new Http2ServerDowngrader()); + Http2ResetFrame reset = new DefaultHttp2ResetFrame(0); + Http2GoAwayFrame goaway = new DefaultHttp2GoAwayFrame(0); + assertTrue(ch.writeInbound(reset)); + assertTrue(ch.writeInbound(goaway)); + + assertEquals(ch.readInbound(), reset); + assertEquals(ch.readInbound(), goaway); + + assertThat(ch.readInbound(), is(nullValue())); + assertFalse(ch.finish()); + } +}